Skip to content

A handler that throws McpError produces a double-prefixed message on the client #2786

Open

Description

@chrikrah

What happens

A request handler that throws McpError produces a message the client shows with the prefix twice.
Reproduced with the SDK alone over InMemoryTransport on 1.24.3, and the code path is unchanged in the
current 1.30.0:

server.setRequestHandler(CallToolRequestSchema, async () => {
  throw new McpError(ErrorCode.MethodNotFound, "Unknown tool: nope");
});
// ...
try { await client.callTool({ arguments: {}, name: "nope" }); }
catch (e) { console.log("client received:", e.message); }
server threw:   MCP error -32601: Unknown tool: nope
client received: MCP error -32601: MCP error -32601: Unknown tool: nope

Why

Three steps, each defensible alone:

  1. McpError's constructor calls super(`MCP error ${code}: ${message}`), so .message already carries
    the prefix.
  2. The server serialises a thrown error as message: error.message, so the prefix travels inside the
    JSON-RPC error.message field.
  3. Protocol._onresponse converts it back with McpError.fromError(response.error.code, response.error.message, response.error.data), whose default branch returns
    new McpError(code, message, data) and prefixes what is already prefixed. I confirmed at runtime that
    this is the path a callTool rejection takes, by counting calls into fromError: exactly one.

_onresponse also holds a new McpError(...) conversion in its _requestResolvers branch, for queued
responses, which double-prefixes for the same reason.

Throwing McpError is the SDK's own mechanism and shared/protocol throws it in several places itself, so
this is the default result rather than a misuse.

What I expected

One prefix. Either the JSON-RPC error.message carries the bare message, or the client stops re-wrapping a
message that already has the prefix.

Not checked

Only InMemoryTransport, and only a callTool rejection. The one branch of fromError that does not take
the default path is UrlElicitationRequired carrying elicitations, which returns
UrlElicitationRequiredError; that class calls super with the same code, so I would expect it to prefix
too, but I did not exercise it.

Activity

  1. Ben-Home commented on Sep 11, 2026

    @Ben-Home
  2. pj-workspace commented on Sep 11, 2026

    @pj-workspace

    Confirmed on the published packages with Node 24.19.0: @modelcontextprotocol/[email protected] and the v2 client/server packages at 2.0.0. I recorded the server's error response at the public InMemoryTransport.send boundary and checked all four server/client combinations. All negotiated protocol version 2025-11-25.

    For the -32601 error in the report:

    Server → client Prefixes in wire error.message Prefixes in client .message
    v1 → v1 1 2
    v1 → v2 1 1
    v2 → v1 0 1
    v2 → v2 0 0

    I also exercised the previously unchecked UrlElicitationRequiredError path (-32042): it has the same message behavior, while code, data.elicitations, and the reconstructed specialized error type are preserved. A plain Error control does not acquire a server-side prefix. These checks cover ordinary callTool rejection over in-memory transport, not task-queued responses or HTTP.

    #1727 already addressed this in v2 by removing the constructor prefix, replacing #1578's separate wire-message approach. It explicitly identified the local .message change as breaking, so I would not assume the same constructor change is appropriate for v1.

    Would a v1.x fix that preserves the locally constructed McpError.message format while serializing the original, unprefixed message be welcome, or would you prefer documenting the v2 behavior here? If a v1 patch is desired, I can work on it with wire/client regression coverage, including URL elicitation, without heuristically stripping text from arbitrary error messages.

  3. LouisDeconinck commented on Sep 11, 2026

    @LouisDeconinck

    I'd like to take this. From the trace, the client re-wraps the server's error message when it builds its own McpError, so the MCP error -32601: prefix gets applied twice — I'll look at skipping the prefix when the incoming message already carries one, and cover both branches if the v1/v2 code paths differ.

  4. fszcd commented on Sep 13, 2026

    @fszcd

    I'd like to work on this, could a maintainer assign it to me?

    Proposed approach (v1.x branch, no breaking change to McpError.message): the v1 server should not put the constructor's MCP error <code>: prefix on the wire. When serializing a thrown McpError, send the original (unprefixed) message — the prefix is a local-presentation concern, and both v1 and v2 clients add it when reconstructing. Per @pj-workspace's matrix this fixes the v1→v1 double prefix (wire goes from 1 prefix to 0) and keeps v1→v2 clean; v2 rows are already correct. Will add a regression test asserting the wire message and the client-visible message for a handler that throws McpError.

  5. added
    v1Issues / PRs related to v1.x
    on Sep 23, 2026
  6. errmakov commented on Sep 27, 2026

    @errmakov

    I'd like to take this — could a maintainer assign it to me?

  7. Majny commented on Oct 18, 2026

    @Majny

    Related, but not covered by #2881: on v1.x the prefix also reaches model-visible tool results. McpServer's tools/call catch passes error.message to createToolError (mcp.ts#L282-L289), so a handler-thrown McpError, an input validation failure (L346) and an unknown/disabled tool (L239-L242) all come back as isError text starting with MCP error -32602: .
    This happens on the server alone, so #2881's wire and fromError changes don't affect it. McpServer + Client over InMemoryTransport on 1.32.1 (the #2881 preview build prints the same three lines):

    handler throws McpError: isError=true text="MCP error -32602: No record x"
    invalid input: isError=true text="MCP error -32602: Input validation error: Invalid arguments for tool lookup: Invalid input: expected string, received number at id"
    unknown tool: isError=true text="MCP error -32602: Tool nope not found"
    

    With #2881 applied, using error.rawMessage for McpError in that catch removed the prefix in all three cases (tried on a local copy of the preview build). I can open that as a small v1.x follow-up if you want it.. v2 already avoids this after #1727 and #1389. In my own server I work around it by throwing a plain Error.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    v1Issues / PRs related to v1.x

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions