Skip to content

Commit c621b1b

Browse files
authored
fix(agentic): do not advertise an output schema devframe cannot derive (#400)
1 parent f37e49d commit c621b1b

3 files changed

Lines changed: 81 additions & 3 deletions

File tree

‎packages/agentic/src/mcp/__tests__/mcp-server.test.ts‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DevframeHost } from 'devframe/types'
22
import { Client, InMemoryTransport } from '@modelcontextprotocol/client'
33
import { createHostContext } from 'devframe/node'
4+
import * as v from 'valibot'
45
import { describe, expect, it } from 'vitest'
56
import { buildMcpServerFromContext } from '../build-server'
67

@@ -165,6 +166,35 @@ describe('mcp adapter (in-memory)', () => {
165166
}
166167
})
167168

169+
it('calls an rpc-backed tool whose return schema has no native converter', async () => {
170+
const { ctx, client, cleanup } = await bootPair()
171+
try {
172+
ctx.rpc.register({
173+
name: 'list-things',
174+
type: 'query',
175+
jsonSerializable: true,
176+
args: [],
177+
returns: v.array(v.object({ id: v.string() })),
178+
agent: { description: 'Lists things.' },
179+
handler: () => [{ id: 'a' }, { id: 'b' }],
180+
} as never)
181+
182+
const listed = await client.listTools()
183+
const tool = listed.tools.find(t => t.name.endsWith('list-things'))
184+
expect(tool).toBeDefined()
185+
expect(tool!.outputSchema).toBeUndefined()
186+
187+
const result = await client.callTool({ name: tool!.name, arguments: {} })
188+
expect(result.isError).toBeFalsy()
189+
const content = result.content as Array<{ type: string, text: string }>
190+
expect(JSON.parse(content[0]!.text)).toEqual([{ id: 'a' }, { id: 'b' }])
191+
expect(result.structuredContent).toBeUndefined()
192+
}
193+
finally {
194+
await cleanup()
195+
}
196+
})
197+
168198
it('coerces non-JSON values returned from a tool', async () => {
169199
const { ctx, client, cleanup } = await bootPair()
170200
try {

‎packages/devframe/src/agent/__tests__/to-json-schema.test.ts‎

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,39 @@ describe('returnToJsonSchema', () => {
4949
.toEqual({ type: 'object', properties: { ok: { type: 'boolean' } } })
5050
})
5151

52-
it('falls back to permissive for validators without a native converter', () => {
53-
expect(returnToJsonSchema(v.object({ ok: v.boolean() }))).toEqual(PERMISSIVE)
52+
it('yields no schema for validators without a native converter', () => {
53+
expect(returnToJsonSchema(v.object({ ok: v.boolean() }))).toBeUndefined()
54+
expect(returnToJsonSchema(v.array(v.object({ ok: v.boolean() })))).toBeUndefined()
55+
})
56+
57+
it('yields no schema when the converter cannot express the schema', () => {
58+
const throwing = {
59+
'~standard': {
60+
version: 1,
61+
vendor: 'test',
62+
validate: (value: unknown) => ({ value }),
63+
jsonSchema: {
64+
input: () => { throw new Error('unsupported') },
65+
output: () => { throw new Error('unsupported') },
66+
},
67+
} as StandardSchemaV1['~standard'],
68+
}
69+
expect(returnToJsonSchema(throwing)).toBeUndefined()
70+
})
71+
72+
it('converts the output type, not the input type', () => {
73+
const transforming = {
74+
'~standard': {
75+
version: 1,
76+
vendor: 'test',
77+
validate: (value: unknown) => ({ value }),
78+
jsonSchema: {
79+
input: () => ({ type: 'string' }),
80+
output: () => ({ type: 'object', properties: { parsed: { type: 'number' } } }),
81+
},
82+
} as StandardSchemaV1['~standard'],
83+
}
84+
expect(returnToJsonSchema(transforming))
85+
.toEqual({ type: 'object', properties: { parsed: { type: 'number' } } })
5486
})
5587
})

‎packages/devframe/src/agent/to-json-schema.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,28 @@ function safeToJsonSchema(schema: StandardSchemaV1): unknown {
2929

3030
/**
3131
* JSON Schema for an RPC return value on the agent/MCP surface.
32+
*
33+
* Unlike args, a return value has no permissive fallback: the schema is
34+
* advertised as an MCP `outputSchema`, which obliges the tool to return a
35+
* matching object on every call. A validator with no native converter
36+
* (e.g. valibot), or one whose converter cannot express the schema, yields
37+
* no output schema rather than an unfounded object one, so array- and
38+
* primitive-returning tools still work. Conversion uses the converter's
39+
* `output`, since a transforming validator returns its output type.
3240
* @internal
3341
*/
3442
export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown {
3543
if (!schema)
3644
return undefined
37-
return safeToJsonSchema(schema)
45+
const standard = schema['~standard'] as MaybeJsonSchema
46+
if (!standard.jsonSchema)
47+
return undefined
48+
try {
49+
return standard.jsonSchema.output({ target: 'draft-2020-12' })
50+
}
51+
catch {
52+
return undefined
53+
}
3854
}
3955

4056
/**

0 commit comments

Comments
 (0)