From 2198620812a5e340b7019edbf903ee8b602a417c Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 03:40:59 +0800 Subject: [PATCH 1/3] Return protocol errors for invalid tool arguments --- packages/server/src/server/mcp.ts | 4 +- .../server/test/server/mcp.compat.test.ts | 52 ++++++++++++++++++- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index fb45fd5db6..06ee99847d 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -208,8 +208,8 @@ export class McpServer { await this.validateToolOutput(tool, result, request.params.name); return result; } catch (error) { - if (error instanceof ProtocolError && error.code === ProtocolErrorCode.UrlElicitationRequired) { - throw error; // Return the error to the caller without wrapping in CallToolResult + if (error instanceof ProtocolError) { + throw error; // Return protocol-level errors to the caller without wrapping in CallToolResult } return this.createToolError(error instanceof Error ? error.message : String(error)); } diff --git a/packages/server/test/server/mcp.compat.test.ts b/packages/server/test/server/mcp.compat.test.ts index 322b615353..a3dc73ed3a 100644 --- a/packages/server/test/server/mcp.compat.test.ts +++ b/packages/server/test/server/mcp.compat.test.ts @@ -2,9 +2,10 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { InMemoryTransport, isStandardSchema, LATEST_PROTOCOL_VERSION } from '@modelcontextprotocol/core'; import { describe, expect, expectTypeOf, it, vi } from 'vitest'; import * as z from 'zod/v4'; + import { McpServer } from '../../src/index.js'; -import type { InferRawShape } from '../../src/server/mcp.js'; import { completable } from '../../src/server/completable.js'; +import type { InferRawShape } from '../../src/server/mcp.js'; describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () => { it('registerTool accepts a raw shape for inputSchema and auto-wraps it', () => { @@ -119,6 +120,55 @@ describe('registerTool/registerPrompt accept raw Zod shape (auto-wrapped)', () = await server.close(); }); + + it('returns a protocol error for invalid tool arguments', async () => { + const server = new McpServer({ name: 't', version: '1.0.0' }); + + const handler = vi.fn(async () => ({ + content: [{ type: 'text' as const, text: 'should not run' }] + })); + server.registerTool('echo', { inputSchema: { x: z.number() } }, handler); + + const [client, srv] = InMemoryTransport.createLinkedPair(); + await server.connect(srv); + await client.start(); + + const responses: JSONRPCMessage[] = []; + client.onmessage = m => responses.push(m); + + await client.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'c', version: '1.0.0' } + } + } as JSONRPCMessage); + await client.send({ jsonrpc: '2.0', method: 'notifications/initialized' } as JSONRPCMessage); + await client.send({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { name: 'echo', arguments: { x: 'not-a-number' } } + } as JSONRPCMessage); + + await vi.waitFor(() => expect(responses.some(r => 'id' in r && r.id === 2)).toBe(true)); + + expect(handler).not.toHaveBeenCalled(); + const response = responses.find(r => 'id' in r && r.id === 2) as { + error?: { code: number; message: string }; + result?: unknown; + }; + expect(response.result).toBeUndefined(); + expect(response.error).toMatchObject({ + code: -32_602, + message: expect.stringContaining('Invalid arguments for tool echo') + }); + + await server.close(); + }); }); describe('InferRawShape', () => { From 2a4e1d591cd974ceadd85f1b0a732a7bff0a9eb6 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 04:14:19 +0800 Subject: [PATCH 2/3] Preserve tool error semantics for non-input failures --- packages/server/src/server/mcp.ts | 11 +- test/integration/test/server/mcp.test.ts | 114 +++++------ test/integration/test/standardSchema.test.ts | 189 +++++++++++-------- 3 files changed, 163 insertions(+), 151 deletions(-) diff --git a/packages/server/src/server/mcp.ts b/packages/server/src/server/mcp.ts index 06ee99847d..455fac4797 100644 --- a/packages/server/src/server/mcp.ts +++ b/packages/server/src/server/mcp.ts @@ -208,8 +208,8 @@ export class McpServer { await this.validateToolOutput(tool, result, request.params.name); return result; } catch (error) { - if (error instanceof ProtocolError) { - throw error; // Return protocol-level errors to the caller without wrapping in CallToolResult + if (error instanceof ProtocolError && this.shouldReturnProtocolError(error)) { + throw error; // Invalid client-supplied arguments and elicitation requests are protocol errors. } return this.createToolError(error instanceof Error ? error.message : String(error)); } @@ -236,6 +236,13 @@ export class McpServer { }; } + private shouldReturnProtocolError(error: ProtocolError): boolean { + return ( + error.code === ProtocolErrorCode.UrlElicitationRequired || + (error.code === ProtocolErrorCode.InvalidParams && error.message.startsWith('Input validation error:')) + ); + } + /** * Validates tool input arguments against the tool's input schema. */ diff --git a/test/integration/test/server/mcp.test.ts b/test/integration/test/server/mcp.test.ts index 92af09744c..e34eb0e001 100644 --- a/test/integration/test/server/mcp.test.ts +++ b/test/integration/test/server/mcp.test.ts @@ -1,5 +1,5 @@ import { Client } from '@modelcontextprotocol/client'; -import type { CallToolResult, Notification, TextContent } from '@modelcontextprotocol/core'; +import type { CallToolResult, Notification, ProtocolError, TextContent } from '@modelcontextprotocol/core'; import { getDisplayName, InMemoryTaskStore, @@ -28,6 +28,18 @@ function createLatch() { }; } +async function expectInvalidToolArguments(call: Promise): Promise { + try { + await call; + throw new Error('Expected invalid tool arguments to reject'); + } catch (error) { + const protocolError = error as ProtocolError; + expect(protocolError.code).toBe(ProtocolErrorCode.InvalidParams); + expect(protocolError.message).toContain('Input validation error'); + return protocolError; + } +} + describe('Zod v4', () => { describe('McpServer', () => { /*** @@ -1212,26 +1224,20 @@ describe('Zod v4', () => { await Promise.all([client.connect(clientTransport), mcpServer.server.connect(serverTransport)]); - const result = await client.request({ - method: 'tools/call', - params: { - name: 'test', - arguments: { + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'test', - value: 'not a number' - } - } - }); - - expect(result.isError).toBe(true); - expect(result.content).toEqual( - expect.arrayContaining([ - { - type: 'text', - text: expect.stringContaining('Input validation error: Invalid arguments for tool test') + arguments: { + name: 'test', + value: 'not a number' + } } - ]) + }) ); + + expect(error.message).toContain('Input validation error: Invalid arguments for tool test'); }); /*** @@ -5149,22 +5155,14 @@ describe('Zod v4', () => { await server.connect(serverTransport); await client.connect(clientTransport); - const invalidTypeResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'a', - value: 123 - } - }); - - expect(invalidTypeResult.isError).toBe(true); - expect(invalidTypeResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) + await expectInvalidToolArguments( + client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }) ); }); }); @@ -6407,40 +6405,24 @@ describe('Zod v4', () => { await server.connect(serverTransport); await client.connect(clientTransport); - const invalidTypeResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'a', - value: 123 - } - }); - - expect(invalidTypeResult.isError).toBe(true); - expect(invalidTypeResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) + await expectInvalidToolArguments( + client.callTool({ + name: 'union-test', + arguments: { + type: 'a', + value: 123 + } + }) ); - const invalidDiscriminatorResult = await client.callTool({ - name: 'union-test', - arguments: { - type: 'c', - value: 'test' - } - }); - - expect(invalidDiscriminatorResult.isError).toBe(true); - expect(invalidDiscriminatorResult.content).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - type: 'text', - text: expect.stringContaining('Input validation error') - }) - ]) + await expectInvalidToolArguments( + client.callTool({ + name: 'union-test', + arguments: { + type: 'c', + value: 'test' + } + }) ); }); }); diff --git a/test/integration/test/standardSchema.test.ts b/test/integration/test/standardSchema.test.ts index 67f16c5fa7..c716454c42 100644 --- a/test/integration/test/standardSchema.test.ts +++ b/test/integration/test/standardSchema.test.ts @@ -4,8 +4,8 @@ */ import { Client } from '@modelcontextprotocol/client'; -import type { TextContent } from '@modelcontextprotocol/core'; -import { AjvJsonSchemaValidator, fromJsonSchema, InMemoryTransport } from '@modelcontextprotocol/core'; +import type { ProtocolError, TextContent } from '@modelcontextprotocol/core'; +import { AjvJsonSchemaValidator, fromJsonSchema, InMemoryTransport, ProtocolErrorCode } from '@modelcontextprotocol/core'; import { completable, fromJsonSchema as serverFromJsonSchema, McpServer } from '@modelcontextprotocol/server'; import { toStandardJsonSchema } from '@valibot/to-json-schema'; import { type } from 'arktype'; @@ -33,6 +33,18 @@ describe('Standard Schema Support', () => { await Promise.all([client.connect(clientTransport), mcpServer.connect(serverTransport)]); } + async function expectInvalidToolArguments(call: Promise): Promise { + try { + await call; + throw new Error('Expected invalid tool arguments to reject'); + } catch (error) { + const protocolError = error as ProtocolError; + expect(protocolError.code).toBe(ProtocolErrorCode.InvalidParams); + expect(protocolError.message).toContain('Input validation error'); + return protocolError; + } + } + describe('ArkType schemas', () => { describe('tool registration', () => { test('should register tool with ArkType input schema', async () => { @@ -130,13 +142,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { name: 'double', arguments: { value: 'not a number' } } - }); + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'double', arguments: { value: 'not a number' } } + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; expect(errorText).toContain('Input validation error'); expect(errorText).toContain('value'); expect(errorText).toContain('number'); @@ -153,13 +166,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { name: 'calculate', arguments: { operation: 'divide' } } - }); + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'calculate', arguments: { operation: 'divide' } } + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; expect(errorText).toContain('Input validation error'); expect(errorText).toMatch(/add|subtract|multiply/); }); @@ -173,13 +187,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { name: 'greet', arguments: { name: 'Alice' } } - }); + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'greet', arguments: { name: 'Alice' } } + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; expect(errorText).toContain('Input validation error'); expect(errorText).toContain('age'); }); @@ -273,13 +288,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { name: 'double', arguments: { value: 'not a number' } } - }); + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'double', arguments: { value: 'not a number' } } + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; expect(errorText).toContain('Input validation error'); expect(errorText).toContain('number'); }); @@ -297,13 +313,14 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { name: 'calculate', arguments: { operation: 'divide' } } - }); + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'calculate', arguments: { operation: 'divide' } } + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; expect(errorText).toContain('Input validation error'); }); @@ -328,12 +345,13 @@ describe('Standard Schema Support', () => { expect(validResult.isError).toBeFalsy(); // Invalid value (too high) - const invalidResult = await client.request({ - method: 'tools/call', - params: { name: 'setPercentage', arguments: { percentage: 150 } } - }); - expect(invalidResult.isError).toBe(true); - const errorText = (invalidResult.content[0] as TextContent).text; + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'setPercentage', arguments: { percentage: 150 } } + }) + ); + const errorText = error.message; expect(errorText).toContain('Input validation error'); }); }); @@ -420,10 +438,11 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ method: 'tools/call', params: { name: 'double', arguments: { count: 'not a number' } } }); + const error = await expectInvalidToolArguments( + client.request({ method: 'tools/call', params: { name: 'double', arguments: { count: 'not a number' } } }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; expect(errorText).toContain('Input validation error'); }); }); @@ -456,12 +475,13 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { name: 'double-default', arguments: { count: 'not a number' } } - }); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { name: 'double-default', arguments: { count: 'not a number' } } + }) + ); + const errorText = error.message; expect(errorText).toContain('Input validation error'); }); }); @@ -595,20 +615,21 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { - name: 'test', - arguments: { - email: 123, - age: 'not a number', - status: 'unknown' + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } } - } - }); + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; // Check that error mentions the specific issues expect(errorText).toContain('Input validation error'); @@ -631,20 +652,21 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { - name: 'test', - arguments: { - email: 123, - age: 'not a number', - status: 'unknown' + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } } - } - }); + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; // Check that error mentions the specific issues expect(errorText).toContain('Input validation error'); @@ -665,20 +687,21 @@ describe('Standard Schema Support', () => { await connectClientAndServer(); - const result = await client.request({ - method: 'tools/call', - params: { - name: 'test', - arguments: { - email: 123, - age: 'not a number', - status: 'unknown' + const error = await expectInvalidToolArguments( + client.request({ + method: 'tools/call', + params: { + name: 'test', + arguments: { + email: 123, + age: 'not a number', + status: 'unknown' + } } - } - }); + }) + ); - expect(result.isError).toBe(true); - const errorText = (result.content[0] as TextContent).text; + const errorText = error.message; // Check that error mentions the specific issues expect(errorText).toContain('Input validation error'); From e307e572318edf7fb912b09140ae2a0931b6b310 Mon Sep 17 00:00:00 2001 From: FU-max-boop Date: Fri, 29 May 2026 05:14:39 +0800 Subject: [PATCH 3/3] Add changeset for tool argument protocol errors --- .changeset/tool-input-protocol-error.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tool-input-protocol-error.md diff --git a/.changeset/tool-input-protocol-error.md b/.changeset/tool-input-protocol-error.md new file mode 100644 index 0000000000..beed66f551 --- /dev/null +++ b/.changeset/tool-input-protocol-error.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server": patch +--- + +Return protocol errors for invalid tool arguments instead of tool execution errors.