From 24ee8a0f908475e97a11163863f4963298cd6bf8 Mon Sep 17 00:00:00 2001 From: Mukunda Rao Katta Date: Sun, 26 Apr 2026 15:07:48 -0700 Subject: [PATCH] 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. --- .../sep-1303-input-validation-tool-error.md | 6 + src/server/mcp.ts | 37 ++++-- ...test_1956_sep1303_input_validation.test.ts | 119 ++++++++++++++++++ 3 files changed, 154 insertions(+), 8 deletions(-) create mode 100644 .changeset/sep-1303-input-validation-tool-error.md create mode 100644 test/issues/test_1956_sep1303_input_validation.test.ts diff --git a/.changeset/sep-1303-input-validation-tool-error.md b/.changeset/sep-1303-input-validation-tool-error.md new file mode 100644 index 000000000..1ad81329d --- /dev/null +++ b/.changeset/sep-1303-input-validation-tool-error.md @@ -0,0 +1,6 @@ +--- +'@modelcontextprotocol/sdk': patch +--- + +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. diff --git a/src/server/mcp.ts b/src/server/mcp.ts index 9fe0ed549..3349476de 100644 --- a/src/server/mcp.ts +++ b/src/server/mcp.ts @@ -209,8 +209,14 @@ export class McpServer { return await this.handleAutomaticTaskPolling(tool, request, extra); } - // Normal execution path - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + // Normal execution path. Per SEP-1303, input validation failures + // are returned as Tool Execution Errors (`isError: true`), not + // JSON-RPC protocol errors, so the model can self-correct. + const validation = await this.validateToolInput(tool, request.params.arguments, request.params.name); + if ('errorResult' in validation) { + return validation.errorResult; + } + const args = validation.data; const result = await this.executeToolHandler(tool, args, extra); // Return CreateTaskResult immediately for task requests @@ -254,6 +260,14 @@ export class McpServer { /** * Validates tool input arguments against the tool's input schema. + * + * Per SEP-1303 (spec 2025-11-25), validation failures are returned as a + * Tool Execution Error (a `CallToolResult` with `isError: true`) so the + * caller can surface the problem to the model for self-correction. This + * is preferred over throwing a JSON-RPC protocol error, which would be + * opaque to the model. + * + * Returns either `{ data }` on success or `{ errorResult }` on failure. */ private async validateToolInput< Tool extends RegisteredTool, @@ -262,9 +276,9 @@ export class McpServer { ? SchemaOutput : undefined : undefined - >(tool: Tool, args: Args, toolName: string): Promise { + >(tool: Tool, args: Args, toolName: string): Promise<{ data: Args } | { errorResult: CallToolResult }> { if (!tool.inputSchema) { - return undefined as Args; + return { data: undefined as Args }; } // Try to normalize to object schema first (for raw shapes and object schemas) @@ -275,10 +289,12 @@ export class McpServer { if (!parseResult.success) { const error = 'error' in parseResult ? parseResult.error : 'Unknown error'; const errorMessage = getParseErrorMessage(error); - throw new McpError(ErrorCode.InvalidParams, `Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`); + return { + errorResult: this.createToolError(`Input validation error: Invalid arguments for tool ${toolName}: ${errorMessage}`) + }; } - return parseResult.data as unknown as Args; + return { data: parseResult.data as unknown as Args }; } /** @@ -369,8 +385,13 @@ export class McpServer { throw new Error('No task store provided for task-capable tool.'); } - // Validate input and create task - const args = await this.validateToolInput(tool, request.params.arguments, request.params.name); + // Validate input and create task. Per SEP-1303, surface validation + // failures as Tool Execution Errors instead of protocol errors. + const validation = await this.validateToolInput(tool, request.params.arguments, request.params.name); + if ('errorResult' in validation) { + return validation.errorResult; + } + const args = validation.data; const handler = tool.handler as ToolTaskHandler; const taskExtra = { ...extra, taskStore: extra.taskStore }; diff --git a/test/issues/test_1956_sep1303_input_validation.test.ts b/test/issues/test_1956_sep1303_input_validation.test.ts new file mode 100644 index 000000000..00f964fb4 --- /dev/null +++ b/test/issues/test_1956_sep1303_input_validation.test.ts @@ -0,0 +1,119 @@ +/** + * Regression test for https://github.com/modelcontextprotocol/typescript-sdk/issues/1956 + * + * Per SEP-1303 (spec 2025-11-25), tool input validation failures must be + * returned as Tool Execution Errors (a successful `CallToolResult` with + * `isError: true`), not as JSON-RPC protocol errors. This lets the model + * see the validation message and self-correct on retry. + * + * https://modelcontextprotocol.io/specification/2025-11-25/changelog + */ + +import { Client } from '../../src/client/index.js'; +import { InMemoryTransport } from '../../src/inMemory.js'; +import { CallToolResultSchema, type CallToolResult } from '../../src/types.js'; +import { McpServer } from '../../src/server/mcp.js'; +import { zodTestMatrix, type ZodMatrixEntry } from '../../src/__fixtures__/zodTestMatrix.js'; + +describe.each(zodTestMatrix)('Issue #1956 (SEP-1303): $zodVersionLabel', (entry: ZodMatrixEntry) => { + const { z } = entry; + + test('returns Tool Execution Error (not protocol error) for invalid tool input', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + mcpServer.registerTool( + 'add', + { + inputSchema: { + a: z.number(), + b: z.number() + } + }, + async ({ a, b }) => ({ + content: [{ type: 'text', text: String(a + b) }] + }) + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Invoke with a wrong type for `b`. Per SEP-1303 the request should + // resolve with isError: true (Tool Execution Error), not reject with + // a JSON-RPC -32602 InvalidParams protocol error. + const result = (await client.request( + { + method: 'tools/call', + params: { + name: 'add', + arguments: { a: 1, b: 'two' } + } + }, + CallToolResultSchema + )) as CallToolResult; + + // Must be a successful result, not a thrown McpError. + expect(result).toBeDefined(); + expect(result.isError).toBe(true); + + // Content references the field name + tool name so the model can + // self-correct on retry. + expect(Array.isArray(result.content)).toBe(true); + const text = result.content + .filter((part): part is { type: 'text'; text: string } => part.type === 'text') + .map(part => part.text) + .join('\n'); + expect(text).toContain('Input validation error'); + expect(text).toContain('add'); + expect(text).toContain('b'); + }); + + test('does not invoke the tool handler when input validation fails', async () => { + const mcpServer = new McpServer({ + name: 'test server', + version: '1.0' + }); + const client = new Client({ + name: 'test client', + version: '1.0' + }); + + let handlerCalls = 0; + mcpServer.registerTool( + 'echo', + { + inputSchema: { + message: z.string() + } + }, + async ({ message }) => { + handlerCalls++; + return { content: [{ type: 'text', text: message }] }; + } + ); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); + + // Wrong type for `message`. + const result = (await client.request( + { + method: 'tools/call', + params: { + name: 'echo', + arguments: { message: 42 } + } + }, + CallToolResultSchema + )) as CallToolResult; + + expect(result.isError).toBe(true); + expect(handlerCalls).toBe(0); + }); +});