History

Migrate outbound MCP client to SDK v2 (spec 2026-07-28)

Rhys Sullivan committed 3 months ago

Commit 11295eb1 parent 37abb0717 files · +304 −87Browse files

Unified · Split

17 files
  1. bun.lock +12 −0
  2. packages/plugins/mcp/package.json +2 −0
  3. packages/plugins/mcp/src/sdk/catalog-sync.test.ts +8 −0
  4. packages/plugins/mcp/src/sdk/connection-pool.test.ts +10 −6
  5. packages/plugins/mcp/src/sdk/connection-pool.ts +4 −0
  6. packages/plugins/mcp/src/sdk/connection.ts +19 −11
  7. packages/plugins/mcp/src/sdk/elicitation.test.ts +3 −3
  8. packages/plugins/mcp/src/sdk/http-status.test.ts +17 −3
  9. packages/plugins/mcp/src/sdk/http-status.ts +25 −20
  10. packages/plugins/mcp/src/sdk/invoke.test.ts +10 −5
  11. packages/plugins/mcp/src/sdk/invoke.ts +24 −15
  12. packages/plugins/mcp/src/sdk/plugin.test.ts +8 −0
  13. packages/plugins/mcp/src/sdk/plugin.ts +6 −5
  14. packages/plugins/mcp/src/sdk/probe-shape.test.ts +93 −2
  15. packages/plugins/mcp/src/sdk/probe-shape.ts +51 −10
  16. packages/plugins/mcp/src/sdk/stdio-connector.ts +4 −3
  17. packages/plugins/mcp/src/sdk/testing-fixtures.test.ts +8 −4
bun.lock+12−0
@@ -994,6 +994,8 @@ · 993 unmodified lines
994 "@effect/platform-node": "catalog:",994 "@effect/platform-node": "catalog:",
995 "@executor-js/config": "workspace:*",995 "@executor-js/config": "workspace:*",
996 "@executor-js/sdk": "workspace:*",996 "@executor-js/sdk": "workspace:*",
997 "@modelcontextprotocol/client": "2.0.0",
998 "@modelcontextprotocol/core": "2.0.0",
997 "@modelcontextprotocol/sdk": "^1.29.0",999 "@modelcontextprotocol/sdk": "^1.29.0",
998 "zod": "4.3.6",1000 "zod": "4.3.6",
999 },1001 },
@@ -2126,6 +2128,10 @@ · 1126 unmodified lines
2126 2128
2127 "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="],2129 "@mishieck/ink-titled-box": ["@mishieck/ink-titled-box@0.3.0", "", { "peerDependencies": { "ink": "^6.0.0", "react": "^19.1.0", "typescript": "^5" } }, "sha512-ugzVH9hixp3hwKfQ8On/qnsrdAxS3y9rTu/aGOFed4zVUvtZyGZNIR4rxAwXult8HKI4vJEh0OM8wib9NPrwUg=="],
2128 2130
2131 "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="],
2132
2133 "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="],
2134
2129 "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="],2135 "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="],
2130 2136
2131 "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],2137 "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
@@ -6118,6 +6124,12 @@ · 3986 unmodified lines
6118 6124
6119 "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],6125 "@manypkg/get-packages/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="],
6120 6126
6127 "@modelcontextprotocol/client/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
6128
6129 "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6130
6131 "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
6132
6121 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],6133 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
6122 6134
6123 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],6135 "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="],
packages/plugins/mcp/package.json+2−0
@@ -65,6 +65,8 @@ · 64 unmodified lines
65 "@effect/platform-node": "catalog:",65 "@effect/platform-node": "catalog:",
66 "@executor-js/config": "workspace:*",66 "@executor-js/config": "workspace:*",
67 "@executor-js/sdk": "workspace:*",67 "@executor-js/sdk": "workspace:*",
68 "@modelcontextprotocol/client": "2.0.0",
69 "@modelcontextprotocol/core": "2.0.0",
68 "@modelcontextprotocol/sdk": "^1.29.0",70 "@modelcontextprotocol/sdk": "^1.29.0",
69 "zod": "4.3.6"71 "zod": "4.3.6"
70 },72 },
packages/plugins/mcp/src/sdk/catalog-sync.test.ts+8−0
@@ -182,6 +182,13 @@ const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(Js · 181 unmodified lines
182const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>182const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>
183 HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result });183 HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result });
184 184
185const jsonRpcMethodNotFound = (request: JsonRpcRequest) =>
186 HttpServerResponse.jsonUnsafe({
187 jsonrpc: "2.0",
188 id: request.id ?? null,
189 error: { code: -32601, message: "Method not found" },
190 });
191
185const pageTool = (name: string) => ({192const pageTool = (name: string) => ({
186 name,193 name,
187 description: `Tool ${name}`,194 description: `Tool ${name}`,
@@ -199,6 +206,7 @@ const servePaginatedListServer = () => · 11 unmodified lines
199 return Option.match(decodeJsonRpcRequest(body), {206 return Option.match(decodeJsonRpcRequest(body), {
200 onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }),207 onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }),
201 onSome: (rpc) => {208 onSome: (rpc) => {
209 if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc);
202 if (rpc.method === "initialize") {210 if (rpc.method === "initialize") {
203 return jsonRpcResult(rpc, {211 return jsonRpcResult(rpc, {
204 protocolVersion: "2025-06-18",212 protocolVersion: "2025-06-18",
packages/plugins/mcp/src/sdk/connection-pool.test.ts+10−6
@@ -7,6 +7,10 @@ import { createMcpConnectionPool } from "./connection-pool"; · 6 unmodified lines
7import { invokeMcpTool } from "./invoke";7import { invokeMcpTool } from "./invoke";
8import { makeEchoMcpServer, serveMcpServer } from "../testing";8import { makeEchoMcpServer, serveMcpServer } from "../testing";
9 9
10// The public v1 fixture creates one throwaway transport for v2's rejected
11// server/discover probe, then one initialized legacy session per real dial.
12const V1_FIXTURE_TRANSPORTS_PER_DIAL = 2;
13
10const acceptAll: Elicit = () =>14const acceptAll: Elicit = () =>
11 Effect.succeed(ElicitationResponse.make({ action: "accept", content: { approved: true } }));15 Effect.succeed(ElicitationResponse.make({ action: "accept", content: { approved: true } }));
12 16
@@ -54,7 +58,7 @@ describe("MCP connection pool", () => { · 41 unmodified lines
54 58
55 expect(first).toMatchObject({ content: [{ type: "text", text: "first" }] });59 expect(first).toMatchObject({ content: [{ type: "text", text: "first" }] });
56 expect(second).toMatchObject({ content: [{ type: "text", text: "second" }] });60 expect(second).toMatchObject({ content: [{ type: "text", text: "second" }] });
57 expect(server.sessionCount()).toBe(1);61 expect(server.sessionCount()).toBe(V1_FIXTURE_TRANSPORTS_PER_DIAL);
58 yield* pool.close();62 yield* pool.close();
59 }),63 }),
60 ),64 ),
@@ -88,7 +92,7 @@ describe("MCP connection pool", () => { · 27 unmodified lines
88 expect.objectContaining({ content: [{ type: "text", text: "left" }] }),92 expect.objectContaining({ content: [{ type: "text", text: "left" }] }),
89 expect.objectContaining({ content: [{ type: "text", text: "right" }] }),93 expect.objectContaining({ content: [{ type: "text", text: "right" }] }),
90 ]);94 ]);
91 expect(server.sessionCount()).toBe(2);95 expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
92 yield* pool.close();96 yield* pool.close();
93 }),97 }),
94 ),98 ),
@@ -115,7 +119,7 @@ describe("MCP connection pool", () => { · 20 unmodified lines
115 });119 });
116 120
117 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });121 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
118 expect(server.sessionCount()).toBe(2);122 expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
119 yield* pool.close();123 yield* pool.close();
120 }),124 }),
121 ),125 ),
@@ -148,7 +152,7 @@ describe("MCP connection pool", () => { · 26 unmodified lines
148 });152 });
149 153
150 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });154 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
151 expect(server.sessionCount()).toBe(2);155 expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
152 yield* pool.close();156 yield* pool.close();
153 }),157 }),
154 ),158 ),
@@ -188,8 +192,8 @@ describe("MCP connection pool", () => { · 33 unmodified lines
188 });192 });
189 193
190 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });194 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
191 // A second session was dialled rather than the dead one being reused.195 // A second connection was dialled rather than the dead one being reused.
192 expect(server.sessionCount()).toBe(2);196 expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
193 yield* pool.close();197 yield* pool.close();
194 }),198 }),
195 ),199 ),
packages/plugins/mcp/src/sdk/connection-pool.ts+4−0
@@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines
3import type { McpConnection, McpConnector } from "./connection";3import type { McpConnection, McpConnector } from "./connection";
4import type { McpInvocationError } from "./errors";4import type { McpInvocationError } from "./errors";
5 5
6// The pool preserves sessions for sessionful legacy servers. Stateless
7// 2026-07-28 servers do not need it, but retaining a cheap idle client is
8// harmless and keeps one lifecycle for both protocol eras.
9
6const IDLE_TTL_MS = 5 * 60 * 1_000;10const IDLE_TTL_MS = 5 * 60 * 1_000;
7 11
8type IdleConnection = {12type IdleConnection = {
packages/plugins/mcp/src/sdk/connection.ts+19−11
@@ -1,16 +1,18 @@
1import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";1import {
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";2 Client,
3import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";3 SSEClientTransport,
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4 StreamableHTTPClientTransport,
5import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";5 type FetchLike,
6import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";6 type OAuthClientProvider,
7} from "@modelcontextprotocol/client";
8import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
7import { Effect, Layer, Predicate, Stream } from "effect";9import { Effect, Layer, Predicate, Stream } from "effect";
8import { HttpClient, HttpClientRequest } from "effect/unstable/http";10import { HttpClient, HttpClientRequest } from "effect/unstable/http";
9 11
10// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module12// NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
11// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process`13// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and
12// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at14// `cross-spawn` eagerly at evaluation time, which crashes workerd (including
13// SIGSEGV on module instantiation. Cloud callers set15// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set
14// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;16// `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
15// prod bundles that DO use stdio load it via a dynamic import inside the17// prod bundles that DO use stdio load it via a dynamic import inside the
16// stdio branch of `createMcpConnector`.18// stdio branch of `createMcpConnector`.
@@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines
201// MCP plugin runs inside a Cloudflare Worker (executor.sh). The203// MCP plugin runs inside a Cloudflare Worker (executor.sh). The
202// cfworker validator does not use code generation and works in every204// cfworker validator does not use code generation and works in every
203// runtime we ship to.205// runtime we ship to.
204const createClient = (): Client =>206const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>
205 new Client(207 new Client(
206 { name: "executor-mcp", version: "0.1.0" },208 { name: "executor-mcp", version: "0.1.0" },
207 {209 {
208 capabilities: { elicitation: { form: {}, url: {} } },210 capabilities: { elicitation: { form: {}, url: {} } },
209 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),211 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
212 ...(versionNegotiation === undefined ? {} : { versionNegotiation }),
210 },213 },
211 );214 );
212 215
@@ -247,9 +250,10 @@ const connectionFailure = ( · 34 unmodified lines
247const connectClient = (input: {250const connectClient = (input: {
248 transport: string;251 transport: string;
249 createTransport: () => Parameters<Client["connect"]>[0];252 createTransport: () => Parameters<Client["connect"]>[0];
253 versionNegotiation?: { readonly mode: "auto" };
250}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>254}): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
251 Effect.gen(function* () {255 Effect.gen(function* () {
252 const client = createClient();256 const client = createClient(input.versionNegotiation);
253 const transportInstance = input.createTransport();257 const transportInstance = input.createTransport();
254 258
255 yield* Effect.tryPromise({259 yield* Effect.tryPromise({
@@ -314,8 +318,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 58 unmodified lines
314 318
315 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});319 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
316 320
321 // Auto-negotiate the 2026-07-28 era only on Streamable HTTP. SSE is a
322 // legacy-only transport, and stdio servers are spawned per call where the
323 // SDK recommends retaining its legacy-default handshake.
317 const connectStreamableHttp = connectClient({324 const connectStreamableHttp = connectClient({
318 transport: "streamable-http",325 transport: "streamable-http",
326 versionNegotiation: { mode: "auto" },
319 createTransport: () =>327 createTransport: () =>
320 new StreamableHTTPClientTransport(endpoint, {328 new StreamableHTTPClientTransport(endpoint, {
321 requestInit,329 requestInit,
packages/plugins/mcp/src/sdk/elicitation.test.ts+3−3
@@ -1,7 +1,7 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Predicate, Schema, Semaphore } from "effect";2import { Effect, Predicate, Schema, Semaphore } from "effect";
3import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";3import type { JsonSchemaType } from "@modelcontextprotocol/client";
4import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";4import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
5 5
6import {6import {
7 AuthTemplateSlug,7 AuthTemplateSlug,
@@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines
226 ]),226 ]),
227 );227 );
228 expect(schema?.outputTypeScript).toContain('type: "text"');228 expect(schema?.outputTypeScript).toContain('type: "text"');
229 expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");229 expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");
230 230
231 const result = yield* executor.execute(231 const result = yield* executor.execute(
232 simpleEcho.address,232 simpleEcho.address,
packages/plugins/mcp/src/sdk/http-status.test.ts+17−3
@@ -1,4 +1,5 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
2 3
3// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors4// oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
4import { insufficientScopeFromCause } from "./http-status";5import { insufficientScopeFromCause } from "./http-status";
@@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines
9// - with an authProvider (the production OAuth path): the StreamableHTTP10// - with an authProvider (the production OAuth path): the StreamableHTTP
10// transport consumes the insufficient_scope challenge itself, retries11// transport consumes the insufficient_scope challenge itself, retries
11// with the broader scope, and only when THAT fails throws the fixed12// with the broader scope, and only when THAT fails throws the fixed
12// "Server returned 403 after trying upscoping" message.13// typed `InsufficientScopeError`, or after retry exhaustion the fixed
14// `SdkHttpError` step-up message.
13describe("insufficientScopeFromCause", () => {15describe("insufficientScopeFromCause", () => {
14 it("detects the OAuth error body embedded in a transport message", () => {16 it("detects the OAuth error body embedded in a transport message", () => {
15 expect(17 expect(
@@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines
31 ).toBe(true);33 ).toBe(true);
32 });34 });
33 35
34 it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {36 it("detects the SDK's typed insufficient-scope failure", () => {
35 expect(37 expect(
36 insufficientScopeFromCause(new Error("Server returned 403 after trying upscoping")),38 insufficientScopeFromCause(new InsufficientScopeError({ requiredScope: "files.read" })),
39 ).toBe(true);
40 });
41
42 it("detects the SDK's exhausted step-up failure (the authProvider path)", () => {
43 expect(
44 insufficientScopeFromCause(
45 new SdkHttpError(
46 SdkErrorCode.ClientHttpForbidden,
47 "Server returned 403 insufficient_scope after step-up re-authorization (retry limit 2 reached)",
48 { status: 403 },
49 ),
50 ),
37 ).toBe(true);51 ).toBe(true);
38 });52 });
39 53
packages/plugins/mcp/src/sdk/http-status.ts+25−20
@@ -1,7 +1,8 @@
1// ---------------------------------------------------------------------------1// ---------------------------------------------------------------------------
2// Extract the HTTP status from an MCP SDK transport error. The SDK surfaces2// Extract the HTTP status from an MCP SDK transport error. The SDK surfaces
3// transport failures two ways: a `StreamableHTTPError` subclass carrying a3// transport failures two ways: an `SdkHttpError` carrying a numeric `status`,
4// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`.4// and an `SseError` carrying a numeric `code`. The SSE transport also retains
5// its historic POST-failure message for errors created below EventSource.
5// Shared by the invoke path (classifies tool-call failures) and the connect6// Shared by the invoke path (classifies tool-call failures) and the connect
6// path (so a 401/403 during the handshake reaches the liveness health check).7// path (so a 401/403 during the handshake reaches the liveness health check).
7// ---------------------------------------------------------------------------8// ---------------------------------------------------------------------------
@@ -9,13 +10,13 @@ · 1 unmodified line
9import { Option, Schema } from "effect";10import { Option, Schema } from "effect";
10 11
11import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";12import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
12import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";13import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client";
13 14
14const SsePostErrorCause = Schema.Struct({ message: Schema.String });15const SsePostErrorCause = Schema.Struct({ message: Schema.String });
15const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);16const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
16 17
17// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify18// V2 still constructs this exact message in SSEClientTransport._send. A format
18// on SDK bumps. A format drift just yields undefined (generic error, no crash).19// drift just yields undefined (generic error, no crash).
19const statusFromSsePostError = (cause: unknown): number | undefined =>20const statusFromSsePostError = (cause: unknown): number | undefined =>
20 Option.match(decodeSsePostErrorCause(cause), {21 Option.match(decodeSsePostErrorCause(cause), {
21 onNone: () => undefined,22 onNone: () => undefined,
@@ -26,32 +27,36 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines
26 },27 },
27 });28 });
28 29
29const statusFromStreamableHttpError = (cause: unknown): number | undefined => {30const statusFromTypedTransportError = (cause: unknown): number | undefined => {
30 // 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 code31 if (SdkHttpError.isInstance(cause)) return cause.status;
31 if (!(cause instanceof StreamableHTTPError)) return undefined;32 if (SseError.isInstance(cause)) {
32 const code = cause.code;33 const code = cause.code;
33 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;34 return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
35 }
36 return undefined;
34};37};
35 38
36export const httpStatusFromCause = (cause: unknown): number | undefined =>39export const httpStatusFromCause = (cause: unknown): number | undefined =>
37 statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);40 statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause);
38 41
39// The SDK embeds the upstream response text in the transport error message42// The SDK embeds the upstream response text in the transport error message
40// ("Error POSTing to endpoint: <body>"), which is the only place a 403's body43// ("Error POSTing to endpoint: <body>"), which is the only place a 403's body
41// survives for connections without an authProvider. For OAuth connections the44// survives for connections without an authProvider. For OAuth connections the
42// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF:45// StreamableHTTP transport consumes the insufficient_scope challenge itself.
43// it re-runs auth requesting the broader scope, and only when that upscoped46// V2 throws `InsufficientScopeError` when configured not to reauthorize; after
44// retry still 403s does it throw — with the fixed message matched below47// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed
45// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on48// message matched below (verified against the installed v2 transport source).
46// SDK bumps). Both paths mean the same thing: the grant does not cover the49// Both paths mean the same thing: the grant does not cover the operation, and
47// operation, and re-running the identical flow cannot help. Strict matching50// re-running the identical flow cannot help. Strict matching
48// (exact serialized field forms via the shared core detector, or the SDK's51// (exact serialized field forms via the shared core detector, or the SDK's
49// exact upscoping message) — a miss stays on the generic auth path.52// exact step-up message) — a miss stays on the generic auth path.
50const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/;53const SDK_STEP_UP_EXHAUSTED_RE =
54 /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;
51 55
52export const insufficientScopeFromCause = (cause: unknown): boolean =>56export const insufficientScopeFromCause = (cause: unknown): boolean =>
57 InsufficientScopeError.isInstance(cause) ||
53 Option.match(decodeSsePostErrorCause(cause), {58 Option.match(decodeSsePostErrorCause(cause), {
54 onNone: () => false,59 onNone: () => false,
55 onSome: ({ message }) =>60 onSome: ({ message }) =>
56 insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message),61 insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message),
57 });62 });
packages/plugins/mcp/src/sdk/invoke.test.ts+10−5
@@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
2import { Effect, Predicate } from "effect";2import { Effect, Predicate } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";3import { HttpServerResponse } from "effect/unstable/http";
4 4
5import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";5import {
6import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";6 ProtocolError,
7import { McpError } from "@modelcontextprotocol/sdk/types.js";7 SdkErrorCode,
8 SdkHttpError,
9 type OAuthClientProvider,
10} from "@modelcontextprotocol/client";
8import { ElicitationResponse } from "@executor-js/sdk";11import { ElicitationResponse } from "@executor-js/sdk";
9import { serveTestHttpApp } from "@executor-js/sdk/testing";12import { serveTestHttpApp } from "@executor-js/sdk/testing";
10 13
@@ -108,14 +111,16 @@ const invocationRejectionCases = [ · 97 unmodified lines
108 name: "wraps callTool rejection with a stable message and status",111 name: "wraps callTool rejection with a stable message and status",
109 toolId: "blocked",112 toolId: "blocked",
110 transport: "streamable-http",113 transport: "streamable-http",
111 cause: new StreamableHTTPError(401, "token=do-not-leak"),114 cause: new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "token=do-not-leak", {
115 status: 401,
116 }),
112 expectedStatus: 401 as number | undefined,117 expectedStatus: 401 as number | undefined,
113 },118 },
114 {119 {
115 name: "does not treat MCP protocol error codes as HTTP statuses",120 name: "does not treat MCP protocol error codes as HTTP statuses",
116 toolId: "protocol_error",121 toolId: "protocol_error",
117 transport: "streamable-http",122 transport: "streamable-http",
118 cause: new McpError(401, "application-level do-not-leak"),123 cause: new ProtocolError(401, "application-level do-not-leak"),
119 expectedStatus: undefined,124 expectedStatus: undefined,
120 },125 },
121 {126 {
packages/plugins/mcp/src/sdk/invoke.ts+24−15
@@ -8,7 +8,7 @@ · 7 unmodified lines
8// legitimately retain state in that session. The pool keeps one idle8// legitimately retain state in that session. The pool keeps one idle
9// connection per resolved identity and leases it exclusively per invoke;9// connection per resolved identity and leases it exclusively per invoke;
10// stdio and callers without a pool remain strictly per-call.10// stdio and callers without a pool remain strictly per-call.
11// 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges11// 2. Installing a per-invocation `elicitation/create` handler that bridges
12// MCP's elicit capability into the host's elicit function threaded via12// MCP's elicit capability into the host's elicit function threaded via
13// `InvokeToolInput.elicit`.13// `InvokeToolInput.elicit`.
14// 3. Calling `client.callTool({ name, arguments })`.14// 3. Calling `client.callTool({ name, arguments })`.
@@ -16,12 +16,7 @@ · 1 unmodified line
16 16
17import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";17import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
18 18
19import {19import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
20 ElicitRequestSchema,
21 ErrorCode,
22 McpError,
23 ToolListChangedNotificationSchema,
24} from "@modelcontextprotocol/sdk/types.js";
25 20
26import {21import {
27 ElicitationId,22 ElicitationId,
@@ -66,9 +61,10 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines
66 61
67const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>62const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>
68 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass63 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK surfaces JSON-RPC protocol errors as this Error subclass
69 cause instanceof McpError &&64 cause instanceof ProtocolError &&
70 (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) &&65 (cause.code === ProtocolErrorCode.InvalidParams ||
71 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's McpError, whose message carries the only unknown-tool discriminator the protocol provides66 cause.code === ProtocolErrorCode.MethodNotFound) &&
67 // oxlint-disable-next-line executor/no-unknown-error-message -- boundary: instanceof narrows to the SDK's ProtocolError, whose message carries the only unknown-tool discriminator the protocol provides
72 isUnknownToolMessage(cause.message, toolName);68 isUnknownToolMessage(cause.message, toolName);
73 69
74// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
@@ -93,6 +89,17 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines
93type McpElicitParams = typeof McpElicitParams.Type;89type McpElicitParams = typeof McpElicitParams.Type;
94 90
95const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);91const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);
92const decodeElicitContent = Schema.decodeUnknownSync(
93 Schema.Record(
94 Schema.String,
95 Schema.Union([
96 Schema.String,
97 Schema.Number,
98 Schema.Boolean,
99 Schema.mutable(Schema.Array(Schema.String)),
100 ]),
101 ),
102);
96 103
97const toElicitationRequest = (params: McpElicitParams): ElicitationRequest =>104const toElicitationRequest = (params: McpElicitParams): ElicitationRequest =>
98 params.mode === "url"105 params.mode === "url"
@@ -107,7 +114,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines
107 });114 });
108 115
109const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => {116const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => {
110 client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => {117 client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => {
111 const params = decodeElicitParams(request.params);118 const params = decodeElicitParams(request.params);
112 const req = toElicitationRequest(params);119 const req = toElicitationRequest(params);
113 // Use runPromiseExit so we can inspect typed failures — `elicit`120 // Use runPromiseExit so we can inspect typed failures — `elicit`
@@ -119,7 +126,9 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic · 5 unmodified lines
119 const response = exit.value;126 const response = exit.value;
120 return {127 return {
121 action: response.action,128 action: response.action,
122 ...(response.action === "accept" && response.content ? { content: response.content } : {}),129 ...(response.action === "accept" && response.content
130 ? { content: decodeElicitContent(response.content) }
131 : {}),
123 };132 };
124 }133 }
125 const failure = exit.cause.reasons.find(Cause.isFailReason);134 const failure = exit.cause.reasons.find(Cause.isFailReason);
@@ -149,7 +158,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines
149 onToolListChanged: (() => void) | undefined,158 onToolListChanged: (() => void) | undefined,
150): void => {159): void => {
151 if (!onToolListChanged) return;160 if (!onToolListChanged) return;
152 client.setNotificationHandler(ToolListChangedNotificationSchema, () => {161 client.setNotificationHandler("notifications/tools/list_changed", () => {
153 onToolListChanged();162 onToolListChanged();
154 });163 });
155};164};
@@ -189,8 +198,8 @@ const useConnection = ( · 33 unmodified lines
189 });198 });
190 }199 }
191 const status = httpStatusFromCause(cause);200 const status = httpStatusFromCause(cause);
192 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its McpError subclass; transport failures use other error shapes201 // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes
193 const protocolFailure = cause instanceof McpError;202 const protocolFailure = cause instanceof ProtocolError;
194 return new McpInvocationError({203 return new McpInvocationError({
195 toolName,204 toolName,
196 message: `MCP tool call failed for ${toolName}`,205 message: `MCP tool call failed for ${toolName}`,
packages/plugins/mcp/src/sdk/plugin.test.ts+8−0
@@ -55,12 +55,20 @@ const jsonRpcResult = (request: JsonRpcRequest, result: unknown) => · 54 unmodified lines
55 result,55 result,
56 });56 });
57 57
58const jsonRpcMethodNotFound = (request: JsonRpcRequest) =>
59 HttpServerResponse.jsonUnsafe({
60 jsonrpc: "2.0",
61 id: request.id ?? null,
62 error: { code: -32601, message: "Method not found" },
63 });
64
58// The call-tool fixtures share one JSON-RPC scaffold (handshake, tool listing,65// The call-tool fixtures share one JSON-RPC scaffold (handshake, tool listing,
59// unknown-method rejection); only the `tools/call` response varies. Each66// unknown-method rejection); only the `tools/call` response varies. Each
60// scenario supplies that branch via a `CallToolResponder`.67// scenario supplies that branch via a `CallToolResponder`.
61type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType<typeof HttpServerResponse.text>;68type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType<typeof HttpServerResponse.text>;
62 69
63const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponder) => {70const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponder) => {
71 if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc);
64 if (rpc.method === "initialize") {72 if (rpc.method === "initialize") {
65 return jsonRpcResult(rpc, {73 return jsonRpcResult(rpc, {
66 protocolVersion: "2025-06-18",74 protocolVersion: "2025-06-18",
packages/plugins/mcp/src/sdk/plugin.ts+6−5
@@ -1,9 +1,8 @@
1import { Effect, Layer, Option, Result, Schema } from "effect";1import { Effect, Layer, Option, Result, Schema } from "effect";
2import type { HttpClient } from "effect/unstable/http";2import type { HttpClient } from "effect/unstable/http";
3 3
4import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";4import type { OAuthClientProvider } from "@modelcontextprotocol/client";
5import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";5import { CallToolResultSchema } from "@modelcontextprotocol/core";
6import * as z from "zod/v4";
7 6
8import {7import {
9 authToolFailure,8 authToolFailure,
@@ -392,7 +391,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 382 unmodified lines
392 readonly properties?: Record<string, unknown>;391 readonly properties?: Record<string, unknown>;
393};392};
394 393
395const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject;394const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema();
396 395
397const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {396const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {
398 const defaultStructuredContentSchema =397 const defaultStructuredContentSchema =
@@ -493,7 +492,9 @@ export const userFacingProbeMessage = ( · 94 unmodified lines
493// MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the492// MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the
494// transport sends it as a Bearer header. Refresh is core's responsibility493// transport sends it as a Bearer header. Refresh is core's responsibility
495// (the connection row carries the OAuth grant); this adapter never initiates494// (the connection row carries the OAuth grant); this adapter never initiates
496// a new flow and fails loudly if the SDK tries to.495// a new flow and fails loudly if the SDK tries to. V2 stamps stored credentials
496// with the authorization-server issuer and offers scoped invalidation; this
497// single-token boundary intentionally persists neither.
497// ---------------------------------------------------------------------------498// ---------------------------------------------------------------------------
498 499
499const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({500const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({
packages/plugins/mcp/src/sdk/probe-shape.test.ts+93−2
@@ -1,6 +1,12 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect, Ref } from "effect";2import { Effect, Layer, Predicate, Ref } from "effect";
3import { HttpServerResponse } from "effect/unstable/http";3import {
4 HttpClient,
5 HttpClientError,
6 HttpClientRequest,
7 HttpClientResponse,
8 HttpServerResponse,
9} from "effect/unstable/http";
4import { serveTestHttpApp } from "@executor-js/sdk/testing";10import { serveTestHttpApp } from "@executor-js/sdk/testing";
5 11
6import { probeMcpEndpointShape } from "./probe-shape";12import { probeMcpEndpointShape } from "./probe-shape";
@@ -320,6 +326,91 @@ describe("probeMcpEndpointShape", () => { · 313 unmodified lines
320 ),326 ),
321 );327 );
322 328
329 it.effect("falls through a wrong-shape legacy GET retry to modern server discovery", () =>
330 Effect.scoped(
331 Effect.gen(function* () {
332 const server = yield* serveProbeEndpoint((request) => {
333 if (request.body.includes('"method":"server/discover"')) {
334 return HttpServerResponse.jsonUnsafe({
335 jsonrpc: "2.0",
336 id: 2,
337 error: { code: -32601, message: "Method not found" },
338 });
339 }
340 if (request.method === "GET") {
341 return HttpServerResponse.jsonUnsafe({ error: "legacy SSE is unsupported" });
342 }
343 return HttpServerResponse.empty({ status: 405 });
344 });
345
346 const result = yield* probeMcpEndpointShape(server.endpoint);
347 expect(result).toEqual({ kind: "mcp", requiresAuth: false });
348
349 const requests = yield* server.requests;
350 expect(requests).toHaveLength(3);
351 expect(requests[0]?.body).toContain('"protocolVersion":"2025-11-25"');
352 expect(requests[1]?.method).toBe("GET");
353 expect(requests[2]?.body).toBe(
354 JSON.stringify({
355 jsonrpc: "2.0",
356 id: 2,
357 method: "server/discover",
358 params: {
359 _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
360 },
361 }),
362 );
363 expect(requests[2]?.headers["mcp-protocol-version"]).toBe("2026-07-28");
364 }),
365 ),
366 );
367
368 it.effect("keeps the initialize verdict when the discover fallback fails at the transport", () =>
369 Effect.gen(function* () {
370 const bodyText = (request: HttpClientRequest.HttpClientRequest): string =>
371 Predicate.isTagged(request.body, "Uint8Array")
372 ? new TextDecoder().decode(request.body.body)
373 : "";
374 const httpClientLayer = Layer.succeed(HttpClient.HttpClient)(
375 HttpClient.make(
376 (
377 request: HttpClientRequest.HttpClientRequest,
378 ): Effect.Effect<
379 HttpClientResponse.HttpClientResponse,
380 HttpClientError.HttpClientError
381 > =>
382 bodyText(request).includes('"method":"server/discover"')
383 ? Effect.fail(
384 new HttpClientError.HttpClientError({
385 reason: new HttpClientError.TransportError({
386 request,
387 description: "connection reset by peer",
388 }),
389 }),
390 )
391 : Effect.succeed(
392 HttpClientResponse.fromWeb(
393 request,
394 new Response("<html>not mcp</html>", {
395 status: 200,
396 headers: { "content-type": "text/html" },
397 }),
398 ),
399 ),
400 ),
401 );
402
403 const result = yield* probeMcpEndpointShape("https://internal.example/mcp", {
404 httpClientLayer,
405 });
406 expect(result).toEqual({
407 kind: "not-mcp",
408 category: "wrong-shape",
409 reason: "2xx POST body is not a JSON-RPC envelope",
410 });
411 }),
412 );
413
323 it.effect("rejects 2xx with HTML body as wrong-shape", () =>414 it.effect("rejects 2xx with HTML body as wrong-shape", () =>
324 withServer(415 withServer(
325 () =>416 () =>
packages/plugins/mcp/src/sdk/probe-shape.ts+51−10
@@ -13,7 +13,7 @@ · 12 unmodified lines
13// and (b) plenty of real MCP servers authenticate with static API13// and (b) plenty of real MCP servers authenticate with static API
14// keys and publish no OAuth metadata at all (e.g. cubic.dev).14// keys and publish no OAuth metadata at all (e.g. cubic.dev).
15//15//
16// The probe issues an unauth JSON-RPC `initialize` POST and accepts16// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts
17// only the wire shapes a real MCP server can return:17// only the wire shapes a real MCP server can return:
18//18//
19// - 2xx with `Content-Type: text/event-stream` — streamable HTTP19// - 2xx with `Content-Type: text/event-stream` — streamable HTTP
@@ -30,8 +30,14 @@ · 10 unmodified lines
30// only accepts 2xx with `text/event-stream` or the same 401+Bearer30// only accepts 2xx with `text/event-stream` or the same 401+Bearer
31// shape.31// shape.
32//32//
33// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth33// If initialize ultimately has the wrong shape, a second JSON-RPC POST probes
34// round-trip, no DCR — every non-MCP endpoint exits here.34// `server/discover` using the 2026-07-28 envelope and protocol header. This
35// catches modern-only servers that reject initialize with a non-JSON-RPC
36// response. Authentication outcomes remain terminal because they do not vary
37// by transport era.
38//
39// One primary request (occasionally plus legacy GET and modern discover), no
40// MCP-SDK session state, no OAuth round-trip, no DCR.
35// ---------------------------------------------------------------------------41// ---------------------------------------------------------------------------
36 42
37import { Data, Duration, Effect, Layer, Option, Schema } from "effect";43import { Data, Duration, Effect, Layer, Option, Schema } from "effect";
@@ -48,12 +54,21 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines
48 id: 1,54 id: 1,
49 method: "initialize",55 method: "initialize",
50 params: {56 params: {
51 protocolVersion: "2025-06-18",57 protocolVersion: "2025-11-25",
52 capabilities: {},58 capabilities: {},
53 clientInfo: { name: "executor-probe", version: "0" },59 clientInfo: { name: "executor-probe", version: "0" },
54 },60 },
55});61});
56 62
63const DISCOVER_BODY = JSON.stringify({
64 jsonrpc: "2.0",
65 id: 2,
66 method: "server/discover",
67 params: {
68 _meta: { "io.modelcontextprotocol/protocolVersion": "2026-07-28" },
69 },
70});
71
57/** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s72/** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s
58 * `Response.headers` already lower-cases, but we normalise explicitly73 * `Response.headers` already lower-cases, but we normalise explicitly
59 * to stay robust against test mocks that construct `Headers` loosely. */74 * to stay robust against test mocks that construct `Headers` loosely. */
@@ -385,10 +400,9 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines
385 .execute(postRequest)400 .execute(postRequest)
386 .pipe(Effect.timeout(Duration.millis(timeoutMs)));401 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
387 402
388 const postResult = yield* classify(postResponse, "POST");403 let initializeResult = yield* classify(postResponse, "POST");
389 if (postResult) return postResult;
390 404
391 if ([404, 405, 406, 415].includes(postResponse.status)) {405 if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) {
392 let getRequest = HttpClientRequest.get(url.toString()).pipe(406 let getRequest = HttpClientRequest.get(url.toString()).pipe(
393 HttpClientRequest.setHeader("accept", "text/event-stream"),407 HttpClientRequest.setHeader("accept", "text/event-stream"),
394 );408 );
@@ -398,15 +412,42 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines
398 const getResponse = yield* client412 const getResponse = yield* client
399 .execute(getRequest)413 .execute(getRequest)
400 .pipe(Effect.timeout(Duration.millis(timeoutMs)));414 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
401 const getResult = yield* classify(getResponse, "GET");415 initializeResult = yield* classify(getResponse, "GET");
402 if (getResult) return getResult;
403 }416 }
404 417
405 return {418 initializeResult ??= {
406 kind: "not-mcp",419 kind: "not-mcp",
407 category: "wrong-shape",420 category: "wrong-shape",
408 reason: `unexpected status ${postResponse.status} for initialize`,421 reason: `unexpected status ${postResponse.status} for initialize`,
409 } as const;422 } as const;
423
424 if (initializeResult.kind !== "not-mcp" || initializeResult.category !== "wrong-shape") {
425 return initializeResult;
426 }
427
428 let discoverRequest = HttpClientRequest.post(url.toString()).pipe(
429 HttpClientRequest.setHeader("content-type", "application/json"),
430 HttpClientRequest.setHeader("accept", "application/json, text/event-stream"),
431 HttpClientRequest.bodyText(DISCOVER_BODY, "application/json"),
432 );
433 for (const [name, value] of Object.entries(options.headers ?? {})) {
434 discoverRequest = HttpClientRequest.setHeader(discoverRequest, name, value);
435 }
436 discoverRequest = HttpClientRequest.setHeader(
437 discoverRequest,
438 "MCP-Protocol-Version",
439 "2026-07-28",
440 );
441
442 // The endpoint already answered the primary probe, so a transport
443 // failure on this secondary request must not overwrite that verdict
444 // with "unreachable" — keep the initialize classification instead.
445 const discoverResult = yield* client.execute(discoverRequest).pipe(
446 Effect.timeout(Duration.millis(timeoutMs)),
447 Effect.flatMap((discoverResponse) => classify(discoverResponse, "POST")),
448 Effect.catch(() => Effect.succeed(null)),
449 );
450 return discoverResult ?? initializeResult;
410 }).pipe(451 }).pipe(
411 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),452 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),
412 Effect.mapError(453 Effect.mapError(
packages/plugins/mcp/src/sdk/stdio-connector.ts+4−3
@@ -3,8 +3,9 @@ · 2 unmodified lines
3// ---------------------------------------------------------------------------3// ---------------------------------------------------------------------------
4//4//
5// Kept in its own module so `connection.ts` never imports it eagerly at5// Kept in its own module so `connection.ts` never imports it eagerly at
6// module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in6// module load. The v2 `@modelcontextprotocol/client/stdio` entry still eagerly
7// `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers`7// evaluates Node-only process/stream imports and `cross-spawn` (which loads
8// `node:child_process`); under `@cloudflare/vitest-pool-workers`
8// that crashes workerd at module instantiation with SIGSEGV (prod bundles9// that crashes workerd at module instantiation with SIGSEGV (prod bundles
9// tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not).10// tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not).
10//11//
@@ -13,7 +14,7 @@ · 2 unmodified lines
13// the import and therefore never touch `node:child_process`.14// the import and therefore never touch `node:child_process`.
14// ---------------------------------------------------------------------------15// ---------------------------------------------------------------------------
15 16
16import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";17import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
17 18
18export type StdioTransportConfig = {19export type StdioTransportConfig = {
19 readonly command: string;20 readonly command: string;
packages/plugins/mcp/src/sdk/testing-fixtures.test.ts+8−4
@@ -1,7 +1,6 @@
1import { expect, layer } from "@effect/vitest";1import { expect, layer } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
4import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5import { OAuthTestServer } from "@executor-js/sdk/testing";4import { OAuthTestServer } from "@executor-js/sdk/testing";
6 5
7import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing";6import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing";
@@ -16,7 +15,10 @@ const createGreetingMcpServer = () => · 8 unmodified lines
16 });15 });
17 16
18const makeClient = (endpoint: string, accessToken: string) => {17const makeClient = (endpoint: string, accessToken: string) => {
19 const client = new Client({ name: "executor-test-client", version: "1.0.0" });18 const client = new Client(
19 { name: "executor-test-client", version: "1.0.0" },
20 { versionNegotiation: { mode: "auto" } },
21 );
20 const transport = new StreamableHTTPClientTransport(new URL(endpoint), {22 const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
21 requestInit: {23 requestInit: {
22 headers: { authorization: `Bearer ${accessToken}` },24 headers: { authorization: `Bearer ${accessToken}` },
@@ -47,7 +49,9 @@ layer(OAuthTestServer.layer(), { timeout: "15 seconds" })("MCP testing fixtures" · 24 unmodified lines
47 expect(result).toMatchObject({49 expect(result).toMatchObject({
48 content: [{ type: "text", text: "Hello Ada" }],50 content: [{ type: "text", text: "Hello Ada" }],
49 });51 });
50 expect(server.sessionCount()).toBe(1);52 // The v1 fixture creates a throwaway transport for the rejected modern
53 // discovery probe before the initialized legacy session.
54 expect(server.sessionCount()).toBe(2);
51 55
52 const requests = yield* server.requests;56 const requests = yield* server.requests;
53 expect(57 expect(