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-invocation-sdk-failure.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions packages/plugins/mcp/src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
14 changes: 11 additions & 3 deletions packages/plugins/mcp/src/sdk/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -154,6 +160,7 @@ const invocationRejectionCases = [
},
expectedStatus: 403,
expectedProtocolError: undefined,
expectedSdkFailure: { name: "object" },
},
];

Expand Down Expand Up @@ -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"),
});
Expand Down
20 changes: 19 additions & 1 deletion packages/plugins/mcp/src/sdk/invoke.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -367,9 +376,18 @@ 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 ? [] : [String(sdkFailure.code)]),
...(status === undefined ? [] : [`HTTP ${status}`]),
].join(" ")})`,
sdkFailure,
...(status === undefined ? {} : { status }),
...(protocolError === undefined
? { transportFailure: true }
Expand Down
7 changes: 6 additions & 1 deletion packages/plugins/mcp/src/sdk/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1140,7 +1140,12 @@ 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 HTTP 500)",
});
expect(error).toMatchObject({ message: expect.not.stringContaining("do-not-leak") });
expect(Predicate.isTagged(error.cause, "McpInvocationError")).toBe(true);
const cause = error.cause as McpInvocationError;
Expand Down
Loading