From ad1a89718a548a17031a0f6b95089fa1e30c9af5 Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Tue, 21 Jul 2026 19:36:09 -0400 Subject: [PATCH 1/2] test(server): cover zod output schema wrappers --- test/integration/test/server/mcp.test.ts | 71 ++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 4b9a3865f0..920e86d046 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1359,6 +1359,77 @@ describe('Zod v4', () => { expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); }); + test('should validate outputSchema when using non-object Zod schema wrappers', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'optional-output', + { + outputSchema: z.object({ data: z.string() }).optional() + }, + async () => ({ + structuredContent: { data: 'optional' } + }) + ); + + const unionOutputSchema = z.union([z.object({ data: z.string() }), z.object({ value: z.string() })]); + + mcpServer.registerTool( + 'union-output', + { + outputSchema: unionOutputSchema + }, + async () => ({ + structuredContent: { value: 'union' } + }) + ); + + mcpServer.registerTool( + 'invalid-union-output', + { + outputSchema: unionOutputSchema + }, + async () => ({ + structuredContent: { invalid: true } + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + const optionalResult = await client.callTool({ + name: 'optional-output' + }); + expect(optionalResult.structuredContent).toEqual({ data: 'optional' }); + + const unionResult = await client.callTool({ + name: 'union-output' + }); + expect(unionResult.structuredContent).toEqual({ value: 'union' }); + + const invalidUnionResult = await client.callTool({ + name: 'invalid-union-output' + }); + expect(invalidUnionResult.isError).toBe(true); + expect(invalidUnionResult.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Output validation error: Invalid structured content for tool invalid-union-output') + } + ]) + ); + }); + /*** * Test: Tool with Output Schema Must Provide Structured Content */ From 4f0debdfa501feff95c3d992b7d01ebc2e9cf58a Mon Sep 17 00:00:00 2001 From: Pradeep Ramola Date: Wed, 22 Jul 2026 00:45:21 -0400 Subject: [PATCH 2/2] test(server): cover non-object output schemas --- .../test/server/createMcpHandler.test.ts | 58 +++++++++- test/integration/test/server/mcp.test.ts | 102 +++++++++++++++++- 2 files changed, 156 insertions(+), 4 deletions(-) diff --git a/test/integration/test/server/createMcpHandler.test.ts b/test/integration/test/server/createMcpHandler.test.ts index 9c2a6a7a0d..12ada94bb3 100644 --- a/test/integration/test/server/createMcpHandler.test.ts +++ b/test/integration/test/server/createMcpHandler.test.ts @@ -22,6 +22,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import * as z from 'zod/v4'; const MODERN = '2026-07-28'; +type ServerFactory = (ctx: McpRequestContext) => McpServer; describe('createMcpHandler over HTTP (legacy postures end to end)', () => { const cleanups: Array<() => Promise | void> = []; @@ -31,7 +32,7 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { // One factory for both legs: the era only shows up in the tool output so the // tests can see which leg served the call. - const factory = (ctx: McpRequestContext) => { + const factory: ServerFactory = (ctx: McpRequestContext) => { const mcpServer = new McpServer( { name: 'dual-era-endpoint', version: '1.0.0' }, { capabilities: { tools: {} }, instructions: 'dual era endpoint' } @@ -42,8 +43,11 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { return mcpServer; }; - async function startEndpoint(options?: CreateMcpHandlerOptions): Promise<{ baseUrl: URL; handler: McpHttpHandler }> { - const handler = createMcpHandler(factory, options); + async function startEndpoint( + options?: CreateMcpHandlerOptions, + serverFactory: ServerFactory = factory + ): Promise<{ baseUrl: URL; handler: McpHttpHandler }> { + const handler = createMcpHandler(serverFactory, options); const httpServer: HttpServer = createServer(toNodeHandler(handler)); const baseUrl = await listenOnRandomPort(httpServer); cleanups.push(async () => { @@ -114,6 +118,54 @@ describe('createMcpHandler over HTTP (legacy postures end to end)', () => { expect(bodies[0]).toContain('server/discover'); }); + it('preserves non-object tool output schemas and structured content for modern clients', async () => { + const { baseUrl } = await startEndpoint(undefined, () => { + const mcpServer = new McpServer({ name: 'non-object-output', version: '1.0.0' }, { capabilities: { tools: {} } }); + mcpServer.registerTool( + 'string-output', + { + outputSchema: z.string() + }, + () => ({ + structuredContent: 'pong' + }) + ); + mcpServer.registerTool( + 'array-output', + { + outputSchema: z.array(z.string()) + }, + () => ({ + structuredContent: ['alpha', 'beta'] + }) + ); + return mcpServer; + }); + + const client = new Client({ name: 'modern-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } }); + await client.connect(new StreamableHTTPClientTransport(baseUrl)); + cleanups.push(() => client.close()); + + expect(client.getNegotiatedProtocolVersion()).toBe(MODERN); + + const { tools } = await client.listTools(); + expect(tools.find(tool => tool.name === 'string-output')?.outputSchema).toMatchObject({ + type: 'string' + }); + expect(tools.find(tool => tool.name === 'array-output')?.outputSchema).toMatchObject({ + type: 'array', + items: { type: 'string' } + }); + + const stringResult = await client.callTool({ name: 'string-output', arguments: {} }); + expect(stringResult.structuredContent).toBe('pong'); + expect(stringResult.content).toContainEqual({ type: 'text', text: '"pong"' }); + + const arrayResult = await client.callTool({ name: 'array-output', arguments: {} }); + expect(arrayResult.structuredContent).toEqual(['alpha', 'beta']); + expect(arrayResult.content).toContainEqual({ type: 'text', text: '["alpha","beta"]' }); + }); + it('answers an envelope claiming an unsupported revision with the supported list over plain HTTP', async () => { const { baseUrl } = await startEndpoint(); diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 920e86d046..e32cae2b73 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1359,7 +1359,7 @@ describe('Zod v4', () => { expect(JSON.parse(textContent.text)).toEqual(result.structuredContent); }); - test('should validate outputSchema when using non-object Zod schema wrappers', async () => { + test('should validate outputSchema when using Zod schema wrappers and non-object roots', async () => { const mcpServer = new McpServer({ name: 'test server', version: '1.0' @@ -1402,10 +1402,62 @@ describe('Zod v4', () => { }) ); + mcpServer.registerTool( + 'string-output', + { + outputSchema: z.string() + }, + async () => ({ + structuredContent: 'pong' + }) + ); + + mcpServer.registerTool( + 'array-output', + { + outputSchema: z.array(z.string()) + }, + async () => ({ + structuredContent: ['alpha', 'beta'] + }) + ); + + mcpServer.registerTool( + 'invalid-string-output', + { + outputSchema: z.string() + }, + async () => ({ + structuredContent: 123 + }) + ); + + mcpServer.registerTool( + 'invalid-array-output', + { + outputSchema: z.array(z.string()) + }, + async () => ({ + structuredContent: ['ok', 1] + }) + ); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + const listResult = await client.request({ method: 'tools/list' }); + expect(listResult.tools.find(tool => tool.name === 'string-output')?.outputSchema).toMatchObject({ + type: 'object', + properties: { result: { type: 'string' } }, + required: ['result'] + }); + expect(listResult.tools.find(tool => tool.name === 'array-output')?.outputSchema).toMatchObject({ + type: 'object', + properties: { result: { type: 'array', items: { type: 'string' } } }, + required: ['result'] + }); + const optionalResult = await client.callTool({ name: 'optional-output' }); @@ -1416,6 +1468,28 @@ describe('Zod v4', () => { }); expect(unionResult.structuredContent).toEqual({ value: 'union' }); + const stringResult = await client.callTool({ + name: 'string-output' + }); + expect(stringResult.structuredContent).toEqual({ result: 'pong' }); + expect(stringResult.content).toEqual([ + { + type: 'text', + text: '"pong"' + } + ]); + + const arrayResult = await client.callTool({ + name: 'array-output' + }); + expect(arrayResult.structuredContent).toEqual({ result: ['alpha', 'beta'] }); + expect(arrayResult.content).toEqual([ + { + type: 'text', + text: '["alpha","beta"]' + } + ]); + const invalidUnionResult = await client.callTool({ name: 'invalid-union-output' }); @@ -1428,6 +1502,32 @@ describe('Zod v4', () => { } ]) ); + + const invalidStringResult = await client.callTool({ + name: 'invalid-string-output' + }); + expect(invalidStringResult.isError).toBe(true); + expect(invalidStringResult.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Output validation error: Invalid structured content for tool invalid-string-output') + } + ]) + ); + + const invalidArrayResult = await client.callTool({ + name: 'invalid-array-output' + }); + expect(invalidArrayResult.isError).toBe(true); + expect(invalidArrayResult.content).toEqual( + expect.arrayContaining([ + { + type: 'text', + text: expect.stringContaining('Output validation error: Invalid structured content for tool invalid-array-output') + } + ]) + ); }); /***