From 948df8fee17b2d31430f41d13d89972054d1543a Mon Sep 17 00:00:00 2001 From: Kam Date: Sun, 20 Sep 2026 19:13:10 +0300 Subject: [PATCH 1/2] fix(agentic): do not advertise an output schema devframe cannot derive An RPC function whose `returns` validator has no Standard JSON Schema converter degraded to a permissive `{ type: 'object' }` schema. That is fine as documentation, but it is advertised as the MCP `outputSchema`, which obliges the tool to return a matching object on every call. Any such function returning an array or a primitive therefore failed `tools/call` with -32602, the SDK rejecting the server's own response: Invalid tools/call result: expected record, received array Return no output schema instead when the converter is absent, so the result travels as text content only. Validators with a native converter (zod 4) are unaffected. --- .../src/mcp/__tests__/mcp-server.test.ts | 30 +++++++++++++++++++ .../agent/__tests__/to-json-schema.test.ts | 5 ++-- packages/devframe/src/agent/to-json-schema.ts | 9 ++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts index ba1efe25..21b7a9f0 100644 --- a/packages/agentic/src/mcp/__tests__/mcp-server.test.ts +++ b/packages/agentic/src/mcp/__tests__/mcp-server.test.ts @@ -1,6 +1,7 @@ import type { DevframeHost } from 'devframe/types' import { Client, InMemoryTransport } from '@modelcontextprotocol/client' import { createHostContext } from 'devframe/node' +import * as v from 'valibot' import { describe, expect, it } from 'vitest' import { buildMcpServerFromContext } from '../build-server' @@ -165,6 +166,35 @@ describe('mcp adapter (in-memory)', () => { } }) + it('calls an rpc-backed tool whose return schema has no native converter', async () => { + const { ctx, client, cleanup } = await bootPair() + try { + ctx.rpc.register({ + name: 'list-things', + type: 'query', + jsonSerializable: true, + args: [], + returns: v.array(v.object({ id: v.string() })), + agent: { description: 'Lists things.' }, + handler: () => [{ id: 'a' }, { id: 'b' }], + } as never) + + const listed = await client.listTools() + const tool = listed.tools.find(t => t.name.endsWith('list-things')) + expect(tool).toBeDefined() + expect(tool!.outputSchema).toBeUndefined() + + const result = await client.callTool({ name: tool!.name, arguments: {} }) + expect(result.isError).toBeFalsy() + const content = result.content as Array<{ type: string, text: string }> + expect(JSON.parse(content[0]!.text)).toEqual([{ id: 'a' }, { id: 'b' }]) + expect(result.structuredContent).toBeUndefined() + } + finally { + await cleanup() + } + }) + it('coerces non-JSON values returned from a tool', async () => { const { ctx, client, cleanup } = await bootPair() try { diff --git a/packages/devframe/src/agent/__tests__/to-json-schema.test.ts b/packages/devframe/src/agent/__tests__/to-json-schema.test.ts index f7e57272..0314e8e9 100644 --- a/packages/devframe/src/agent/__tests__/to-json-schema.test.ts +++ b/packages/devframe/src/agent/__tests__/to-json-schema.test.ts @@ -49,7 +49,8 @@ describe('returnToJsonSchema', () => { .toEqual({ type: 'object', properties: { ok: { type: 'boolean' } } }) }) - it('falls back to permissive for validators without a native converter', () => { - expect(returnToJsonSchema(v.object({ ok: v.boolean() }))).toEqual(PERMISSIVE) + it('yields no schema for validators without a native converter', () => { + expect(returnToJsonSchema(v.object({ ok: v.boolean() }))).toBeUndefined() + expect(returnToJsonSchema(v.array(v.object({ ok: v.boolean() })))).toBeUndefined() }) }) diff --git a/packages/devframe/src/agent/to-json-schema.ts b/packages/devframe/src/agent/to-json-schema.ts index 7f7b4b4a..7044bfe3 100644 --- a/packages/devframe/src/agent/to-json-schema.ts +++ b/packages/devframe/src/agent/to-json-schema.ts @@ -29,11 +29,20 @@ function safeToJsonSchema(schema: StandardSchemaV1): unknown { /** * JSON Schema for an RPC return value on the agent/MCP surface. + * + * Unlike args, a return value has no permissive fallback: the schema is + * advertised as an MCP `outputSchema`, which obliges the tool to return a + * matching object on every call. A validator without a native converter + * (e.g. valibot) yields no output schema rather than an unfounded object + * one, so array- and primitive-returning tools still work. * @internal */ export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown { if (!schema) return undefined + const standard = schema['~standard'] as MaybeJsonSchema + if (!standard.jsonSchema) + return undefined return safeToJsonSchema(schema) } From 7ef52dc468904c980d1f3d4a75636c1bfea602a2 Mon Sep 17 00:00:00 2001 From: Kam Date: Sun, 20 Sep 2026 19:18:12 +0300 Subject: [PATCH 2/2] fix(agentic): yield no return schema when conversion fails Checking only for a converter's presence left the throwing path on the fallback: `input`/`output` may throw when conversion is unsupported, and that was caught into the permissive object schema, reproducing the same MCP rejection. Convert via the converter's `output` too, since a transforming validator returns its output type, and the return value is what `outputSchema` describes. --- .../agent/__tests__/to-json-schema.test.ts | 31 +++++++++++++++++++ packages/devframe/src/agent/to-json-schema.ts | 15 ++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/packages/devframe/src/agent/__tests__/to-json-schema.test.ts b/packages/devframe/src/agent/__tests__/to-json-schema.test.ts index 0314e8e9..4fd65665 100644 --- a/packages/devframe/src/agent/__tests__/to-json-schema.test.ts +++ b/packages/devframe/src/agent/__tests__/to-json-schema.test.ts @@ -53,4 +53,35 @@ describe('returnToJsonSchema', () => { expect(returnToJsonSchema(v.object({ ok: v.boolean() }))).toBeUndefined() expect(returnToJsonSchema(v.array(v.object({ ok: v.boolean() })))).toBeUndefined() }) + + it('yields no schema when the converter cannot express the schema', () => { + const throwing = { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => { throw new Error('unsupported') }, + output: () => { throw new Error('unsupported') }, + }, + } as StandardSchemaV1['~standard'], + } + expect(returnToJsonSchema(throwing)).toBeUndefined() + }) + + it('converts the output type, not the input type', () => { + const transforming = { + '~standard': { + version: 1, + vendor: 'test', + validate: (value: unknown) => ({ value }), + jsonSchema: { + input: () => ({ type: 'string' }), + output: () => ({ type: 'object', properties: { parsed: { type: 'number' } } }), + }, + } as StandardSchemaV1['~standard'], + } + expect(returnToJsonSchema(transforming)) + .toEqual({ type: 'object', properties: { parsed: { type: 'number' } } }) + }) }) diff --git a/packages/devframe/src/agent/to-json-schema.ts b/packages/devframe/src/agent/to-json-schema.ts index 7044bfe3..2571bceb 100644 --- a/packages/devframe/src/agent/to-json-schema.ts +++ b/packages/devframe/src/agent/to-json-schema.ts @@ -32,9 +32,11 @@ function safeToJsonSchema(schema: StandardSchemaV1): unknown { * * Unlike args, a return value has no permissive fallback: the schema is * advertised as an MCP `outputSchema`, which obliges the tool to return a - * matching object on every call. A validator without a native converter - * (e.g. valibot) yields no output schema rather than an unfounded object - * one, so array- and primitive-returning tools still work. + * matching object on every call. A validator with no native converter + * (e.g. valibot), or one whose converter cannot express the schema, yields + * no output schema rather than an unfounded object one, so array- and + * primitive-returning tools still work. Conversion uses the converter's + * `output`, since a transforming validator returns its output type. * @internal */ export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknown { @@ -43,7 +45,12 @@ export function returnToJsonSchema(schema: StandardSchemaV1 | undefined): unknow const standard = schema['~standard'] as MaybeJsonSchema if (!standard.jsonSchema) return undefined - return safeToJsonSchema(schema) + try { + return standard.jsonSchema.output({ target: 'draft-2020-12' }) + } + catch { + return undefined + } } /**