Skip to content

Commit 6b528e5

Browse files
committed
fix(server): return Tool Execution Errors for input validation failures (SEP-1303)
Per SEP-1303 (spec 2025-11-25), tool input validation failures should be surfaced as Tool Execution Errors (a `CallToolResult` with `isError: true`) rather than as JSON-RPC `InvalidParams` protocol errors. Returning a tool error lets the model see the validation message and self-correct on retry, which is the motivation behind SEP-1303. `McpServer.validateToolInput` no longer throws an `McpError` on validation failure; it returns either `{ data }` on success or `{ errorResult }` with the Tool Execution Error result. Both call sites (the regular tools/call path and the automatic task polling path) handle the error result by returning it directly to the client. Output validation behavior is unchanged; SEP-1303 targets input validation. Closes #1956.
1 parent bf1e022 commit 6b528e5

3 files changed

Lines changed: 153 additions & 8 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/sdk': patch
3+
---
4+
5+
Return tool input validation failures as Tool Execution Errors (a `CallToolResult` with `isError: true`) instead of throwing JSON-RPC `InvalidParams` protocol errors. Aligns `McpServer` with [SEP-1303](https://github.com/modelcontextprotocol/modelcontextprotocol/issues/1303) (spec 2025-11-25), so the model can see the validation message and self-correct on retry. Closes #1956.

src/server/mcp.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -209,8 +209,14 @@ export class McpServer {
209209
return await this.handleAutomaticTaskPolling(tool, request, extra);
210210
}
211211

212-
// Normal execution path
213-
const args = await this.validateToolInput(tool, request.params.arguments, request.params.name);
212+
// Normal execution path. Per SEP-1303, input validation failures
213+
// are returned as Tool Execution Errors (`isError: true`), not
214+
// JSON-RPC protocol errors, so the model can self-correct.
215+
const validation = await this.validateToolInput(tool, request.params.arguments, request.params.name);
216+
if ('errorResult' in validation) {
217+
return validation.errorResult;
218+
}
219+
const args = validation.data;
214220
const result = await this.executeToolHandler(tool, args, extra);
215221

216222
// Return CreateTaskResult immediately for task requests
@@ -254,6 +260,14 @@ export class McpServer {
254260

255261
/**
256262
* Validates tool input arguments against the tool's input schema.
263+
*
264+
* Per SEP-1303 (spec 2025-11-25), validation failures are returned as a
265+
* Tool Execution Error (a `CallToolResult` with `isError: true`) so the
266+
* caller can surface the problem to the model for self-correction. This
267+
* is preferred over throwing a JSON-RPC protocol error, which would be
268+
* opaque to the model.
269+
*
270+
* Returns either `{ data }` on success or `{ errorResult }` on failure.
257271
*/
258272
private async validateToolInput<
259273
Tool extends RegisteredTool,
@@ -262,9 +276,9 @@ export class McpServer {
262276
? SchemaOutput<InputSchema>
263277
: undefined
264278
: undefined
265-
>(tool: Tool, args: Args, toolName: string): Promise<Args> {
279+
>(tool: Tool, args: Args, toolName: string): Promise<{ data: Args } | { errorResult: CallToolResult }> {
266280
if (!tool.inputSchema) {
267-
return undefined as Args;
281+
return { data: undefined as Args };
268282
}
269283

270284
// Try to normalize to object schema first (for raw shapes and object schemas)
@@ -275,10 +289,12 @@ export class McpServer {
275289
if (!parseResult.success) {
276290
const error = 'error' in parseResult ? parseResult.error : 'Unknown error';
277291
const errorMessage = getParseErrorMessage(error);
278-
throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`);
292+
return {
293+
errorResult: this.createToolError(`Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`)
294+
};
279295
}
280296

281-
return parseResult.data as unknown as Args;
297+
return { data: parseResult.data as unknown as Args };
282298
}
283299

284300
/**
@@ -369,8 +385,13 @@ export class McpServer {
369385
throw new Error('No task store provided for task-capable tool.');
370386
}
371387

372-
// Validate input and create task
373-
const args = await this.validateToolInput(tool, request.params.arguments, request.params.name);
388+
// Validate input and create task. Per SEP-1303, surface validation
389+
// failures as Tool Execution Errors instead of protocol errors.
390+
const validation = await this.validateToolInput(tool, request.params.arguments, request.params.name);
391+
if ('errorResult' in validation) {
392+
return validation.errorResult;
393+
}
394+
const args = validation.data;
374395
const handler = tool.handler as ToolTaskHandler<ZodRawShapeCompat | undefined>;
375396
const taskExtra = { ...extra, taskStore: extra.taskStore };
376397

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
/**
2+
* Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1956
3+
*
4+
* Per SEP-1303 (spec 2025-11-25), tool input validation failures must be
5+
* returned as Tool Execution Errors (a successful `CallToolResult` with
6+
* `isError: true`), not as JSON-RPC protocol errors. This lets the model
7+
* see the validation message and self-correct on retry.
8+
*
9+
* https://modelcontextprotocol.io/specification/2025-11-25/changelog
10+
*/
11+
12+
import { Client } from '../../src/client/index.js';
13+
import { InMemoryTransport } from '../../src/inMemory.js';
14+
import { CallToolResultSchema, type CallToolResult } from '../../src/types.js';
15+
import { McpServer } from '../../src/server/mcp.js';
16+
import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js';
17+
18+
describe.each(zodTestMatrix)('Issue #1956 (SEP-1303): $zodVersionLabel', (entry: ZodMatrixEntry) => {
19+
const { z } = entry;
20+
21+
test('returns Tool Execution Error (not protocol error) for invalid tool input', async () => {
22+
const mcpServer = new McpServer({
23+
name: 'test server',
24+
version: '1.0'
25+
});
26+
const client = new Client({
27+
name: 'test client',
28+
version: '1.0'
29+
});
30+
31+
mcpServer.registerTool(
32+
'add',
33+
{
34+
inputSchema: {
35+
a: z.number(),
36+
b: z.number()
37+
}
38+
},
39+
async ({ a, b }) => ({
40+
content: [{ type: 'text', text: String(a + b) }]
41+
})
42+
);
43+
44+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
45+
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
46+
47+
// Invoke with a wrong type for `b`. Per SEP-1303 the request should
48+
// resolve with isError: true (Tool Execution Error), not reject with
49+
// a JSON-RPC -32602 InvalidParams protocol error.
50+
const result = (await client.request(
51+
{
52+
method: 'tools/call',
53+
params: {
54+
name: 'add',
55+
arguments: { a: 1, b: 'two' }
56+
}
57+
},
58+
CallToolResultSchema
59+
)) as CallToolResult;
60+
61+
// Must be a successful result, not a thrown McpError.
62+
expect(result).toBeDefined();
63+
expect(result.isError).toBe(true);
64+
65+
// Content references the field name + tool name so the model can
66+
// self-correct on retry.
67+
expect(Array.isArray(result.content)).toBe(true);
68+
const text = result.content
69+
.filter((part: { type: string }) => part.type === 'text')
70+
.map((part: { type: 'text'; text: string }) => part.text)
71+
.join('\n');
72+
expect(text).toContain('Input validation error');
73+
expect(text).toContain('add');
74+
expect(text).toContain('b');
75+
});
76+
77+
test('does not invoke the tool handler when input validation fails', async () => {
78+
const mcpServer = new McpServer({
79+
name: 'test server',
80+
version: '1.0'
81+
});
82+
const client = new Client({
83+
name: 'test client',
84+
version: '1.0'
85+
});
86+
87+
let handlerCalls = 0;
88+
mcpServer.registerTool(
89+
'echo',
90+
{
91+
inputSchema: {
92+
message: z.string()
93+
}
94+
},
95+
async ({ message }) => {
96+
handlerCalls++;
97+
return { content: [{ type: 'text', text: message }] };
98+
}
99+
);
100+
101+
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
102+
await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]);
103+
104+
// Wrong type for `message`.
105+
const result = (await client.request(
106+
{
107+
method: 'tools/call',
108+
params: {
109+
name: 'echo',
110+
arguments: { message: 42 }
111+
}
112+
},
113+
CallToolResultSchema
114+
)) as CallToolResult;
115+
116+
expect(result.isError).toBe(true);
117+
expect(handlerCalls).toBe(0);
118+
});
119+
});

0 commit comments

Comments
 (0)