From 5b5049a7ab711a3f0447c1f988672d2ce3a8da13 Mon Sep 17 00:00:00 2001 From: cgeor Date: Wed, 27 May 2026 10:49:58 +0200 Subject: [PATCH 1/4] fix(server): emit underlying object schema for wrapped Zod inputs in tools/list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeObjectSchema doesn't unwrap ZodEffects / ZodPipeline (.refine, .superRefine, .transform, .pipe), so the tools/list emission sites were falling back to {} (input) or dropping outputSchema entirely. validateToolOutput had the same shape and would crash on safeParseAsync(undefined, ...). Mirror the fallback validateToolInput already uses: when normalizeObjectSchema returns undefined, hand the original schema to the converter / parser. zod-to-json-schema (v3) and z4mini.toJSONSchema (v4) walk those wrappers natively and drop the refinement body, so the underlying object's properties come through without us maintaining a wrapper-type list across zod versions. Widens toJsonSchemaCompat's first parameter from AnyObjectSchema to AnySchema so it accepts wrapped schemas. Validation against the wrapped schema is unchanged (still parses the original) — only the JSON Schema emission and output-validation parsing change. Fixes #2145 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/mcp.ts | 31 +++--- src/server/zod-json-schema-compat.ts | 2 +- test/server/mcp.test.ts | 145 +++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 15 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9fe0ed549c..e32a96ea5f 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; @@ -306,8 +306,11 @@ export class McpServer { } // if the tool has an output schema, validate structured content - const outputObj = normalizeObjectSchema(tool.outputSchema) as AnyObjectSchema; - const parseResult = await safeParseAsync(outputObj, result.structuredContent); + // Same fallback as validateToolInput: when normalizeObjectSchema returns undefined + // (wrapped schema), parse against the original to avoid passing undefined into safeParseAsync. + const outputObj = normalizeObjectSchema(tool.outputSchema); + const schemaToParse = outputObj ?? (tool.outputSchema as AnySchema); + const parseResult = await safeParseAsync(schemaToParse, result.structuredContent); if (!parseResult.success) { const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; const errorMessage = getParseErrorMessage(error); 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..25ae7a7c7b 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,150 @@ 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 From 1ee0be6690e63f65331ebde7830c67cb4e199987 Mon Sep 17 00:00:00 2001 From: cgeor Date: Wed, 27 May 2026 10:55:13 +0200 Subject: [PATCH 2/4] test(server): prettier-format new tools/list wrapper tests Co-Authored-By: Claude Opus 4.7 (1M context) --- test/server/mcp.test.ts | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/test/server/mcp.test.ts b/test/server/mcp.test.ts index 25ae7a7c7b..d7751b4fb9 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -5237,10 +5237,9 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { { name: '.refine', build: () => - (buildBase() as { refine: (...args: unknown[]) => AnySchema }).refine( - (v: { count: number }) => v.count <= 100, - { message: 'count must be <= 100' } - ), + (buildBase() as { refine: (...args: unknown[]) => AnySchema }).refine((v: { count: number }) => v.count <= 100, { + message: 'count must be <= 100' + }), rejectsLargeCount: true }, { @@ -5257,10 +5256,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { }, { name: '.transform', - build: () => - (buildBase() as { transform: (...args: unknown[]) => AnySchema }).transform( - (v: Record) => v - ), + build: () => (buildBase() as { transform: (...args: unknown[]) => AnySchema }).transform((v: Record) => v), rejectsLargeCount: false }, { @@ -5280,11 +5276,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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' }] }) - ); + 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)]); @@ -5303,11 +5295,7 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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' }] }) - ); + 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)]); @@ -5341,15 +5329,12 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { 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); + 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' } }) - ); + 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)]); From cce56d77e21e636fa8cd6b6aa74c23e1bac4c294 Mon Sep 17 00:00:00 2001 From: cgeor Date: Wed, 27 May 2026 11:08:55 +0200 Subject: [PATCH 3/4] revert: drop validateToolOutput drive-by, leave to #2106 Co-Authored-By: Claude Opus 4.7 (1M context) --- src/server/mcp.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/server/mcp.ts b/src/server/mcp.ts index e32a96ea5f..f19826e55b 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -306,11 +306,8 @@ export class McpServer { } // if the tool has an output schema, validate structured content - // Same fallback as validateToolInput: when normalizeObjectSchema returns undefined - // (wrapped schema), parse against the original to avoid passing undefined into safeParseAsync. - const outputObj = normalizeObjectSchema(tool.outputSchema); - const schemaToParse = outputObj ?? (tool.outputSchema as AnySchema); - const parseResult = await safeParseAsync(schemaToParse, result.structuredContent); + const outputObj = normalizeObjectSchema(tool.outputSchema) as AnyObjectSchema; + const parseResult = await safeParseAsync(outputObj, result.structuredContent); if (!parseResult.success) { const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; const errorMessage = getParseErrorMessage(error); From b54fd1ab490b24942466c9c7e50ebe6c3c742d09 Mon Sep 17 00:00:00 2001 From: cgeor Date: Wed, 27 May 2026 11:23:30 +0200 Subject: [PATCH 4/4] chore: add changeset for tools/list wrapped-schema fix Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/fix-tools-list-wrapped-schemas.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/fix-tools-list-wrapped-schemas.md 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.