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.
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 withisError: true. "Server errors" is explicitly listed under Protocol Errors.In
McpServer'sCallToolRequestSchemahandler (server/mcp.js), the entire tool invocation is wrapped in a singletry/catch:createToolErroralways returns{ content: [...], isError: true }. Every thrown error — regardless ofMcpErrorcode, 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-RPCerrorisMcpErrorwith codeUrlElicitationRequired, and that's for an unrelated reason (the multi-round-trip input flow).To Reproduce
Calling this tool always returns
isError: trueover a normal (e.g. HTTP 200 on Streamable HTTP) response — never a top-level JSON-RPCerror, no matter what's thrown or what code anMcpErrorcarries.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.