From 427660b70de29a711d30824bbb88f7d20d4f7e38 Mon Sep 17 00:00:00 2001 From: Genmin Date: Thu, 30 Apr 2026 18:01:28 -0700 Subject: [PATCH] fix(server): validate wrapped output schemas on v1 --- .changeset/fix-output-schema-wrappers.md | 5 ++ src/server/mcp.ts | 8 +-- test/server/mcp.test.ts | 68 ++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 .changeset/fix-output-schema-wrappers.md diff --git a/.changeset/fix-output-schema-wrappers.md b/.changeset/fix-output-schema-wrappers.md new file mode 100644 index 0000000000..5d82a063f0 --- /dev/null +++ b/.changeset/fix-output-schema-wrappers.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +Fix v1.x server tool output validation for optional, nullable, nullish, and union Zod output schemas. diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9fe0ed549c..04aabbe39f 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -305,9 +305,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); + // Try to normalize to object schema first (for raw shapes and object schemas) + // If that fails, use the schema directly (for union/optional/nullable/etc) + const outputObj = normalizeObjectSchema(tool.outputSchema); + const schemaToParse = outputObj ?? tool.outputSchema; + 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/test/server/mcp.test.ts b/test/server/mcp.test.ts index 575d6a300e..7035f28193 100644 --- a/test/server/mcp.test.ts +++ b/test/server/mcp.test.ts @@ -1295,6 +1295,74 @@ describe.each(zodTestMatrix)('$zodVersionLabel', (entry: ZodMatrixEntry) => { expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); }); + test('should validate structuredContent against non-object-wrapper output schemas', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + const schemaCases = [ + { + name: 'optional', + outputSchema: z.object({ data: z.string() }).optional(), + structuredContent: { data: 'hello' } + }, + { + name: 'nullable', + outputSchema: z.object({ data: z.string() }).nullable(), + structuredContent: { data: 'hello' } + }, + { + name: 'nullish', + outputSchema: z.object({ data: z.string() }).nullish(), + structuredContent: { data: 'hello' } + }, + { + name: 'union', + outputSchema: z.union([z.object({ data: z.string() }), z.object({ value: z.string() })]), + structuredContent: { value: 'hello' } + } + ]; + + for (const { name, outputSchema, structuredContent } of schemaCases) { + mcpServer.registerTool( + `test-${name}`, + { + description: `Test tool with ${name} output schema`, + outputSchema + }, + async () => ({ + structuredContent, + content: [ + { + type: 'text', + text: JSON.stringify(structuredContent) + } + ] + }) + ); + } + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + for (const { name, structuredContent } of schemaCases) { + const result = await client.callTool({ + name: `test-${name}`, + arguments: {} + }); + + expect(result.isError).not.toBe(true); + expect(result.structuredContent).toEqual(structuredContent); + } + }); + /*** * Test: Tool with Output Schema Must Provide Structured Content */