diff --git a/.changeset/fix-tools-list-wrapped-schemas.md b/.changeset/fix-tools-list-wrapped-schemas.md new file mode 100644 index 0000000000..4be1aa8ffe --- /dev/null +++ b/.changeset/fix-tools-list-wrapped-schemas.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Fix `tools/list` emitting `inputSchema: {}` (and silently dropping `outputSchema`) for tools whose schema is wrapped in `ZodEffects` / `ZodPipeline` (`.refine`, `.superRefine`, `.transform`, `.pipe`). The emission sites now fall back to the original schema when +`normalizeObjectSchema` can't unwrap, mirroring what `validateToolInput` already does — the converter libraries walk those wrappers natively. diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9fe0ed549c..f19826e55b 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -146,13 +146,15 @@ export class McpServer { title: tool.title, description: tool.description, inputSchema: (() => { + if (!tool.inputSchema) return EMPTY_OBJECT_JSON_SCHEMA; + // Fallback to the original schema when normalizeObjectSchema can't unwrap + // it (e.g. .refine/.superRefine/.transform/.pipe). The converter walks + // those wrappers natively and emits the underlying object's properties. const obj = normalizeObjectSchema(tool.inputSchema); - return obj - ? (toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: 'input' - }) as Tool['inputSchema']) - : EMPTY_OBJECT_JSON_SCHEMA; + return toJsonSchemaCompat(obj ?? (tool.inputSchema as AnySchema), { + strictUnions: true, + pipeStrategy: 'input' + }) as Tool['inputSchema']; })(), annotations: tool.annotations, execution: tool.execution, @@ -161,12 +163,10 @@ export class McpServer { if (tool.outputSchema) { const obj = normalizeObjectSchema(tool.outputSchema); - if (obj) { - toolDefinition.outputSchema = toJsonSchemaCompat(obj, { - strictUnions: true, - pipeStrategy: 'output' - }) as Tool['outputSchema']; - } + toolDefinition.outputSchema = toJsonSchemaCompat(obj ?? (tool.outputSchema as AnySchema), { + strictUnions: true, + pipeStrategy: 'output' + }) as Tool['outputSchema']; } return toolDefinition; diff --git a/src/server/zod-json-schema-compat.ts b/src/server/zod-json-schema-compat.ts index cde66b1772..53cf605a7d 100644 --- a/src/server/zod-json-schema-compat.ts +++ b/src/server/zod-json-schema-compat.ts @@ -28,7 +28,7 @@ function mapMiniTarget(t: CommonOpts['target'] | undefined): 'draft-7' | 'draft- return 'draft-7'; // fallback } -export function toJsonSchemaCompat(schema: AnyObjectSchema, opts?: CommonOpts): JsonSchema { +export function toJsonSchemaCompat(schema: AnySchema, opts?: CommonOpts): JsonSchema { if (isZ4Schema(schema)) { // v4 branch — use Mini's built-in toJSONSchema return z4mini.toJSONSchema(schema as z4c.$ZodType, { diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index 575d6a300e..d7751b4fb9 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -21,6 +21,7 @@ import { } from '../../src/types.js'; import { completable } from '../../src/server/completable.js'; import { McpServer, ResourceTemplate } from '../../src/server/mcp.js'; +import type { AnySchema } from '../../src/server/zod-compat.js'; import { InMemoryTaskStore } from '../../src/experimental/tasks/stores/in-memory.js'; import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; @@ -5220,6 +5221,135 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }); }); + describe('tools/list with wrapped inputSchema (issue #2145)', () => { + // Each entry wraps the same plain object shape: + // z.object({ prompt: z.string().min(1), count: z.number().int().positive().default(1) }) + // The wrapper (.refine / .superRefine / .transform / .pipe) hides `.shape` from the + // top-level schema, but tools/list should still emit the underlying object's + // `properties` instead of falling back to {}. + const buildBase = () => + z.object({ + prompt: z.string().min(1), + count: z.number().int().positive().default(1) + }); + + const wrappers: Array<{ name: string; build: () => AnySchema; rejectsLargeCount: boolean }> = [ + { + name: '.refine', + build: () => + (buildBase() as { refine: (...args: unknown[]) => AnySchema }).refine((v: { count: number }) => v.count <= 100, { + message: 'count must be <= 100' + }), + rejectsLargeCount: true + }, + { + name: '.superRefine', + build: () => + (buildBase() as { superRefine: (...args: unknown[]) => AnySchema }).superRefine( + (v: { count: number }, ctx: { addIssue: (issue: Record) => void }) => { + if (v.count > 100) { + ctx.addIssue({ code: 'custom', path: ['count'], message: 'count must be <= 100' }); + } + } + ), + rejectsLargeCount: true + }, + { + name: '.transform', + build: () => (buildBase() as { transform: (...args: unknown[]) => AnySchema }).transform((v: Record) => v), + rejectsLargeCount: false + }, + { + name: '.pipe', + build: () => + (buildBase() as { pipe: (...args: unknown[]) => AnySchema }).pipe( + z.object({ + prompt: z.string(), + count: z.number() + }) + ), + rejectsLargeCount: false + } + ]; + + test.each(wrappers)('$name: tools/list emits properties from the underlying object', async ({ build }) => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test', version: '1.0.0' }); + + server.registerTool('wrapped', { inputSchema: build() }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const wrapped = result.tools.find(t => t.name === 'wrapped'); + expect(wrapped).toBeDefined(); + expect(wrapped!.inputSchema.type).toBe('object'); + const properties = wrapped!.inputSchema.properties as Record | undefined; + expect(properties).toBeDefined(); + expect(properties!.prompt).toBeDefined(); + expect(properties!.count).toBeDefined(); + }); + + test.each(wrappers)('$name: tools/call still validates against the wrapped schema', async ({ build, rejectsLargeCount }) => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test', version: '1.0.0' }); + + server.registerTool('wrapped', { inputSchema: build() }, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const okResult = await client.callTool({ name: 'wrapped', arguments: { prompt: 'hi', count: 5 } }); + expect(okResult.isError).toBeFalsy(); + + if (rejectsLargeCount) { + const badResult = await client.callTool({ name: 'wrapped', arguments: { prompt: 'hi', count: 200 } }); + expect(badResult.isError).toBe(true); + expect(JSON.stringify(badResult.content)).toContain('count'); + } + }); + + test('regression: tool without inputSchema still emits EMPTY_OBJECT_JSON_SCHEMA', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test', version: '1.0.0' }); + + server.registerTool('no-args', {}, async () => ({ content: [{ type: 'text' as const, text: 'ok' }] })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const tool = result.tools.find(t => t.name === 'no-args'); + expect(tool).toBeDefined(); + expect(tool!.inputSchema).toEqual({ type: 'object', properties: {} }); + }); + + test('wrapped outputSchema is also emitted in tools/list', async () => { + const server = new McpServer({ name: 'test', version: '1.0.0' }); + const client = new Client({ name: 'test', version: '1.0.0' }); + + const wrappedOutput = (z.object({ result: z.string() }) as { refine: (...args: unknown[]) => AnySchema }).refine(() => true); + + server.registerTool('with-wrapped-output', { outputSchema: wrappedOutput }, async () => ({ + content: [{ type: 'text' as const, text: 'ok' }], + structuredContent: { result: 'ok' } + })); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + const result = await client.request({ method: 'tools/list' }, ListToolsResultSchema); + const tool = result.tools.find(t => t.name === 'with-wrapped-output'); + expect(tool).toBeDefined(); + expect(tool!.outputSchema).toBeDefined(); + expect(tool!.outputSchema!.type).toBe('object'); + const properties = tool!.outputSchema!.properties as Record | undefined; + expect(properties).toBeDefined(); + expect(properties!.result).toBeDefined(); + }); + }); + describe('resource()', () => { /*** * Test: Resource Registration with URI and Read Callback