Skip to content

Commit e963e8f

Browse files
baseballyamaantfu
andauthored
fix: fallback to structured-clone for RPC error envelopes (#8)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent 8e90d6d commit e963e8f

14 files changed

Lines changed: 536 additions & 42 deletions

File tree

packages/devframe/src/client/static-rpc.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { RpcDumpRecordError } from '../rpc/types'
2+
import { reviveDumpError } from '../rpc/dump-error'
13
import { hash } from '../utils/hash'
24
import { structuredCloneDeserialize } from '../utils/structured-clone'
35

@@ -28,10 +30,7 @@ export type StaticRpcManifest = Record<string, StaticRpcManifestEntry>
2830
export interface StaticRpcRecord {
2931
inputs?: any[]
3032
output?: any
31-
error?: {
32-
message: string
33-
name: string
34-
}
33+
error?: RpcDumpRecordError
3534
}
3635

3736
function isStaticEntry(value: unknown): value is StaticRpcManifestStaticEntry {
@@ -56,11 +55,8 @@ function isRecord(value: unknown): value is StaticRpcRecord {
5655
}
5756

5857
function resolveRecordOutput(record: StaticRpcRecord): any {
59-
if (record.error) {
60-
const error = new Error(record.error.message)
61-
error.name = record.error.name
62-
throw error
63-
}
58+
if (record.error)
59+
throw reviveDumpError(record.error)
6460
return record.output
6561
}
6662

packages/devframe/src/node/__tests__/static-dump.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,106 @@ describe('collectStaticRpcDump', () => {
216216
.toThrowError(/jsonSerializable: true.*is a Map/)
217217
})
218218
})
219+
220+
describe('error-bearing records', () => {
221+
it('writes JSON-safe error shape (message + name + cause) for jsonSerializable: true', async () => {
222+
const flaky = defineRpcFunction({
223+
name: 'test:flaky',
224+
type: 'query',
225+
jsonSerializable: true,
226+
handler: () => {
227+
throw new TypeError('boom', { cause: new Error('inner') })
228+
},
229+
dump: {
230+
inputs: [[]] as [][],
231+
},
232+
})
233+
234+
const result = await collectStaticRpcDump([flaky], {})
235+
const entry = result.manifest['test:flaky'] as {
236+
records: Record<string, string>
237+
serialization: 'json'
238+
}
239+
240+
expect(entry.serialization).toBe('json')
241+
242+
const recordPath = Object.values(entry.records)[0]!
243+
const file = result.files[recordPath]!
244+
expect(file.serialization).toBe('json')
245+
246+
// serializeDumpError flattens Error.cause into a plain object, so
247+
// strict-JSON encoding succeeds without any per-record promotion.
248+
const wireText = strictJsonStringify(file.data, file.fnName)
249+
const parsed = JSON.parse(wireText) as {
250+
error: { name: string, message: string, cause: { name: string, message: string } }
251+
}
252+
expect(parsed.error.name).toBe('TypeError')
253+
expect(parsed.error.message).toBe('boom')
254+
expect(parsed.error.cause.name).toBe('Error')
255+
expect(parsed.error.cause.message).toBe('inner')
256+
})
257+
258+
it('preserves rich error info end-to-end for default (structured-clone) entries', async () => {
259+
const tags = new Map<string, number>([['a', 1]])
260+
const flaky = defineRpcFunction({
261+
name: 'test:flaky-roundtrip',
262+
type: 'query',
263+
// default jsonSerializable: false → structured-clone shards
264+
handler: () => {
265+
const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown }
266+
err.tags = tags
267+
throw err
268+
},
269+
dump: {
270+
inputs: [[]] as [][],
271+
},
272+
})
273+
274+
const result = await collectStaticRpcDump([flaky], {})
275+
const entry = result.manifest['test:flaky-roundtrip'] as {
276+
records: Record<string, string>
277+
serialization: 'structured-clone'
278+
}
279+
expect(entry.serialization).toBe('structured-clone')
280+
281+
const recordPath = Object.values(entry.records)[0]!
282+
const file = result.files[recordPath]!
283+
const revived = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(file.data))) as {
284+
error: { name: string, message: string, cause: { message: string }, tags: Map<string, number> }
285+
}
286+
expect(revived.error.name).toBe('TypeError')
287+
expect(revived.error.message).toBe('boom')
288+
expect(revived.error.cause.message).toBe('inner')
289+
expect(revived.error.tags).toBeInstanceOf(Map)
290+
expect(revived.error.tags.get('a')).toBe(1)
291+
})
292+
293+
it('throws DF0020 when a jsonSerializable: true function attaches non-JSON to an error', async () => {
294+
const flaky = defineRpcFunction({
295+
name: 'test:flaky-non-json',
296+
type: 'query',
297+
jsonSerializable: true,
298+
handler: () => {
299+
const err = new Error('boom') as Error & { tags?: unknown }
300+
err.tags = new Map([['a', 1]])
301+
throw err
302+
},
303+
dump: {
304+
inputs: [[]] as [][],
305+
},
306+
})
307+
308+
const result = await collectStaticRpcDump([flaky], {})
309+
const recordPath = Object.values(
310+
(result.manifest['test:flaky-non-json'] as { records: Record<string, string> }).records,
311+
)[0]!
312+
const file = result.files[recordPath]!
313+
314+
// Attaching a Map to the thrown Error violates the `jsonSerializable: true`
315+
// contract; the strict serializer surfaces this at build time, same as
316+
// if the function had returned a Map.
317+
expect(() => strictJsonStringify(file.data, file.fnName))
318+
.toThrowError(/jsonSerializable: true.*is a Map/)
319+
})
320+
})
219321
})

packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,47 @@ describe('mcp adapter (in-memory)', () => {
8080
}
8181
})
8282

83+
it('coerces non-JSON values returned from a tool', async () => {
84+
const { ctx, client, cleanup } = await bootPair()
85+
try {
86+
ctx.agent.registerTool({
87+
id: 'rich',
88+
description: 'Returns BigInt + Date.',
89+
handler: () => ({ count: 42n, when: new Date(0) }),
90+
})
91+
92+
const result = await client.callTool({ name: 'rich', arguments: {} })
93+
const content = result.content as Array<{ type: string, text: string }>
94+
expect(content[0]!.text).toContain('"42n"')
95+
expect(content[0]!.text).toContain('1970-01-01T00:00:00.000Z')
96+
}
97+
finally {
98+
await cleanup()
99+
}
100+
})
101+
102+
it('surfaces Error name and cause when a tool throws', async () => {
103+
const { ctx, client, cleanup } = await bootPair()
104+
try {
105+
ctx.agent.registerTool({
106+
id: 'crash',
107+
description: 'Throws.',
108+
handler: () => {
109+
throw new TypeError('boom', { cause: new Error('inner') })
110+
},
111+
})
112+
113+
const result = await client.callTool({ name: 'crash', arguments: {} })
114+
expect(result.isError).toBe(true)
115+
const content = result.content as Array<{ type: string, text: string }>
116+
expect(content[0]!.text).toContain('TypeError: boom')
117+
expect(content[0]!.text).toContain('cause: inner')
118+
}
119+
finally {
120+
await cleanup()
121+
}
122+
})
123+
83124
it('lists and reads registered resources', async () => {
84125
const { ctx, client, cleanup } = await bootPair()
85126
try {
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { formatMcpError, stringifyForMcp } from '../stringify'
3+
4+
describe('stringifyForMcp', () => {
5+
it('returns "undefined" sentinel for undefined', () => {
6+
expect(stringifyForMcp(undefined)).toBe('undefined')
7+
})
8+
9+
it('passes strings through unchanged', () => {
10+
expect(stringifyForMcp('hello')).toBe('hello')
11+
})
12+
13+
it('serializes plain JSON-safe objects with indentation', () => {
14+
expect(stringifyForMcp({ a: 1, b: 'two' })).toBe('{\n "a": 1,\n "b": "two"\n}')
15+
})
16+
17+
it('coerces BigInt to a trailing-n string', () => {
18+
expect(JSON.parse(stringifyForMcp({ count: 42n }))).toEqual({ count: '42n' })
19+
})
20+
21+
it('coerces Date to ISO string via toJSON', () => {
22+
expect(JSON.parse(stringifyForMcp({ when: new Date(0) }))).toEqual({
23+
when: '1970-01-01T00:00:00.000Z',
24+
})
25+
})
26+
27+
it('coerces Map to a tagged entries object', () => {
28+
const value = new Map<string, number>([['a', 1], ['b', 2]])
29+
expect(JSON.parse(stringifyForMcp(value))).toEqual({
30+
__type: 'Map',
31+
entries: [['a', 1], ['b', 2]],
32+
})
33+
})
34+
35+
it('coerces Set to a tagged entries object', () => {
36+
const value = new Set(['x', 'y'])
37+
expect(JSON.parse(stringifyForMcp(value))).toEqual({
38+
__type: 'Set',
39+
entries: ['x', 'y'],
40+
})
41+
})
42+
43+
it('serializes Error with name, message, stack, and cause', () => {
44+
const inner = new Error('inner')
45+
const outer = new TypeError('boom', { cause: inner })
46+
const parsed = JSON.parse(stringifyForMcp(outer))
47+
expect(parsed.name).toBe('TypeError')
48+
expect(parsed.message).toBe('boom')
49+
expect(typeof parsed.stack).toBe('string')
50+
expect(parsed.cause.name).toBe('Error')
51+
expect(parsed.cause.message).toBe('inner')
52+
})
53+
54+
it('coerces Function to a readable token', () => {
55+
function namedFn() {}
56+
expect(JSON.parse(stringifyForMcp({ fn: namedFn }))).toEqual({
57+
fn: '[Function: namedFn]',
58+
})
59+
})
60+
61+
it('coerces anonymous functions', () => {
62+
expect(JSON.parse(stringifyForMcp({ fn: () => {} }))).toEqual({
63+
fn: '[Function: fn]',
64+
})
65+
})
66+
67+
it('coerces Symbol to its description', () => {
68+
expect(JSON.parse(stringifyForMcp({ s: Symbol('hi') }))).toEqual({
69+
s: 'Symbol(hi)',
70+
})
71+
})
72+
73+
it('replaces circular refs with [Circular]', () => {
74+
const obj: Record<string, unknown> = { name: 'root' }
75+
obj.self = obj
76+
const parsed = JSON.parse(stringifyForMcp(obj))
77+
expect(parsed.name).toBe('root')
78+
expect(parsed.self).toBe('[Circular]')
79+
})
80+
81+
it('handles a mixed payload end-to-end', () => {
82+
const value = {
83+
count: 42n,
84+
when: new Date(0),
85+
tags: new Set(['a', 'b']),
86+
}
87+
const text = stringifyForMcp(value)
88+
expect(text).toContain('"42n"')
89+
expect(text).toContain('1970-01-01T00:00:00.000Z')
90+
expect(text).toContain('"__type": "Set"')
91+
})
92+
})
93+
94+
describe('formatMcpError', () => {
95+
it('returns String(value) for non-Error throws', () => {
96+
expect(formatMcpError('boom')).toBe('boom')
97+
expect(formatMcpError(42)).toBe('42')
98+
})
99+
100+
it('formats an Error as "name: message"', () => {
101+
expect(formatMcpError(new TypeError('bad'))).toBe('TypeError: bad')
102+
})
103+
104+
it('appends cause.message for Error causes', () => {
105+
const err = new Error('outer', { cause: new Error('inner') })
106+
expect(formatMcpError(err)).toBe('Error: outer (cause: inner)')
107+
})
108+
109+
it('appends String(cause) for non-Error causes', () => {
110+
const err = new Error('outer', { cause: 'bad input' })
111+
expect(formatMcpError(err)).toBe('Error: outer (cause: bad input)')
112+
})
113+
})

packages/devframe/src/node/mcp/build-server.ts

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
import { join } from 'pathe'
1414
import { createHostContext } from '../context'
1515
import { logger } from '../diagnostics'
16+
import { formatMcpError, stringifyForMcp } from './stringify'
1617
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-schema'
1718

1819
export interface CreateMcpServerOptions {
@@ -158,7 +159,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void {
158159
content: [
159160
{
160161
type: 'text',
161-
text: stringify(result),
162+
text: stringifyForMcp(result),
162163
},
163164
],
164165
}
@@ -169,7 +170,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void {
169170
content: [
170171
{
171172
type: 'text',
172-
text: `Error invoking "${name}": ${error instanceof Error ? error.message : String(error)}`,
173+
text: `Error invoking "${name}": ${formatMcpError(error)}`,
173174
},
174175
],
175176
}
@@ -218,7 +219,7 @@ function registerResourceHandlers(
218219
{
219220
uri,
220221
mimeType: content.mimeType ?? 'application/json',
221-
text: content.text ?? stringify(content.json),
222+
text: content.text ?? stringifyForMcp(content.json),
222223
},
223224
],
224225
}
@@ -231,7 +232,7 @@ function registerResourceHandlers(
231232
{
232233
uri,
233234
mimeType: 'application/json',
234-
text: stringify(state.value()),
235+
text: stringifyForMcp(state.value()),
235236
},
236237
],
237238
}
@@ -287,16 +288,3 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin
287288
return { kind: 'resource', id: decoded }
288289
return { kind: 'state', key: decoded }
289290
}
290-
291-
function stringify(value: unknown): string {
292-
if (value === undefined)
293-
return 'undefined'
294-
if (typeof value === 'string')
295-
return value
296-
try {
297-
return JSON.stringify(value, null, 2)
298-
}
299-
catch {
300-
return String(value)
301-
}
302-
}

0 commit comments

Comments
 (0)