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..4fd65665 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,39 @@ 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() + }) + + 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 7f7b4b4a..2571bceb 100644 --- a/packages/devframe/src/agent/to-json-schema.ts +++ b/packages/devframe/src/agent/to-json-schema.ts @@ -29,12 +29,28 @@ 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 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 { if (!schema) return undefined - return safeToJsonSchema(schema) + const standard = schema['~standard'] as MaybeJsonSchema + if (!standard.jsonSchema) + return undefined + try { + return standard.jsonSchema.output({ target: 'draft-2020-12' }) + } + catch { + return undefined + } } /**