Skip to content

Commit 076ffe7

Browse files
committed
feat: preserve rich errors in dumps and MCP output
Extends the structured-clone error fallback shipped for the WS RPC transport to the dump and MCP surfaces: - Dumps capture `Error.cause` chains and own properties instead of reducing to `{ message, name }`. Static-dump promotes error-bearing records to structured-clone per-record (via new `recordSerializations`/`fallbackSerialization` manifest fields) so `jsonSerializable: true` functions still round-trip thrown errors losslessly. - MCP replaces the bare `JSON.stringify` with a coercing helper for `BigInt`/`Date`/`Map`/`Set`/`Error`/cycles, since MCP wire format is text-only and cannot use the `s:` structured-clone prefix. Error responses now include `name` and `cause.message`.
1 parent 1666fb1 commit 076ffe7

13 files changed

Lines changed: 535 additions & 44 deletions

File tree

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

Lines changed: 18 additions & 11 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

@@ -16,6 +18,15 @@ export interface StaticRpcManifestQueryEntry {
1618
fallback?: string
1719
/** Encoder used when each record/fallback file was written. Default: `'json'`. */
1820
serialization?: StaticRpcSerialization
21+
/**
22+
* Per-record encoder override. When a record file was written with a
23+
* different serializer than {@link serialization} (e.g. an error-bearing
24+
* record promoted to `'structured-clone'` for a `jsonSerializable: true`
25+
* function), the override is recorded here.
26+
*/
27+
recordSerializations?: Record<string, StaticRpcSerialization>
28+
/** Encoder override for the fallback shard. */
29+
fallbackSerialization?: StaticRpcSerialization
1930
}
2031

2132
export type StaticRpcManifestEntry
@@ -28,10 +39,7 @@ export type StaticRpcManifest = Record<string, StaticRpcManifestEntry>
2839
export interface StaticRpcRecord {
2940
inputs?: any[]
3041
output?: any
31-
error?: {
32-
message: string
33-
name: string
34-
}
42+
error?: RpcDumpRecordError
3543
}
3644

3745
function isStaticEntry(value: unknown): value is StaticRpcManifestStaticEntry {
@@ -56,11 +64,8 @@ function isRecord(value: unknown): value is StaticRpcRecord {
5664
}
5765

5866
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-
}
67+
if (record.error)
68+
throw reviveDumpError(record.error)
6469
return record.output
6570
}
6671

@@ -124,12 +129,14 @@ export function createStaticRpcCaller(
124129
const recordPath = entry.records[argsHash]
125130

126131
if (recordPath) {
127-
const record = await loadQueryRecord(recordPath, entry.serialization)
132+
const recordSerialization = entry.recordSerializations?.[argsHash] ?? entry.serialization
133+
const record = await loadQueryRecord(recordPath, recordSerialization)
128134
return resolveRecordOutput(record)
129135
}
130136

131137
if (entry.fallback) {
132-
const fallback = await loadQueryRecord(entry.fallback, entry.serialization)
138+
const fallbackSerialization = entry.fallbackSerialization ?? entry.serialization
139+
const fallback = await loadQueryRecord(entry.fallback, fallbackSerialization)
133140
return resolveRecordOutput(fallback)
134141
}
135142

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

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,4 +216,111 @@ describe('collectStaticRpcDump', () => {
216216
.toThrowError(/jsonSerializable: true.*is a Map/)
217217
})
218218
})
219+
220+
describe('error-bearing records', () => {
221+
it('promotes error-bearing query records to structured-clone for jsonSerializable: true', async () => {
222+
const flaky = defineRpcFunction({
223+
name: 'test:flaky',
224+
type: 'query',
225+
jsonSerializable: true,
226+
handler: (id: string) => {
227+
if (id === 'bad')
228+
throw new Error('boom')
229+
return { id }
230+
},
231+
dump: {
232+
inputs: [['good'], ['bad']],
233+
},
234+
})
235+
236+
const result = await collectStaticRpcDump([flaky], {})
237+
const entry = result.manifest['test:flaky'] as {
238+
type: 'query'
239+
records: Record<string, string>
240+
serialization: 'json'
241+
recordSerializations?: Record<string, 'json' | 'structured-clone'>
242+
}
243+
244+
expect(entry.serialization).toBe('json')
245+
expect(entry.recordSerializations).toBeDefined()
246+
247+
const erroredKeys = Object.keys(entry.recordSerializations!)
248+
expect(erroredKeys).toHaveLength(1)
249+
250+
const erroredPath = entry.records[erroredKeys[0]!]!
251+
expect(result.files[erroredPath]!.serialization).toBe('structured-clone')
252+
253+
const goodPath = entry.records[Object.keys(entry.records).find(k => k !== erroredKeys[0])!]!
254+
expect(result.files[goodPath]!.serialization).toBe('json')
255+
})
256+
257+
it('keeps the entry serialization on error-bearing records when default is structured-clone', async () => {
258+
const flaky = defineRpcFunction({
259+
name: 'test:flaky-sc',
260+
type: 'query',
261+
// default jsonSerializable: false → structured-clone shards
262+
handler: (id: string) => {
263+
if (id === 'bad')
264+
throw new Error('boom')
265+
return { id }
266+
},
267+
dump: {
268+
inputs: [['good'], ['bad']],
269+
},
270+
})
271+
272+
const result = await collectStaticRpcDump([flaky], {})
273+
const entry = result.manifest['test:flaky-sc'] as {
274+
serialization: 'json' | 'structured-clone'
275+
recordSerializations?: Record<string, 'json' | 'structured-clone'>
276+
}
277+
278+
expect(entry.serialization).toBe('structured-clone')
279+
// No overrides — everything is already SC.
280+
expect(entry.recordSerializations).toBeUndefined()
281+
})
282+
283+
it('preserves rich error info (cause + custom props) through a full read round-trip', async () => {
284+
const tags = new Map<string, number>([['a', 1]])
285+
const flaky = defineRpcFunction({
286+
name: 'test:flaky-roundtrip',
287+
type: 'query',
288+
jsonSerializable: true,
289+
handler: (id: string) => {
290+
if (id === 'bad') {
291+
const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown }
292+
err.tags = tags
293+
throw err
294+
}
295+
return { id }
296+
},
297+
dump: {
298+
inputs: [['bad']],
299+
},
300+
})
301+
302+
const result = await collectStaticRpcDump([flaky], {})
303+
const entry = result.manifest['test:flaky-roundtrip'] as {
304+
records: Record<string, string>
305+
recordSerializations?: Record<string, 'json' | 'structured-clone'>
306+
}
307+
308+
const recordKey = Object.keys(entry.records)[0]!
309+
const recordPath = entry.records[recordKey]!
310+
const file = result.files[recordPath]!
311+
312+
// The shard was promoted to structured-clone — verify the Error
313+
// (and its Map property) round-trips losslessly through the wire format.
314+
expect(file.serialization).toBe('structured-clone')
315+
const wireText = structuredCloneStringify(file.data)
316+
const revived = structuredCloneDeserialize(JSON.parse(wireText)) as {
317+
error: { name: string, message: string, cause: { message: string }, tags: Map<string, number> }
318+
}
319+
expect(revived.error.name).toBe('TypeError')
320+
expect(revived.error.message).toBe('boom')
321+
expect(revived.error.cause.message).toBe('inner')
322+
expect(revived.error.tags).toBeInstanceOf(Map)
323+
expect(revived.error.tags.get('a')).toBe(1)
324+
})
325+
})
219326
})

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)