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
6 changes: 6 additions & 0 deletions .changeset/sep-1303-input-validation-tool-error.md
Original file line number Diff line number Diff line change
@@ -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.
37 changes: 29 additions & 8 deletions src/server/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -262,9 +276,9 @@ export class McpServer {
? SchemaOutput<InputSchema>
: undefined
: undefined
>(tool: Tool, args: Args, toolName: string): Promise<Args> {
>(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)
Expand All @@ -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 };
}

/**
Expand Down Expand Up @@ -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<ZodRawShapeCompat | undefined>;
const taskExtra = { ...extra, taskStore: extra.taskStore };

Expand Down
119 changes: 119 additions & 0 deletions test/issues/test_1956_sep1303_input_validation.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading