Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/mcp-jsonrpc-refusal.md
Original file line number Diff line number Diff line change
@@ -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.
8 changes: 8 additions & 0 deletions packages/plugins/mcp/src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/mcp/src/sdk/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,26 @@ 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",
toolId: "network",
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",
Expand All @@ -147,6 +153,7 @@ const invocationRejectionCases = [
message: "Error POSTing to endpoint (HTTP 403): do-not-leak: upstream auth challenge",
},
expectedStatus: 403,
expectedProtocolError: undefined,
},
];

Expand Down Expand Up @@ -313,6 +320,7 @@ describe("invokeMcpTool", () => {
});
expect(invocation.status).toBe(testCase.expectedStatus);
expect("cause" in invocation).toBe(false);
expect(invocation.protocolError).toEqual(testCase.expectedProtocolError);
}),
);
}
Expand Down
12 changes: 10 additions & 2 deletions packages/plugins/mcp/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
60 changes: 50 additions & 10 deletions packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
},
});
}),
),
);
Expand Down
15 changes: 15 additions & 0 deletions packages/plugins/mcp/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Loading