From 35158ef94b8b0e4f6b8d9cb4ca67cb9cec853710 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 15 Sep 2026 16:40:42 -0700 Subject: [PATCH 1/2] Name the SDK rejection in MCP invocation defects An MCP tools/call rejection that is neither HTTP auth nor a JSON-RPC error reaches the dispatch defect log as "MCP tool call failed for " and nothing else, so an opaque correlation id cannot be traced to a cause. Carry the SDK error class and stable code on McpInvocationError and in its message. Structural only; the SDK message is never copied, since a transport message can embed an upstream body. Co-Authored-By: Claude Fable 5.1 --- .changeset/mcp-invocation-sdk-failure.md | 5 +++++ packages/plugins/mcp/src/sdk/errors.ts | 10 ++++++++++ packages/plugins/mcp/src/sdk/invoke.test.ts | 14 +++++++++++--- packages/plugins/mcp/src/sdk/invoke.ts | 16 +++++++++++++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 6 +++++- 5 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 .changeset/mcp-invocation-sdk-failure.md diff --git a/.changeset/mcp-invocation-sdk-failure.md b/.changeset/mcp-invocation-sdk-failure.md new file mode 100644 index 000000000..f5c6fc726 --- /dev/null +++ b/.changeset/mcp-invocation-sdk-failure.md @@ -0,0 +1,5 @@ +--- +"executor": patch +--- + +Name the MCP SDK rejection (error class and code) in the `Internal tool error` defect log, so an opaque MCP failure can be diagnosed from the trace instead of only naming the tool that failed. diff --git a/packages/plugins/mcp/src/sdk/errors.ts b/packages/plugins/mcp/src/sdk/errors.ts index 31c53d714..3f4e8af3a 100644 --- a/packages/plugins/mcp/src/sdk/errors.ts +++ b/packages/plugins/mcp/src/sdk/errors.ts @@ -87,6 +87,16 @@ export class McpInvocationError extends Data.TaggedError("McpInvocationError")<{ readonly code: number; readonly message: string; }; + /** Operator-facing summary of the SDK rejection this error sanitized: + * the error's class name and its stable `code` (an `SdkErrorCode`, + * `ProtocolErrorCode`, or HTTP status). Never the message — a transport + * message can embed an upstream body. Carried so the dispatch defect log + * (`tool dispatch failed`, keyed by correlation id) names WHAT the SDK + * refused instead of only the tool it refused. */ + readonly sdkFailure?: { + readonly name: string; + readonly code?: string | number; + }; }> {} 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 8412d749f..bb48e9908 100644 --- a/packages/plugins/mcp/src/sdk/invoke.test.ts +++ b/packages/plugins/mcp/src/sdk/invoke.test.ts @@ -125,6 +125,10 @@ const invocationRejectionCases = [ }), expectedStatus: 401 as number | undefined, expectedProtocolError: undefined as { code: number; message: string } | undefined, + expectedSdkFailure: { name: "SdkHttpError", code: SdkErrorCode.ClientHttpAuthentication } as { + name: string; + code?: string | number; + }, }, { // The JSON-RPC error is the server's own answer to the call: its code is @@ -136,6 +140,7 @@ const invocationRejectionCases = [ cause: new ProtocolError(401, "application-level do-not-leak"), expectedStatus: undefined, expectedProtocolError: { code: 401, message: "application-level do-not-leak" }, + expectedSdkFailure: { name: "ProtocolError", code: 401 }, }, { name: "does not invent a status from non-HTTP rejection shapes", @@ -144,6 +149,7 @@ const invocationRejectionCases = [ cause: { code: -1, message: "socket said do-not-leak" }, expectedStatus: undefined, expectedProtocolError: undefined, + expectedSdkFailure: { name: "object", code: -1 }, }, { name: "extracts the status from the SDK SSE POST error prefix without leaking the body", @@ -154,6 +160,7 @@ const invocationRejectionCases = [ }, expectedStatus: 403, expectedProtocolError: undefined, + expectedSdkFailure: { name: "object" }, }, ]; @@ -312,9 +319,10 @@ describe("invokeMcpTool", () => { expect(Predicate.isTagged(error, "McpInvocationError")).toBe(true); const invocation = error as McpInvocationError; expect(invocation.toolName).toBe(testCase.toolId); - expect(invocation).toMatchObject({ - message: `MCP tool call failed for ${testCase.toolId}`, - }); + expect(invocation.message.startsWith(`MCP tool call failed for ${testCase.toolId} (`)).toBe( + true, + ); + expect(invocation.sdkFailure).toEqual(testCase.expectedSdkFailure); expect(invocation).toMatchObject({ message: expect.not.stringContaining("do-not-leak"), }); diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 27073e560..53e0b033a 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -152,6 +152,15 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean ).test(message); }; +/** The class name and stable code of an SDK rejection, for the defect log. + * Structural only: the message is deliberately not read here. */ +const summarizeSdkFailure = (cause: unknown): { name: string; code?: string | number } => { + // oxlint-disable-next-line executor/no-instanceof-error -- boundary: the MCP SDK rejects with Error subclasses whose constructor name is the only class discriminator for non-branded errors + const name = cause instanceof Error ? cause.constructor.name : typeof cause; + const code = Predicate.hasProperty(cause, "code") ? cause.code : undefined; + return typeof code === "string" || typeof code === "number" ? { name, code } : { name }; +}; + const asProtocolError = (cause: unknown): ProtocolError | undefined => { const sdk = mcpClientSdkIfLoaded(); if (sdk === undefined) return undefined; @@ -367,9 +376,14 @@ const useConnection = ( } const status = httpStatusFromCause(cause); const protocolError = asProtocolError(cause); + const sdkFailure = summarizeSdkFailure(cause); return new McpInvocationError({ toolName, - message: `MCP tool call failed for ${toolName}`, + // The class and code ride in the message because the dispatch + // defect log renders only `Error#toString()`: without them the + // trace says which tool failed and nothing about how. + message: `MCP tool call failed for ${toolName} (${sdkFailure.name}${sdkFailure.code === undefined ? "" : ` ${sdkFailure.code}`})`, + sdkFailure, ...(status === undefined ? {} : { status }), ...(protocolError === undefined ? { transportFailure: true } diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 15058f255..2e19db4fc 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1140,7 +1140,11 @@ describe("mcpPlugin", () => { expect(Predicate.isTagged(failure, "ToolInvocationError")).toBe(true); const error = failure as { readonly message: string; readonly cause?: unknown }; - expect(error).toMatchObject({ message: "MCP tool call failed for explode" }); + // The defect log renders only the message, so it names the SDK + // rejection (class + code) without carrying the upstream body. + expect(error).toMatchObject({ + message: "MCP tool call failed for explode (SdkHttpError CLIENT_HTTP_NOT_IMPLEMENTED)", + }); expect(error).toMatchObject({ message: expect.not.stringContaining("do-not-leak") }); expect(Predicate.isTagged(error.cause, "McpInvocationError")).toBe(true); const cause = error.cause as McpInvocationError; From a6b38dfdd7ed26b62f4351510803e9d6532263d7 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 15 Sep 2026 17:00:07 -0700 Subject: [PATCH 2/2] Include the HTTP status in the MCP invocation defect message Co-Authored-By: Claude Fable 5.1 --- packages/plugins/mcp/src/sdk/invoke.ts | 6 +++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/plugins/mcp/src/sdk/invoke.ts b/packages/plugins/mcp/src/sdk/invoke.ts index 53e0b033a..a74e7a2eb 100644 --- a/packages/plugins/mcp/src/sdk/invoke.ts +++ b/packages/plugins/mcp/src/sdk/invoke.ts @@ -382,7 +382,11 @@ const useConnection = ( // The class and code ride in the message because the dispatch // defect log renders only `Error#toString()`: without them the // trace says which tool failed and nothing about how. - message: `MCP tool call failed for ${toolName} (${sdkFailure.name}${sdkFailure.code === undefined ? "" : ` ${sdkFailure.code}`})`, + message: `MCP tool call failed for ${toolName} (${[ + sdkFailure.name, + ...(sdkFailure.code === undefined ? [] : [String(sdkFailure.code)]), + ...(status === undefined ? [] : [`HTTP ${status}`]), + ].join(" ")})`, sdkFailure, ...(status === undefined ? {} : { status }), ...(protocolError === undefined diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 2e19db4fc..22f249aae 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -1143,7 +1143,8 @@ describe("mcpPlugin", () => { // The defect log renders only the message, so it names the SDK // rejection (class + code) without carrying the upstream body. expect(error).toMatchObject({ - message: "MCP tool call failed for explode (SdkHttpError CLIENT_HTTP_NOT_IMPLEMENTED)", + message: + "MCP tool call failed for explode (SdkHttpError CLIENT_HTTP_NOT_IMPLEMENTED HTTP 500)", }); expect(error).toMatchObject({ message: expect.not.stringContaining("do-not-leak") }); expect(Predicate.isTagged(error.cause, "McpInvocationError")).toBe(true);