Skip to content
Draft
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
12 changes: 12 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/plugins/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@
"@effect/platform-node": "catalog:",
"@executor-js/config": "workspace:*",
"@executor-js/sdk": "workspace:*",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "4.3.6"
},
Expand Down
8 changes: 8 additions & 0 deletions packages/plugins/mcp/src/sdk/catalog-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(Js
const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>
HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result });

const jsonRpcMethodNotFound = (request: JsonRpcRequest) =>
HttpServerResponse.jsonUnsafe({
jsonrpc: "2.0",
id: request.id ?? null,
error: { code: -32601, message: "Method not found" },
});

const pageTool = (name: string) => ({
name,
description: `Tool ${name}`,
Expand All @@ -199,6 +206,7 @@ const servePaginatedListServer = () =>
return Option.match(decodeJsonRpcRequest(body), {
onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }),
onSome: (rpc) => {
if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc);
if (rpc.method === "initialize") {
return jsonRpcResult(rpc, {
protocolVersion: "2025-06-18",
Expand Down
16 changes: 10 additions & 6 deletions packages/plugins/mcp/src/sdk/connection-pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import { createMcpConnectionPool } from "./connection-pool";
import { invokeMcpTool } from "./invoke";
import { makeEchoMcpServer, serveMcpServer } from "../testing";

// The public v1 fixture creates one throwaway transport for v2's rejected
// server/discover probe, then one initialized legacy session per real dial.
const V1_FIXTURE_TRANSPORTS_PER_DIAL = 2;

const acceptAll: Elicit = () =>
Effect.succeed(ElicitationResponse.make({ action: "accept", content: { approved: true } }));

Expand Down Expand Up @@ -54,7 +58,7 @@ describe("MCP connection pool", () => {

expect(first).toMatchObject({ content: [{ type: "text", text: "first" }] });
expect(second).toMatchObject({ content: [{ type: "text", text: "second" }] });
expect(server.sessionCount()).toBe(1);
expect(server.sessionCount()).toBe(V1_FIXTURE_TRANSPORTS_PER_DIAL);
yield* pool.close();
}),
),
Expand Down Expand Up @@ -88,7 +92,7 @@ describe("MCP connection pool", () => {
expect.objectContaining({ content: [{ type: "text", text: "left" }] }),
expect.objectContaining({ content: [{ type: "text", text: "right" }] }),
]);
expect(server.sessionCount()).toBe(2);
expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
yield* pool.close();
}),
),
Expand All @@ -115,7 +119,7 @@ describe("MCP connection pool", () => {
});

expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
expect(server.sessionCount()).toBe(2);
expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
yield* pool.close();
}),
),
Expand Down Expand Up @@ -148,7 +152,7 @@ describe("MCP connection pool", () => {
});

expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
expect(server.sessionCount()).toBe(2);
expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
yield* pool.close();
}),
),
Expand Down Expand Up @@ -188,8 +192,8 @@ describe("MCP connection pool", () => {
});

expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
// A second session was dialled rather than the dead one being reused.
expect(server.sessionCount()).toBe(2);
// A second connection was dialled rather than the dead one being reused.
expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
yield* pool.close();
}),
),
Expand Down
4 changes: 4 additions & 0 deletions packages/plugins/mcp/src/sdk/connection-pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect";
import type { McpConnection, McpConnector } from "./connection";
import type { McpInvocationError } from "./errors";

// The pool preserves sessions for sessionful legacy servers. Stateless
// 2026-07-28 servers do not need it, but retaining a cheap idle client is
// harmless and keeps one lifecycle for both protocol eras.

const IDLE_TTL_MS = 5 * 60 * 1_000;

type IdleConnection = {
Expand Down
30 changes: 19 additions & 11 deletions packages/plugins/mcp/src/sdk/connection.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import {
Client,
SSEClientTransport,
StreamableHTTPClientTransport,
type FetchLike,
type OAuthClientProvider,
} from "@modelcontextprotocol/client";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
import { Effect, Layer, Predicate, Stream } from "effect";
import { HttpClient, HttpClientRequest } from "effect/unstable/http";

// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process`
// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at
// SIGSEGV on module instantiation. Cloud callers set
// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and
// `cross-spawn` eagerly at evaluation time, which crashes workerd (including
// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set
// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
// prod bundles that DO use stdio load it via a dynamic import inside the
// stdio branch of `createMcpConnector`.
Expand Down Expand Up @@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = (
// MCP plugin runs inside a Cloudflare Worker (executor.sh). The
// cfworker validator does not use code generation and works in every
// runtime we ship to.
const createClient = (): Client =>
const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>
new Client(
{ name: "executor-mcp", version: "0.1.0" },
{
capabilities: { elicitation: { form: {}, url: {} } },
jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
...(versionNegotiation === undefined ? {} : { versionNegotiation }),
},
);

Expand Down Expand Up @@ -247,9 +250,10 @@ const connectionFailure = (
const connectClient = (input: {
transport: string;
createTransport: () => Parameters<Client["connect"]>[0];
versionNegotiation?: { readonly mode: "auto" };
}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
Effect.gen(function* () {
const client = createClient();
const client = createClient(input.versionNegotiation);
const transportInstance = input.createTransport();

yield* Effect.tryPromise({
Expand Down Expand Up @@ -314,8 +318,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => {

const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});

// Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a
// legacy-only transport, and stdio servers are spawned per call where the
// SDK recommends retaining its legacy-default handshake.
const connectStreamableHttp = connectClient({
transport: "streamable-http",
versionNegotiation: { mode: "auto" },
createTransport: () =>
new StreamableHTTPClientTransport(endpoint, {
requestInit,
Expand Down
6 changes: 3 additions & 3 deletions packages/plugins/mcp/src/sdk/elicitation.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { describe, expect, it } from "@effect/vitest";
import { Effect, Predicate, Schema, Semaphore } from "effect";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
import type { JsonSchemaType } from "@modelcontextprotocol/client";
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";

import {
AuthTemplateSlug,
Expand Down Expand Up @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => {
]),
);
expect(schema?.outputTypeScript).toContain('type: "text"');
expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");
expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");

const result = yield* executor.execute(
simpleEcho.address,
Expand Down
20 changes: 17 additions & 3 deletions packages/plugins/mcp/src/sdk/http-status.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, it } from "@effect/vitest";
import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";

// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
import { insufficientScopeFromCause } from "./http-status";
Expand All @@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status";
// - with an authProvider (the production OAuth path): the StreamableHTTP
// transport consumes the insufficient_scope challenge itself, retries
// with the broader scope, and only when THAT fails throws the fixed
// "Server returned 403 after trying upscoping" message.
// typed `InsufficientScopeError`, or after retry exhaustion the fixed
// `SdkHttpError` step-up message.
describe("insufficientScopeFromCause", () => {
it("detects the OAuth error body embedded in a transport message", () => {
expect(
Expand All @@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => {
).toBe(true);
});

it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
it("detects the SDK's typed insufficient-scope failure", () => {
expect(
insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),
insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),
).toBe(true);
});

it("detects the SDK's exhausted step-up failure (the authProvider path)", () => {
expect(
insufficientScopeFromCause(
new SdkHttpError(
SdkErrorCode.ClientHttpForbidden,
"Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)",
{ status: 403 },
),
),
).toBe(true);
});

Expand Down
45 changes: 25 additions & 20 deletions packages/plugins/mcp/src/sdk/http-status.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
// ---------------------------------------------------------------------------
// Extract the HTTP status from an MCP SDK transport error. The SDK surfaces
// transport failures two ways: a `StreamableHTTPError` subclass carrying a
// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`.
// transport failures two ways: an `SdkHttpError` carrying a numeric `status`,
// and an `SseError` carrying a numeric `code`. The SSE transport also retains
// its historic POST-failure message for errors created below EventSource.
// Shared by the invoke path (classifies tool-call failures) and the connect
// path (so a 401/403 during the handshake reaches the liveness health check).
// ---------------------------------------------------------------------------

import { Option, Schema } from "effect";

import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client";

const SsePostErrorCause = Schema.Struct({ message: Schema.String });
const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);

// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify
// on SDK bumps. A format drift just yields undefined (generic error, no crash).
// V2 still constructs this exact message in SSEClientTransport._send. A format
// drift just yields undefined (generic error, no crash).
const statusFromSsePostError = (cause: unknown): number | undefined =>
Option.match(decodeSsePostErrorCause(cause), {
onNone: () => undefined,
Expand All @@ -26,32 +27,36 @@ const statusFromSsePostError = (cause: unknown): number | undefined =>
},
});

const statusFromStreamableHttpError = (cause: unknown): number | undefined => {
// oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK exposes transport HTTP failures as this Error subclass; protocol errors can carry the same numeric code
if (!(cause instanceof StreamableHTTPError)) return undefined;
const code = cause.code;
return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
const statusFromTypedTransportError = (cause: unknown): number | undefined => {
if (SdkHttpError.isInstance(cause)) return cause.status;
if (SseError.isInstance(cause)) {
const code = cause.code;
return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
}
return undefined;
};

export const httpStatusFromCause = (cause: unknown): number | undefined =>
statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause);

// The SDK embeds the upstream response text in the transport error message
// ("Error POSTing to endpoint: <body>"), which is the only place a 403's body
// survives for connections without an authProvider. For OAuth connections the
// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF:
// it re-runs auth requesting the broader scope, and only when that upscoped
// retry still 403s does it throw — with the fixed message matched below
// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on
// SDK bumps). Both paths mean the same thing: the grant does not cover the
// operation, and re-running the identical flow cannot help. Strict matching
// StreamableHTTP transport consumes the insufficient_scope challenge itself.
// V2 throws `InsufficientScopeError` when configured not to reauthorize; after
// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed
// message matched below (verified against the installed v2 transport source).
// Both paths mean the same thing: the grant does not cover the operation, and
// re-running the identical flow cannot help. Strict matching
// (exact serialized field forms via the shared core detector, or the SDK's
// exact upscoping message) — a miss stays on the generic auth path.
const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/;
// exact step-up message) — a miss stays on the generic auth path.
const SDK_STEP_UP_EXHAUSTED_RE =
/^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;

export const insufficientScopeFromCause = (cause: unknown): boolean =>
InsufficientScopeError.isInstance(cause) ||
Option.match(decodeSsePostErrorCause(cause), {
onNone: () => false,
onSome: ({ message }) =>
insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message),
insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message),
});
15 changes: 10 additions & 5 deletions packages/plugins/mcp/src/sdk/invoke.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest";
import { Effect, Predicate } from "effect";
import { HttpServerResponse } from "effect/unstable/http";

import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { McpError } from "@modelcontextprotocol/sdk/types.js";
import {
ProtocolError,
SdkErrorCode,
SdkHttpError,
type OAuthClientProvider,
} from "@modelcontextprotocol/client";
import { ElicitationResponse } from "@executor-js/sdk";
import { serveTestHttpApp } from "@executor-js/sdk/testing";

Expand Down Expand Up @@ -108,14 +111,16 @@ const invocationRejectionCases = [
name: "wraps callTool rejection with a stable message and status",
toolId: "blocked",
transport: "streamable-http",
cause: new StreamableHTTPError(401, "token=do-not-leak"),
cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", {
status: 401,
}),
expectedStatus: 401 as number | undefined,
},
{
name: "does not treat MCP protocol error codes as HTTP statuses",
toolId: "protocol_error",
transport: "streamable-http",
cause: new McpError(401, "application-level do-not-leak"),
cause: new ProtocolError(401, "application-level do-not-leak"),
expectedStatus: undefined,
},
{
Expand Down
Loading
Loading