diff --git a/.changeset/fix-client-skip-output-validation-on-error.md b/.changeset/fix-client-skip-output-validation-on-error.md new file mode 100644 index 000000000..acba698c9 --- /dev/null +++ b/.changeset/fix-client-skip-output-validation-on-error.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/client': patch +--- + +Client `callTool` and `experimental.tasks.callToolStream` now skip `outputSchema` validation when a tool result has `isError: true`, matching server-side `validateToolOutput` behavior. Tools that return a structured error envelope (e.g. `{ error: { code, message } }`) are no longer rejected with `Structured content does not match the tool's output schema`. Fixes #1943. diff --git a/packages/client/src/client/client.ts b/packages/client/src/client/client.ts index 21a43bd15..62eccabde 100644 --- a/packages/client/src/client/client.ts +++ b/packages/client/src/client/client.ts @@ -890,7 +890,7 @@ export class Client extends Protocol { } // Only validate structured content if present (not when there's an error) - if (result.structuredContent) { + if (result.structuredContent && !result.isError) { try { // Validate the structured content against the schema const validationResult = validator(result.structuredContent); diff --git a/packages/client/src/experimental/tasks/client.ts b/packages/client/src/experimental/tasks/client.ts index 75ba873c9..11faaea06 100644 --- a/packages/client/src/experimental/tasks/client.ts +++ b/packages/client/src/experimental/tasks/client.ts @@ -136,7 +136,7 @@ export class ExperimentalClientTasks { } // Only validate structured content if present (not when there's an error) - if (result.structuredContent) { + if (result.structuredContent && !result.isError) { try { // Validate the structured content against the schema const validationResult = validator(result.structuredContent); diff --git a/test/integration/test/client/client.test.ts b/test/integration/test/client/client.test.ts index 52d151bdd..deae3cee6 100644 --- a/test/integration/test/client/client.test.ts +++ b/test/integration/test/client/client.test.ts @@ -1,5 +1,5 @@ import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/client'; -import type { Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core'; +import type { CallToolResult, Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core'; import { CallToolResultSchema, ElicitResultSchema, @@ -2278,6 +2278,72 @@ describe('outputSchema validation', () => { /Structured content does not match the tool's output schema/ ); }); + + /*** + * Test: Skip outputSchema validation when result is an error (mirrors server-side behavior) + */ + test('should not validate structuredContent against outputSchema when isError is true', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' }, + count: { type: 'number' } + }, + required: ['result', 'count'] + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + // Error envelope carrying a structuredContent shape that does NOT match outputSchema. + // Server-side validateToolOutput short-circuits on isError; client must behave the same. + return { + isError: true, + content: [{ type: 'text', text: 'NOT_FOUND' }], + structuredContent: { error: { code: 'NOT_FOUND', message: 'nope' } } + }; + }); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { capabilities: { tasks: { requests: { tools: { call: {} } } } } } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const result = await client.callTool({ name: 'test-tool' }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ error: { code: 'NOT_FOUND', message: 'nope' } }); + }); }); describe('Task-based execution', () => { @@ -4087,6 +4153,85 @@ test('callToolStream() should not validate structuredContent when isError is tru await server.close(); }); +test('callToolStream() should not validate structuredContent when isError is true', async () => { + const server = new Server( + { + name: 'test-server', + version: '1.0.0' + }, + { + capabilities: { + tools: {} + } + } + ); + + server.setRequestHandler('tools/list', async () => ({ + tools: [ + { + name: 'test-tool', + description: 'A test tool', + inputSchema: { + type: 'object', + properties: {} + }, + outputSchema: { + type: 'object', + properties: { + result: { type: 'string' } + }, + required: ['result'] + } + } + ] + })); + + server.setRequestHandler('tools/call', async () => { + // Error envelope carrying a structuredContent shape that does NOT match outputSchema. + // Server-side validateToolOutput short-circuits on isError; client must behave the same. + return { + isError: true, + content: [{ type: 'text', text: 'NOT_FOUND' }], + structuredContent: { error: { code: 'NOT_FOUND', message: 'nope' } } + }; + }); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client( + { + name: 'test-client', + version: '1.0.0' + }, + { + capabilities: { tasks: { requests: { tools: { call: {} } } } } + } + ); + + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); + + await client.listTools(); + + const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} }); + + const messages = []; + for await (const message of stream) { + messages.push(message); + } + + // Should have received result (not error), with isError flag set and structuredContent preserved + expect(messages.length).toBe(1); + expect(messages[0]!.type).toBe('result'); + if (messages[0]!.type === 'result') { + const result = messages[0]!.result as CallToolResult; + expect(result.isError).toBe(true); + expect(result.structuredContent).toEqual({ error: { code: 'NOT_FOUND', message: 'nope' } }); + } + + await client.close(); + await server.close(); +}); + describe('getSupportedElicitationModes', () => { test('should support nothing when capabilities are undefined', () => { const result = getSupportedElicitationModes(undefined);