From 05afe68bdcf8d9f2a8610edd95963c97eb2c1e94 Mon Sep 17 00:00:00 2001 From: ggbond <2256433591@qq.com> Date: Sun, 13 Sep 2026 14:48:43 +0800 Subject: [PATCH 1/3] fix(protocol): send the original McpError message, not the prefixed one A handler that throws McpError produced a wire error whose message already carried the local `MCP error :` prefix, so a v1 peer reconstructing it via `new McpError(code, message)` displayed the prefix twice ("MCP error -32601: MCP error -32601: ..."). Retain the constructor argument as `McpError.originalMessage` and send that on the wire; peers add the prefix when reconstructing, matching the v2 behavior where the constructor no longer prefixes. Fixes #2786 --- src/shared/protocol.ts | 5 +- src/types.ts | 8 +++ .../test_2786_mcp_error_wire_message.test.ts | 49 +++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 test/issues/test_2786_mcp_error_wire_message.test.ts diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 2637be65bc..6d42522ad4 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -822,7 +822,10 @@ export abstract class Protocol:` prefix, which is + // reconstructed by the receiving peer; send the original message instead of + // leaking the local prefixed form onto the wire (#2786). + message: error instanceof McpError ? error.originalMessage : (error.message ?? 'Internal error'), ...(error['data'] !== undefined && { data: error['data'] }) } }; diff --git a/src/types.ts b/src/types.ts index 835eac89f8..6dabc1aa63 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2305,6 +2305,13 @@ export const ServerResultSchema = z.union([ ]); export class McpError extends Error { + /** + * The message exactly as passed to the constructor, without the + * `MCP error :` prefix that `.message` carries. Peers reconstruct + * the prefixed form from `code`, so this is what belongs on the wire. + */ + public readonly originalMessage: string; + constructor( public readonly code: number, message: string, @@ -2312,6 +2319,7 @@ export class McpError extends Error { ) { super(`MCP error ${code}: ${message}`); this.name = 'McpError'; + this.originalMessage = message; } /** diff --git a/test/issues/test_2786_mcp_error_wire_message.test.ts b/test/issues/test_2786_mcp_error_wire_message.test.ts new file mode 100644 index 0000000000..6f48087cee --- /dev/null +++ b/test/issues/test_2786_mcp_error_wire_message.test.ts @@ -0,0 +1,49 @@ +import { Client } from '../../src/client/index.js'; +import { InMemoryTransport } from '../../src/inMemory.js'; +import { Server } from '../../src/server/index.js'; +import { CallToolRequestSchema, ErrorCode, McpError, type JSONRPCError } from '../../src/types.js'; + +describe('Issue #2786: a handler-thrown McpError must not be double-prefixed', () => { + test('wire message carries the original message; client reconstructs a single prefix', async () => { + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + const server = new Server({ name: 'test-server', version: '1.0.0' }, { capabilities: { tools: {} } }); + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + server.setRequestHandler(CallToolRequestSchema, async () => { + throw new McpError(ErrorCode.MethodNotFound, 'Unknown tool: nope'); + }); + + // Capture the raw JSON-RPC error the server puts on the wire. + const wireErrors: JSONRPCError[] = []; + const originalSend = serverTransport.send.bind(serverTransport); + serverTransport.send = async message => { + if ('error' in message) { + wireErrors.push(message); + } + return originalSend(message); + }; + + await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]); + + let caught: unknown; + try { + await client.callTool({ name: 'nope', arguments: {} }); + } catch (error) { + caught = error; + } finally { + await Promise.all([client.close(), server.close()]); + } + + // The wire carries the original message, without the local `MCP error :` prefix... + expect(wireErrors).toHaveLength(1); + expect(wireErrors[0]?.error.code).toBe(ErrorCode.MethodNotFound); + expect(wireErrors[0]?.error.message).toBe('Unknown tool: nope'); + + // ...and the client reconstructs exactly one prefix. + expect(caught).toBeInstanceOf(McpError); + const mcpError = caught as McpError; + expect(mcpError.code).toBe(ErrorCode.MethodNotFound); + expect(mcpError.message).toBe('MCP error -32601: Unknown tool: nope'); + expect(mcpError.originalMessage).toBe('Unknown tool: nope'); + }); +}); From 7d7e5018956f187ac3309517548315592f452882 Mon Sep 17 00:00:00 2001 From: GGbond <2256433591@qq.com> Date: Sun, 13 Sep 2026 18:11:15 +0800 Subject: [PATCH 2/3] chore: add changeset for McpError double-prefix fix --- .changeset/mcperror-double-prefix.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/mcperror-double-prefix.md diff --git a/.changeset/mcperror-double-prefix.md b/.changeset/mcperror-double-prefix.md new file mode 100644 index 0000000000..7e32ce6bfd --- /dev/null +++ b/.changeset/mcperror-double-prefix.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/sdk": patch +--- + +Fix v1 server sending McpError messages with a doubled prefix on the wire \ No newline at end of file From 95a404d7e234711c7bf70cd9bd05b58fa3515280 Mon Sep 17 00:00:00 2001 From: ggbond <2256433591@qq.com> Date: Sun, 13 Sep 2026 19:53:21 +0800 Subject: [PATCH 3/3] chore: format changeset with prettier --- .changeset/mcperror-double-prefix.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/mcperror-double-prefix.md b/.changeset/mcperror-double-prefix.md index 7e32ce6bfd..2848b8b127 100644 --- a/.changeset/mcperror-double-prefix.md +++ b/.changeset/mcperror-double-prefix.md @@ -1,5 +1,5 @@ --- -"@modelcontextprotocol/sdk": patch +'@modelcontextprotocol/sdk': patch --- -Fix v1 server sending McpError messages with a doubled prefix on the wire \ No newline at end of file +Fix v1 server sending McpError messages with a doubled prefix on the wire