Skip to content

tools/call has no path to a protocol-level "Server errors" response, even for unhandled exceptions #2656

Description

@hadaromash

The Tools spec defines two error mechanisms: Protocol Errors (unknown tool, malformed requests, server errors) returned as a JSON-RPC error, and Tool Execution Errors (API failures, input validation, business logic) returned as a normal result with isError: true. "Server errors" is explicitly listed under Protocol Errors.

In McpServer's CallToolRequestSchema handler (server/mcp.js), the entire tool invocation is wrapped in a single try/catch:

catch (error) {
    if (error instanceof McpError) {
        if (error.code === ErrorCode.UrlElicitationRequired) {
            throw error; // escapes as a real protocol error
        }
    }
    return this.createToolError(error instanceof Error ? error.message : String(error));
}

createToolError always returns { content: [...], isError: true }. Every thrown error — regardless of McpError code, regardless of whether it represents a business-level failure or a genuine unhandled/internal exception — is funneled into a Tool Execution Error. The only value that ever escapes this catch and becomes a real JSON-RPC error is McpError with code UrlElicitationRequired, and that's for an unrelated reason (the multi-round-trip input flow).

To Reproduce

server.registerTool("example", { description: "..." }, async () => {
  throw new Error("something actually crashed, unrelated to tool args or business logic");
});

Calling this tool always returns isError: true over a normal (e.g. HTTP 200 on Streamable HTTP) response — never a top-level JSON-RPC error, no matter what's thrown or what code an McpError carries.

Expected behavior
Some way for an error to legitimately escape as a Protocol Error / "Server errors" response, since the spec defines that case explicitly. Right now there's no way to distinguish, at the protocol level, "the tool intentionally reported a failure" from "the handler crashed" — everything becomes isError: true.

Related: #1429 (same catch-all is why raw internal error messages can leak to the client) and #2162 / #1956 (same catch-all also suppresses a protocol-error path for invalid-argument errors specifically). This issue is about the general case — there's no way to reach the spec's "server errors" protocol-error case at all, for any kind of unhandled exception.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions