Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-client-skip-output-validation-on-error.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/client/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -890,7 +890,7 @@ export class Client extends Protocol<ClientContext> {
}

// 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);
Expand Down
2 changes: 1 addition & 1 deletion packages/client/src/experimental/tasks/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
147 changes: 146 additions & 1 deletion test/integration/test/client/client.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
Expand Down
Loading