Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 5 additions & 9 deletions packages/devframe/src/client/static-rpc.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { RpcDumpRecordError } from '../rpc/types'
import { reviveDumpError } from '../rpc/dump-error'
import { hash } from '../utils/hash'
import { structuredCloneDeserialize } from '../utils/structured-clone'

Expand Down Expand Up @@ -28,10 +30,7 @@ export type StaticRpcManifest = Record<string, StaticRpcManifestEntry>
export interface StaticRpcRecord {
inputs?: any[]
output?: any
error?: {
message: string
name: string
}
error?: RpcDumpRecordError
}

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

function resolveRecordOutput(record: StaticRpcRecord): any {
if (record.error) {
const error = new Error(record.error.message)
error.name = record.error.name
throw error
}
if (record.error)
throw reviveDumpError(record.error)
return record.output
}

Expand Down
102 changes: 102 additions & 0 deletions packages/devframe/src/node/__tests__/static-dump.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,4 +216,106 @@ describe('collectStaticRpcDump', () => {
.toThrowError(/jsonSerializable: true.*is a Map/)
})
})

describe('error-bearing records', () => {
it('writes JSON-safe error shape (message + name + cause) for jsonSerializable: true', async () => {
const flaky = defineRpcFunction({
name: 'test:flaky',
type: 'query',
jsonSerializable: true,
handler: () => {
throw new TypeError('boom', { cause: new Error('inner') })
},
dump: {
inputs: [[]] as [][],
},
})

const result = await collectStaticRpcDump([flaky], {})
const entry = result.manifest['test:flaky'] as {
records: Record<string, string>
serialization: 'json'
}

expect(entry.serialization).toBe('json')

const recordPath = Object.values(entry.records)[0]!
const file = result.files[recordPath]!
expect(file.serialization).toBe('json')

// serializeDumpError flattens Error.cause into a plain object, so
// strict-JSON encoding succeeds without any per-record promotion.
const wireText = strictJsonStringify(file.data, file.fnName)
const parsed = JSON.parse(wireText) as {
error: { name: string, message: string, cause: { name: string, message: string } }
}
expect(parsed.error.name).toBe('TypeError')
expect(parsed.error.message).toBe('boom')
expect(parsed.error.cause.name).toBe('Error')
expect(parsed.error.cause.message).toBe('inner')
})

it('preserves rich error info end-to-end for default (structured-clone) entries', async () => {
const tags = new Map<string, number>([['a', 1]])
const flaky = defineRpcFunction({
name: 'test:flaky-roundtrip',
type: 'query',
// default jsonSerializable: false → structured-clone shards
handler: () => {
const err = new TypeError('boom', { cause: new Error('inner') }) as Error & { tags?: unknown }
err.tags = tags
throw err
},
dump: {
inputs: [[]] as [][],
},
})

const result = await collectStaticRpcDump([flaky], {})
const entry = result.manifest['test:flaky-roundtrip'] as {
records: Record<string, string>
serialization: 'structured-clone'
}
expect(entry.serialization).toBe('structured-clone')

const recordPath = Object.values(entry.records)[0]!
const file = result.files[recordPath]!
const revived = structuredCloneDeserialize(JSON.parse(structuredCloneStringify(file.data))) as {
error: { name: string, message: string, cause: { message: string }, tags: Map<string, number> }
}
expect(revived.error.name).toBe('TypeError')
expect(revived.error.message).toBe('boom')
expect(revived.error.cause.message).toBe('inner')
expect(revived.error.tags).toBeInstanceOf(Map)
expect(revived.error.tags.get('a')).toBe(1)
})

it('throws DF0020 when a jsonSerializable: true function attaches non-JSON to an error', async () => {
const flaky = defineRpcFunction({
name: 'test:flaky-non-json',
type: 'query',
jsonSerializable: true,
handler: () => {
const err = new Error('boom') as Error & { tags?: unknown }
err.tags = new Map([['a', 1]])
throw err
},
dump: {
inputs: [[]] as [][],
},
})

const result = await collectStaticRpcDump([flaky], {})
const recordPath = Object.values(
(result.manifest['test:flaky-non-json'] as { records: Record<string, string> }).records,
)[0]!
const file = result.files[recordPath]!

// Attaching a Map to the thrown Error violates the `jsonSerializable: true`
// contract; the strict serializer surfaces this at build time, same as
// if the function had returned a Map.
expect(() => strictJsonStringify(file.data, file.fnName))
.toThrowError(/jsonSerializable: true.*is a Map/)
})
})
})
41 changes: 41 additions & 0 deletions packages/devframe/src/node/mcp/__tests__/mcp-server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,47 @@ describe('mcp adapter (in-memory)', () => {
}
})

it('coerces non-JSON values returned from a tool', async () => {
const { ctx, client, cleanup } = await bootPair()
try {
ctx.agent.registerTool({
id: 'rich',
description: 'Returns BigInt + Date.',
handler: () => ({ count: 42n, when: new Date(0) }),
})

const result = await client.callTool({ name: 'rich', arguments: {} })
const content = result.content as Array<{ type: string, text: string }>
expect(content[0]!.text).toContain('"42n"')
expect(content[0]!.text).toContain('1970-01-01T00:00:00.000Z')
}
finally {
await cleanup()
}
})

it('surfaces Error name and cause when a tool throws', async () => {
const { ctx, client, cleanup } = await bootPair()
try {
ctx.agent.registerTool({
id: 'crash',
description: 'Throws.',
handler: () => {
throw new TypeError('boom', { cause: new Error('inner') })
},
})

const result = await client.callTool({ name: 'crash', arguments: {} })
expect(result.isError).toBe(true)
const content = result.content as Array<{ type: string, text: string }>
expect(content[0]!.text).toContain('TypeError: boom')
expect(content[0]!.text).toContain('cause: inner')
}
finally {
await cleanup()
}
})

it('lists and reads registered resources', async () => {
const { ctx, client, cleanup } = await bootPair()
try {
Expand Down
113 changes: 113 additions & 0 deletions packages/devframe/src/node/mcp/__tests__/stringify.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { describe, expect, it } from 'vitest'
import { formatMcpError, stringifyForMcp } from '../stringify'

describe('stringifyForMcp', () => {
it('returns "undefined" sentinel for undefined', () => {
expect(stringifyForMcp(undefined)).toBe('undefined')
})

it('passes strings through unchanged', () => {
expect(stringifyForMcp('hello')).toBe('hello')
})

it('serializes plain JSON-safe objects with indentation', () => {
expect(stringifyForMcp({ a: 1, b: 'two' })).toBe('{\n "a": 1,\n "b": "two"\n}')
})

it('coerces BigInt to a trailing-n string', () => {
expect(JSON.parse(stringifyForMcp({ count: 42n }))).toEqual({ count: '42n' })
})

it('coerces Date to ISO string via toJSON', () => {
expect(JSON.parse(stringifyForMcp({ when: new Date(0) }))).toEqual({
when: '1970-01-01T00:00:00.000Z',
})
})

it('coerces Map to a tagged entries object', () => {
const value = new Map<string, number>([['a', 1], ['b', 2]])
expect(JSON.parse(stringifyForMcp(value))).toEqual({
__type: 'Map',
entries: [['a', 1], ['b', 2]],
})
})

it('coerces Set to a tagged entries object', () => {
const value = new Set(['x', 'y'])
expect(JSON.parse(stringifyForMcp(value))).toEqual({
__type: 'Set',
entries: ['x', 'y'],
})
})

it('serializes Error with name, message, stack, and cause', () => {
const inner = new Error('inner')
const outer = new TypeError('boom', { cause: inner })
const parsed = JSON.parse(stringifyForMcp(outer))
expect(parsed.name).toBe('TypeError')
expect(parsed.message).toBe('boom')
expect(typeof parsed.stack).toBe('string')
expect(parsed.cause.name).toBe('Error')
expect(parsed.cause.message).toBe('inner')
})

it('coerces Function to a readable token', () => {
function namedFn() {}
expect(JSON.parse(stringifyForMcp({ fn: namedFn }))).toEqual({
fn: '[Function: namedFn]',
})
})

it('coerces anonymous functions', () => {
expect(JSON.parse(stringifyForMcp({ fn: () => {} }))).toEqual({
fn: '[Function: fn]',
})
})

it('coerces Symbol to its description', () => {
expect(JSON.parse(stringifyForMcp({ s: Symbol('hi') }))).toEqual({
s: 'Symbol(hi)',
})
})

it('replaces circular refs with [Circular]', () => {
const obj: Record<string, unknown> = { name: 'root' }
obj.self = obj
const parsed = JSON.parse(stringifyForMcp(obj))
expect(parsed.name).toBe('root')
expect(parsed.self).toBe('[Circular]')
})

it('handles a mixed payload end-to-end', () => {
const value = {
count: 42n,
when: new Date(0),
tags: new Set(['a', 'b']),
}
const text = stringifyForMcp(value)
expect(text).toContain('"42n"')
expect(text).toContain('1970-01-01T00:00:00.000Z')
expect(text).toContain('"__type": "Set"')
})
})

describe('formatMcpError', () => {
it('returns String(value) for non-Error throws', () => {
expect(formatMcpError('boom')).toBe('boom')
expect(formatMcpError(42)).toBe('42')
})

it('formats an Error as "name: message"', () => {
expect(formatMcpError(new TypeError('bad'))).toBe('TypeError: bad')
})

it('appends cause.message for Error causes', () => {
const err = new Error('outer', { cause: new Error('inner') })
expect(formatMcpError(err)).toBe('Error: outer (cause: inner)')
})

it('appends String(cause) for non-Error causes', () => {
const err = new Error('outer', { cause: 'bad input' })
expect(formatMcpError(err)).toBe('Error: outer (cause: bad input)')
})
})
22 changes: 5 additions & 17 deletions packages/devframe/src/node/mcp/build-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
import { join } from 'pathe'
import { createHostContext } from '../context'
import { logger } from '../diagnostics'
import { formatMcpError, stringifyForMcp } from './stringify'
import { valibotArgsToJsonSchema, valibotReturnToJsonSchema } from './to-json-schema'

export interface CreateMcpServerOptions {
Expand Down Expand Up @@ -158,7 +159,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void {
content: [
{
type: 'text',
text: stringify(result),
text: stringifyForMcp(result),
},
],
}
Expand All @@ -169,7 +170,7 @@ function registerToolHandlers(server: Server, ctx: DevToolsNodeContext): void {
content: [
{
type: 'text',
text: `Error invoking "${name}": ${error instanceof Error ? error.message : String(error)}`,
text: `Error invoking "${name}": ${formatMcpError(error)}`,
},
],
}
Expand Down Expand Up @@ -218,7 +219,7 @@ function registerResourceHandlers(
{
uri,
mimeType: content.mimeType ?? 'application/json',
text: content.text ?? stringify(content.json),
text: content.text ?? stringifyForMcp(content.json),
},
],
}
Expand All @@ -231,7 +232,7 @@ function registerResourceHandlers(
{
uri,
mimeType: 'application/json',
text: stringify(state.value()),
text: stringifyForMcp(state.value()),
},
],
}
Expand Down Expand Up @@ -287,16 +288,3 @@ function parseResourceUri(uri: string): { kind: 'resource', id: string } | { kin
return { kind: 'resource', id: decoded }
return { kind: 'state', key: decoded }
}

function stringify(value: unknown): string {
if (value === undefined)
return 'undefined'
if (typeof value === 'string')
return value
try {
return JSON.stringify(value, null, 2)
}
catch {
return String(value)
}
}
Loading
Loading