Skip to content

Commit ed8e535

Browse files
author
Brian O'Kelley
committed
fix(client): skip outputSchema validation when result is an error
Aligns client-side callTool and experimental.tasks.callToolStream with the server-side validateToolOutput short-circuit on isError. The guard comment already said "not when there's an error" — the !result.isError check was just missing. Tools returning a structured error envelope (e.g. { error: { code, message } } alongside isError: true) are no longer rejected with "Structured content does not match the tool's output schema". Fixes #1943.
1 parent bdfd7f0 commit ed8e535

4 files changed

Lines changed: 153 additions & 3 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/client': patch
3+
---
4+
5+
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.

packages/client/src/client/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -890,7 +890,7 @@ export class Client extends Protocol<ClientContext> {
890890
}
891891

892892
// Only validate structured content if present (not when there's an error)
893-
if (result.structuredContent) {
893+
if (result.structuredContent && !result.isError) {
894894
try {
895895
// Validate the structured content against the schema
896896
const validationResult = validator(result.structuredContent);

packages/client/src/experimental/tasks/client.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ export class ExperimentalClientTasks {
136136
}
137137

138138
// Only validate structured content if present (not when there's an error)
139-
if (result.structuredContent) {
139+
if (result.structuredContent && !result.isError) {
140140
try {
141141
// Validate the structured content against the schema
142142
const validationResult = validator(result.structuredContent);

test/integration/test/client/client.test.ts

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Client, getSupportedElicitationModes } from '@modelcontextprotocol/client';
2-
import type { Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core';
2+
import type { CallToolResult, Prompt, Resource, Tool, Transport } from '@modelcontextprotocol/core';
33
import {
44
CallToolResultSchema,
55
ElicitResultSchema,
@@ -2278,6 +2278,72 @@ describe('outputSchema validation', () => {
22782278
/Structured content does not match the tool's output schema/
22792279
);
22802280
});
2281+
2282+
/***
2283+
* Test: Skip outputSchema validation when result is an error (mirrors server-side behavior)
2284+
*/
2285+
test('should not validate structuredContent against outputSchema when isError is true', async () => {
2286+
const server = new Server(
2287+
{
2288+
name: 'test-server',
2289+
version: '1.0.0'
2290+
},
2291+
{
2292+
capabilities: {
2293+
tools: {}
2294+
}
2295+
}
2296+
);
2297+
2298+
server.setRequestHandler('tools/list', async () => ({
2299+
tools: [
2300+
{
2301+
name: 'test-tool',
2302+
description: 'A test tool',
2303+
inputSchema: {
2304+
type: 'object',
2305+
properties: {}
2306+
},
2307+
outputSchema: {
2308+
type: 'object',
2309+
properties: {
2310+
result: { type: 'string' },
2311+
count: { type: 'number' }
2312+
},
2313+
required: ['result', 'count']
2314+
}
2315+
}
2316+
]
2317+
}));
2318+
2319+
server.setRequestHandler('tools/call', async () => {
2320+
// Error envelope carrying a structuredContent shape that does NOT match outputSchema.
2321+
// Server-side validateToolOutput short-circuits on isError; client must behave the same.
2322+
return {
2323+
isError: true,
2324+
content: [{ type: 'text', text: 'NOT_FOUND' }],
2325+
structuredContent: { error: { code: 'NOT_FOUND', message: 'nope' } }
2326+
};
2327+
});
2328+
2329+
const client = new Client(
2330+
{
2331+
name: 'test-client',
2332+
version: '1.0.0'
2333+
},
2334+
{ capabilities: { tasks: { requests: { tools: { call: {} } } } } }
2335+
);
2336+
2337+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
2338+
2339+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
2340+
2341+
await client.listTools();
2342+
2343+
const result = await client.callTool({ name: 'test-tool' });
2344+
expect(result.isError).toBe(true);
2345+
expect(result.structuredContent).toEqual({ error: { code: 'NOT_FOUND', message: 'nope' } });
2346+
});
22812347
});
22822348

22832349
describe('Task-based execution', () => {
@@ -4087,6 +4153,85 @@ test('callToolStream() should not validate structuredContent when isError is tru
40874153
await server.close();
40884154
});
40894155

4156+
test('callToolStream() should not validate structuredContent when isError is true', async () => {
4157+
const server = new Server(
4158+
{
4159+
name: 'test-server',
4160+
version: '1.0.0'
4161+
},
4162+
{
4163+
capabilities: {
4164+
tools: {}
4165+
}
4166+
}
4167+
);
4168+
4169+
server.setRequestHandler('tools/list', async () => ({
4170+
tools: [
4171+
{
4172+
name: 'test-tool',
4173+
description: 'A test tool',
4174+
inputSchema: {
4175+
type: 'object',
4176+
properties: {}
4177+
},
4178+
outputSchema: {
4179+
type: 'object',
4180+
properties: {
4181+
result: { type: 'string' }
4182+
},
4183+
required: ['result']
4184+
}
4185+
}
4186+
]
4187+
}));
4188+
4189+
server.setRequestHandler('tools/call', async () => {
4190+
// Error envelope carrying a structuredContent shape that does NOT match outputSchema.
4191+
// Server-side validateToolOutput short-circuits on isError; client must behave the same.
4192+
return {
4193+
isError: true,
4194+
content: [{ type: 'text', text: 'NOT_FOUND' }],
4195+
structuredContent: { error: { code: 'NOT_FOUND', message: 'nope' } }
4196+
};
4197+
});
4198+
4199+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
4200+
4201+
const client = new Client(
4202+
{
4203+
name: 'test-client',
4204+
version: '1.0.0'
4205+
},
4206+
{
4207+
capabilities: { tasks: { requests: { tools: { call: {} } } } }
4208+
}
4209+
);
4210+
4211+
await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
4212+
4213+
await client.listTools();
4214+
4215+
const stream = client.experimental.tasks.callToolStream({ name: 'test-tool', arguments: {} });
4216+
4217+
const messages = [];
4218+
for await (const message of stream) {
4219+
messages.push(message);
4220+
}
4221+
4222+
// Should have received result (not error), with isError flag set and structuredContent preserved
4223+
expect(messages.length).toBe(1);
4224+
expect(messages[0]!.type).toBe('result');
4225+
if (messages[0]!.type === 'result') {
4226+
const result = messages[0]!.result as CallToolResult;
4227+
expect(result.isError).toBe(true);
4228+
expect(result.structuredContent).toEqual({ error: { code: 'NOT_FOUND', message: 'nope' } });
4229+
}
4230+
4231+
await client.close();
4232+
await server.close();
4233+
});
4234+
40904235
describe('getSupportedElicitationModes', () => {
40914236
test('should support nothing when capabilities are undefined', () => {
40924237
const result = getSupportedElicitationModes(undefined);

0 commit comments

Comments
 (0)