From e361bd2c5c30f570c104cd2b0f824a71630d2f21 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:18:35 -0700 Subject: [PATCH] Surface MCP JSON-RPC refusals as typed tool failures An MCP server that rejects tools/call with a JSON-RPC error (Stripe's -32602 invalid params, for one) fell through the dispatch catch as an opaque "Internal tool error [id]", so the model read a bad argument as an integration outage. Carry the server's code and message on McpInvocationError and answer with an mcp_tool_error result instead. Co-Authored-By: Claude Fable 5.1 --- .changeset/mcp-jsonrpc-refusal.md | 5 ++ packages/plugins/mcp/src/sdk/errors.ts | 8 +++ packages/plugins/mcp/src/sdk/invoke.test.ts | 8 +++ packages/plugins/mcp/src/sdk/invoke.ts | 12 ++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 60 +++++++++++++++++---- packages/plugins/mcp/src/sdk/plugin.ts | 15 ++++++ 6 files changed, 96 insertions(+), 12 deletions(-) create mode 100644 .changeset/mcp-jsonrpc-refusal.md diff --git a/.changeset/mcp-jsonrpc-refusal.md b/.changeset/mcp-jsonrpc-refusal.md new file mode 100644 index 0000000000..05952c0d72 --- /dev/null +++ b/.changeset/mcp-jsonrpc-refusal.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +**Fix: an MCP server refusing a tool call with a JSON-RPC error (for example `-32602 Invalid params`) surfaced as `Internal tool error [id]`.** The server's answer is for the caller, so it now comes back as a typed `mcp_tool_error` failure carrying the server's message and JSON-RPC code, and the model can correct the arguments instead of reading an outage. diff --git a/packages/plugins/mcp/src/sdk/errors.ts b/packages/plugins/mcp/src/sdk/errors.ts index dd14d2c5fd..31c53d7147 100644 --- a/packages/plugins/mcp/src/sdk/errors.ts +++ b/packages/plugins/mcp/src/sdk/errors.ts @@ -79,6 +79,14 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ * grant cannot fix it, so the failure must not be labelled * connection_rejected. */ readonly insufficientScope?: boolean; + /** The server answered `tools/call` with a JSON-RPC error response (the + * spec's protocol error: invalid params, internal error, ...). The call + * reached the server and was refused on its merits, so the code and the + * server's own message are the failure — not an infrastructure defect. */ + readonly protocolError?: { + readonly code: number; + readonly message: string; + }; }> {} export class McpOAuthReauthorizationRequired extends Data.TaggedError( diff --git a/packages/plugins/mcp/src/sdk/invoke.test.ts b/packages/plugins/mcp/src/sdk/invoke.test.ts index 2585776d2e..8412d749f8 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -124,13 +124,18 @@ const invocationRejectionCases = [ status: 401, }), expectedStatus: 401 as number | undefined, + expectedProtocolError: undefined as { code: number; message: string } | undefined, }, { + // The JSON-RPC error is the server's own answer to the call: its code is + // not an HTTP status, and its message is kept (structurally, beside the + // sanitized invocation message) so the plugin can hand it to the caller. name: "does not treat MCP protocol error codes as HTTP statuses", toolId: "protocol_error", transport: "streamable-http", cause: new ProtocolError(401, "application-level do-not-leak"), expectedStatus: undefined, + expectedProtocolError: { code: 401, message: "application-level do-not-leak" }, }, { name: "does not invent a status from non-HTTP rejection shapes", @@ -138,6 +143,7 @@ const invocationRejectionCases = [ transport: "streamable-http", cause: { code: -1, message: "socket said do-not-leak" }, expectedStatus: undefined, + expectedProtocolError: undefined, }, { name: "extracts the status from the SDK SSE POST error prefix without leaking the body", @@ -147,6 +153,7 @@ const invocationRejectionCases = [ message: "Error POSTing to endpoint (HTTP 403): do-not-leak: upstream auth challenge", }, expectedStatus: 403, + expectedProtocolError: undefined, }, ]; @@ -313,6 +320,7 @@ describe("invokeMcpTool", () => { }); expect(invocation.status).toBe(testCase.expectedStatus); expect("cause" in invocation).toBe(false); + expect(invocation.protocolError).toEqual(testCase.expectedProtocolError); }), ); } diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index af5257d122..27073e5605 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -366,12 +366,20 @@ const useConnection = ( }); } const status = httpStatusFromCause(cause); - const protocolFailure = asProtocolError(cause) !== undefined; + const protocolError = asProtocolError(cause); return new McpInvocationError({ toolName, message: `MCP tool call failed for ${toolName}`, ...(status === undefined ? {} : { status }), - ...(!protocolFailure ? { transportFailure: true } : {}), + ...(protocolError === undefined + ? { transportFailure: true } + : { + // A JSON-RPC error is the server's answer to this call, written + // for the caller (the same trust level as an `isError` result + // envelope), so its message may travel back to the sandbox. + // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: the narrowing above reaches the SDK's ProtocolError, whose message is the server's JSON-RPC error text + protocolError: { code: protocolError.code, message: protocolError.message }, + }), ...(isUnknownToolCause(cause, toolName) ? { unknownTool: true } : {}), ...(status === 403 && insufficientScopeFromCause(cause) ? { insufficientScope: true } diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 6043d81291..15058f2558 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1159,17 +1159,57 @@ describe("mcpPlugin", () => { callTool: jsonRpcErrorCallTool(401), }); - const failure = yield* executor - .execute(toolAddress, {}, { onElicitation: "accept-all" }) - .pipe(Effect.flip); - expect(Predicate.isTagged(failure, "ToolInvocationError")).toBe(true); + const result = yield* executor.execute(toolAddress, {}, { onElicitation: "accept-all" }); - const error = failure as { readonly message: string; readonly cause?: unknown }; - expect(error).toMatchObject({ message: "MCP tool call failed for explode" }); - expect(error).toMatchObject({ message: expect.not.stringContaining("do-not-leak") }); - expect(Predicate.isTagged(error.cause, "McpInvocationError")).toBe(true); - const cause = error.cause as McpInvocationError; - expect(cause.status).toBeUndefined(); + // A JSON-RPC error code is not an HTTP status: 401 here is the + // server's application-level answer, not an auth wall. + expect(result).toMatchObject({ + ok: false, + error: { code: "mcp_tool_error", details: { jsonrpc: { code: 401 } } }, + }); + expect(result).not.toMatchObject({ error: { status: 401 } }); + expect(result).not.toMatchObject({ error: { details: { category: "authentication" } } }); + }), + ), + ); + + // A server that validates arguments itself (Stripe's MCP, for one) refuses a + // bad call with `-32602 Invalid params` and a message naming the offending + // field. That answer is for the caller: without it the model cannot fix the + // arguments, and scrubbing it into "Internal tool error [id]" reads as an + // outage of the whole integration. + it.effect("surfaces a JSON-RPC invalid-params refusal as a typed tool failure", () => + Effect.scoped( + Effect.gen(function* () { + const { executor, toolAddress } = yield* seedCallToolExecutor({ + slug: "call_jsonrpc_invalid_params", + callTool: (rpc) => + HttpServerResponse.jsonUnsafe({ + jsonrpc: "2.0", + id: rpc.id ?? null, + error: { + code: -32602, + message: + "Invalid method parameters: The property '#/intent' value \"x\" did not match one of the following values: a, b", + }, + }), + }); + + const result = yield* executor.execute( + toolAddress, + { intent: "x" }, + { onElicitation: "accept-all" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "mcp_tool_error", + message: expect.stringContaining("'#/intent'"), + retryable: false, + details: { jsonrpc: { code: -32602 } }, + }, + }); }), ), ); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 4af0c4bf58..e4e9d582da 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1805,6 +1805,21 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }) .pipe(Effect.ignore, Effect.as(unknownToolFailure(String(toolRow.name), credential))); } + // The server refused the call itself (typically -32602 invalid + // params: an argument outside the schema's enum, a missing required + // field). That is an expected tool failure the caller can act on — + // it needs the server's message to fix the arguments — not a + // dispatch defect to scrub into an opaque correlation id. + if (error.protocolError !== undefined) { + return Effect.succeed( + ToolResult.fail({ + code: "mcp_tool_error", + message: error.protocolError.message, + retryable: false, + details: { jsonrpc: { code: error.protocolError.code } }, + }), + ); + } return Effect.fail(error); }), Effect.withSpan("mcp.plugin.invoke_tool", {