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
994994 "@effect/platform-node": "catalog:",
995995 "@executor-js/config": "workspace:*",
996996 "@executor-js/sdk": "workspace:*",
997+ "@modelcontextprotocol/client": "2.0.0",
998+ "@modelcontextprotocol/core": "2.0.0",
997999 "@modelcontextprotocol/sdk": "^1.29.0",
9981000 "zod": "4.3.6",
9991001 },
@@ -2126,6 +2128,10 @@ · 1126 unmodified lines
21262128
21272129 "@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=="],
21282130
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+
21292135 "@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=="],
21302136
21312137 "@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
61186124
61196125 "@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=="],
61206126
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+
61216133 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
61226134
61236135 "@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
6565 "@effect/platform-node": "catalog:",
6666 "@executor-js/config": "workspace:*",
6767 "@executor-js/sdk": "workspace:*",
68+ "@modelcontextprotocol/client": "2.0.0",
69+ "@modelcontextprotocol/core": "2.0.0",
6870 "@modelcontextprotocol/sdk": "^1.29.0",
6971 "zod": "4.3.6"
7072 },
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
182182 const jsonRpcResult = (request: JsonRpcRequest, result: unknown) =>
183183 HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result });
184184
185+const 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+
185192 const pageTool = (name: string) => ({
186193 name,
187194 description: `Tool ${name}`,
@@ -199,6 +206,7 @@ const servePaginatedListServer = () => · 11 unmodified lines
199206 return Option.match(decodeJsonRpcRequest(body), {
200207 onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }),
201208 onSome: (rpc) => {
209+ if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc);
202210 if (rpc.method === "initialize") {
203211 return jsonRpcResult(rpc, {
204212 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
77 import { invokeMcpTool } from "./invoke";
88 import { makeEchoMcpServer, serveMcpServer } from "../testing";
99
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.
12+const V1_FIXTURE_TRANSPORTS_PER_DIAL = 2;
13+
1014 const acceptAll: Elicit = () =>
1115 Effect.succeed(ElicitationResponse.make({ action: "accept", content: { approved: true } }));
1216
@@ -54,7 +58,7 @@ describe("MCP connection pool", () => { · 41 unmodified lines
5458
5559 expect(first).toMatchObject({ content: [{ type: "text", text: "first" }] });
5660 expect(second).toMatchObject({ content: [{ type: "text", text: "second" }] });
57− expect(server.sessionCount()).toBe(1);
61+ expect(server.sessionCount()).toBe(V1_FIXTURE_TRANSPORTS_PER_DIAL);
5862 yield* pool.close();
5963 }),
6064 ),
@@ -88,7 +92,7 @@ describe("MCP connection pool", () => { · 27 unmodified lines
8892 expect.objectContaining({ content: [{ type: "text", text: "left" }] }),
8993 expect.objectContaining({ content: [{ type: "text", text: "right" }] }),
9094 ]);
91− expect(server.sessionCount()).toBe(2);
95+ expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
9296 yield* pool.close();
9397 }),
9498 ),
@@ -115,7 +119,7 @@ describe("MCP connection pool", () => { · 20 unmodified lines
115119 });
116120
117121 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);
119123 yield* pool.close();
120124 }),
121125 ),
@@ -148,7 +152,7 @@ describe("MCP connection pool", () => { · 26 unmodified lines
148152 });
149153
150154 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);
152156 yield* pool.close();
153157 }),
154158 ),
@@ -188,8 +192,8 @@ describe("MCP connection pool", () => { · 33 unmodified lines
188192 });
189193
190194 expect(after).toMatchObject({ content: [{ type: "text", text: "after" }] });
191− // A second session was dialled rather than the dead one being reused.
192− expect(server.sessionCount()).toBe(2);
195+ // A second connection was dialled rather than the dead one being reused.
196+ expect(server.sessionCount()).toBe(2 * V1_FIXTURE_TRANSPORTS_PER_DIAL);
193197 yield* pool.close();
194198 }),
195199 ),
packages/plugins/mcp/src/sdk/connection-pool.ts+4−0
@@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines
33 import type { McpConnection, McpConnector } from "./connection";
44 import type { McpInvocationError } from "./errors";
55
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+
610 const IDLE_TTL_MS = 5 * 60 * 1_000;
711
812 type IdleConnection = {
packages/plugins/mcp/src/sdk/connection.ts+19−11
@@ -1,16 +1,18 @@
1−import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3−import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
4−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
5−import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js";
6−import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
1+import {
2+ Client,
3+ SSEClientTransport,
4+ StreamableHTTPClientTransport,
5+ type FetchLike,
6+ type OAuthClientProvider,
7+} from "@modelcontextprotocol/client";
8+import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
79 import { Effect, Layer, Predicate, Stream } from "effect";
810 import { HttpClient, HttpClientRequest } from "effect/unstable/http";
911
1012 // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module
11−// (`@modelcontextprotocol/sdk/client/stdio.js`) touches `node:child_process`
12−// at evaluation time, which crashes workerd (incl. vitest-pool-workers) at
13−// SIGSEGV on module instantiation. Cloud callers set
13+// (`@modelcontextprotocol/client/stdio`) still imports Node process/stream and
14+// `cross-spawn` eagerly at evaluation time, which crashes workerd (including
15+// vitest-pool-workers) with SIGSEGV on module instantiation. Cloud callers set
1416 // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below;
1517 // prod bundles that DO use stdio load it via a dynamic import inside the
1618 // stdio branch of `createMcpConnector`.
@@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines
201203 // MCP plugin runs inside a Cloudflare Worker (executor.sh). The
202204 // cfworker validator does not use code generation and works in every
203205 // runtime we ship to.
204−const createClient = (): Client =>
206+const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client =>
205207 new Client(
206208 { name: "executor-mcp", version: "0.1.0" },
207209 {
208210 capabilities: { elicitation: { form: {}, url: {} } },
209211 jsonSchemaValidator: new CfWorkerJsonSchemaValidator(),
212+ ...(versionNegotiation === undefined ? {} : { versionNegotiation }),
210213 },
211214 );
212215
@@ -247,9 +250,10 @@ const connectionFailure = ( · 34 unmodified lines
247250 const connectClient = (input: {
248251 transport: string;
249252 createTransport: () => Parameters<Client["connect"]>[0];
253+ versionNegotiation?: { readonly mode: "auto" };
250254 }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> =>
251255 Effect.gen(function* () {
252− const client = createClient();
256+ const client = createClient(input.versionNegotiation);
253257 const transportInstance = input.createTransport();
254258
255259 yield* Effect.tryPromise({
@@ -314,8 +318,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 58 unmodified lines
314318
315319 const endpoint = buildEndpointUrl(input.endpoint, input.queryParams ?? {});
316320
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.
317324 const connectStreamableHttp = connectClient({
318325 transport: "streamable-http",
326+ versionNegotiation: { mode: "auto" },
319327 createTransport: () =>
320328 new StreamableHTTPClientTransport(endpoint, {
321329 requestInit,
packages/plugins/mcp/src/sdk/elicitation.test.ts+3−3
@@ -1,7 +1,7 @@
11 import { describe, expect, it } from "@effect/vitest";
22 import { Effect, Predicate, Schema, Semaphore } from "effect";
3−import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
4−import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types";
3+import type { JsonSchemaType } from "@modelcontextprotocol/client";
4+import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker";
55
66 import {
77 AuthTemplateSlug,
@@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines
226226 ]),
227227 );
228228 expect(schema?.outputTypeScript).toContain('type: "text"');
229− expect(schema?.outputTypeScript).toContain("structuredContent?: { [k: string]: unknown; }");
229+ expect(schema?.outputTypeScript).toContain("structuredContent?: unknown;");
230230
231231 const result = yield* executor.execute(
232232 simpleEcho.address,
packages/plugins/mcp/src/sdk/http-status.test.ts+17−3
@@ -1,4 +1,5 @@
11 import { describe, expect, it } from "@effect/vitest";
2+import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client";
23
34 // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors
45 import { insufficientScopeFromCause } from "./http-status";
@@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines
910 // - with an authProvider (the production OAuth path): the StreamableHTTP
1011 // transport consumes the insufficient_scope challenge itself, retries
1112 // 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.
1315 describe("insufficientScopeFromCause", () => {
1416 it("detects the OAuth error body embedded in a transport message", () => {
1517 expect(
@@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines
3133 ).toBe(true);
3234 });
3335
34− it("detects the SDK's exhausted-upscoping failure (the authProvider path)", () => {
36+ it("detects the SDK's typed insufficient-scope failure", () => {
3537 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+ ),
3751 ).toBe(true);
3852 });
3953
packages/plugins/mcp/src/sdk/http-status.ts+25−20
@@ -1,7 +1,8 @@
11 // ---------------------------------------------------------------------------
22 // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces
3−// transport failures two ways: a `StreamableHTTPError` subclass carrying a
4−// numeric `code`, and an SSE POST failure whose message embeds `(HTTP nnn)`.
3+// transport failures two ways: an `SdkHttpError` carrying a numeric `status`,
4+// and an `SseError` carrying a numeric `code`. The SSE transport also retains
5+// its historic POST-failure message for errors created below EventSource.
56 // Shared by the invoke path (classifies tool-call failures) and the connect
67 // path (so a 401/403 during the handshake reaches the liveness health check).
78 // ---------------------------------------------------------------------------
@@ -9,13 +10,13 @@ · 1 unmodified line
910 import { Option, Schema } from "effect";
1011
1112 import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core";
12−import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
13+import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client";
1314
1415 const SsePostErrorCause = Schema.Struct({ message: Schema.String });
1516 const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause);
1617
17−// Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify
18−// on SDK bumps. A format drift just yields undefined (generic error, no crash).
18+// V2 still constructs this exact message in SSEClientTransport._send. A format
19+// drift just yields undefined (generic error, no crash).
1920 const statusFromSsePostError = (cause: unknown): number | undefined =>
2021 Option.match(decodeSsePostErrorCause(cause), {
2122 onNone: () => undefined,
@@ -26,32 +27,36 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines
2627 },
2728 });
2829
29−const statusFromStreamableHttpError = (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 code
31− if (!(cause instanceof StreamableHTTPError)) return undefined;
32− const code = cause.code;
33− return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
30+const statusFromTypedTransportError = (cause: unknown): number | undefined => {
31+ if (SdkHttpError.isInstance(cause)) return cause.status;
32+ if (SseError.isInstance(cause)) {
33+ const code = cause.code;
34+ return code !== undefined && code >= 100 && code <= 599 ? code : undefined;
35+ }
36+ return undefined;
3437 };
3538
3639 export const httpStatusFromCause = (cause: unknown): number | undefined =>
37− statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause);
40+ statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause);
3841
3942 // The SDK embeds the upstream response text in the transport error message
4043 // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body
4144 // survives for connections without an authProvider. For OAuth connections the
42−// StreamableHTTP transport consumes the insufficient_scope challenge ITSELF:
43−// it re-runs auth requesting the broader scope, and only when that upscoped
44−// retry still 403s does it throw — with the fixed message matched below
45−// (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on
46−// SDK bumps). Both paths mean the same thing: the grant does not cover the
47−// operation, and re-running the identical flow cannot help. Strict matching
45+// StreamableHTTP transport consumes the insufficient_scope challenge itself.
46+// V2 throws `InsufficientScopeError` when configured not to reauthorize; after
47+// exhausting its step-up retries it throws `SdkHttpError` with the exact fixed
48+// message matched below (verified against the installed v2 transport source).
49+// Both paths mean the same thing: the grant does not cover the operation, and
50+// re-running the identical flow cannot help. Strict matching
4851 // (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.
50−const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/;
52+// exact step-up message) — a miss stays on the generic auth path.
53+const SDK_STEP_UP_EXHAUSTED_RE =
54+ /^Server returned 403 insufficient_scope after step-up re-authorization \(retry limit \d+ reached\)$/;
5155
5256 export const insufficientScopeFromCause = (cause: unknown): boolean =>
57+ InsufficientScopeError.isInstance(cause) ||
5358 Option.match(decodeSsePostErrorCause(cause), {
5459 onNone: () => false,
5560 onSome: ({ message }) =>
56− insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message),
61+ insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message),
5762 });
packages/plugins/mcp/src/sdk/invoke.test.ts+10−5
@@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line
22 import { Effect, Predicate } from "effect";
33 import { HttpServerResponse } from "effect/unstable/http";
44
5−import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
6−import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7−import { McpError } from "@modelcontextprotocol/sdk/types.js";
5+import {
6+ ProtocolError,
7+ SdkErrorCode,
8+ SdkHttpError,
9+ type OAuthClientProvider,
10+} from "@modelcontextprotocol/client";
811 import { ElicitationResponse } from "@executor-js/sdk";
912 import { serveTestHttpApp } from "@executor-js/sdk/testing";
1013
@@ -108,14 +111,16 @@ const invocationRejectionCases = [ · 97 unmodified lines
108111 name: "wraps callTool rejection with a stable message and status",
109112 toolId: "blocked",
110113 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+ }),
112117 expectedStatus: 401 as number | undefined,
113118 },
114119 {
115120 name: "does not treat MCP protocol error codes as HTTP statuses",
116121 toolId: "protocol_error",
117122 transport: "streamable-http",
118− cause: new McpError(401, "application-level do-not-leak"),
123+ cause: new ProtocolError(401, "application-level do-not-leak"),
119124 expectedStatus: undefined,
120125 },
121126 {
packages/plugins/mcp/src/sdk/invoke.ts+24−15
@@ -8,7 +8,7 @@ · 7 unmodified lines
88 // legitimately retain state in that session. The pool keeps one idle
99 // connection per resolved identity and leases it exclusively per invoke;
1010 // stdio and callers without a pool remain strictly per-call.
11−// 2. Installing a per-invocation `ElicitRequestSchema` handler that bridges
11+// 2. Installing a per-invocation `elicitation/create` handler that bridges
1212 // MCP's elicit capability into the host's elicit function threaded via
1313 // `InvokeToolInput.elicit`.
1414 // 3. Calling `client.callTool({ name, arguments })`.
@@ -16,12 +16,7 @@ · 1 unmodified line
1616
1717 import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect";
1818
19−import {
20− ElicitRequestSchema,
21− ErrorCode,
22− McpError,
23− ToolListChangedNotificationSchema,
24−} from "@modelcontextprotocol/sdk/types.js";
19+import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client";
2520
2621 import {
2722 ElicitationId,
@@ -66,9 +61,10 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines
6661
6762 const isUnknownToolCause = (cause: unknown, toolName: string): boolean =>
6863 // 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 &&
70− (cause.code === ErrorCode.InvalidParams || cause.code === ErrorCode.MethodNotFound) &&
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 provides
64+ cause instanceof ProtocolError &&
65+ (cause.code === ProtocolErrorCode.InvalidParams ||
66+ 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
7268 isUnknownToolMessage(cause.message, toolName);
7369
7470 // ---------------------------------------------------------------------------
@@ -93,6 +89,17 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines
9389 type McpElicitParams = typeof McpElicitParams.Type;
9490
9591 const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams);
92+const 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+);
96103
97104 const toElicitationRequest = (params: McpElicitParams): ElicitationRequest =>
98105 params.mode === "url"
@@ -107,7 +114,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines
107114 });
108115
109116 const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => {
110− client.setRequestHandler(ElicitRequestSchema, async (request: { params: unknown }) => {
117+ client.setRequestHandler("elicitation/create", async (request: { params: unknown }) => {
111118 const params = decodeElicitParams(request.params);
112119 const req = toElicitationRequest(params);
113120 // Use runPromiseExit so we can inspect typed failures — `elicit`
@@ -119,7 +126,9 @@ const installElicitationHandler = (client: McpConnection["client"], elicit: Elic · 5 unmodified lines
119126 const response = exit.value;
120127 return {
121128 action: response.action,
122− ...(response.action === "accept" && response.content ? { content: response.content } : {}),
129+ ...(response.action === "accept" && response.content
130+ ? { content: decodeElicitContent(response.content) }
131+ : {}),
123132 };
124133 }
125134 const failure = exit.cause.reasons.find(Cause.isFailReason);
@@ -149,7 +158,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines
149158 onToolListChanged: (() => void) | undefined,
150159 ): void => {
151160 if (!onToolListChanged) return;
152− client.setNotificationHandler(ToolListChangedNotificationSchema, () => {
161+ client.setNotificationHandler("notifications/tools/list_changed", () => {
153162 onToolListChanged();
154163 });
155164 };
@@ -189,8 +198,8 @@ const useConnection = ( · 33 unmodified lines
189198 });
190199 }
191200 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 shapes
193− const protocolFailure = cause instanceof McpError;
201+ // oxlint-disable-next-line executor/no-instanceof-tagged-error -- boundary: MCP SDK protocol failures are its ProtocolError subclass; transport failures use other error shapes
202+ const protocolFailure = cause instanceof ProtocolError;
194203 return new McpInvocationError({
195204 toolName,
196205 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
5555 result,
5656 });
5757
58+const 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+
5865 // The call-tool fixtures share one JSON-RPC scaffold (handshake, tool listing,
5966 // unknown-method rejection); only the `tools/call` response varies. Each
6067 // scenario supplies that branch via a `CallToolResponder`.
6168 type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType<typeof HttpServerResponse.text>;
6269
6370 const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponder) => {
71+ if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc);
6472 if (rpc.method === "initialize") {
6573 return jsonRpcResult(rpc, {
6674 protocolVersion: "2025-06-18",
packages/plugins/mcp/src/sdk/plugin.ts+6−5
@@ -1,9 +1,8 @@
11 import { Effect, Layer, Option, Result, Schema } from "effect";
22 import type { HttpClient } from "effect/unstable/http";
33
4−import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
5−import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
6−import * as z from "zod/v4";
4+import type { OAuthClientProvider } from "@modelcontextprotocol/client";
5+import { CallToolResultSchema } from "@modelcontextprotocol/core";
76
87 import {
98 authToolFailure,
@@ -392,7 +391,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 382 unmodified lines
392391 readonly properties?: Record<string, unknown>;
393392 };
394393
395−const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject;
394+const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema();
396395
397396 const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => {
398397 const defaultStructuredContentSchema =
@@ -493,7 +492,9 @@ export const userFacingProbeMessage = ( · 94 unmodified lines
493492 // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the
494493 // transport sends it as a Bearer header. Refresh is core's responsibility
495494 // (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.
497498 // ---------------------------------------------------------------------------
498499
499500 const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({
packages/plugins/mcp/src/sdk/probe-shape.test.ts+93−2
@@ -1,6 +1,12 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Effect, Ref } from "effect";
3−import { HttpServerResponse } from "effect/unstable/http";
2+import { Effect, Layer, Predicate, Ref } from "effect";
3+import {
4+ HttpClient,
5+ HttpClientError,
6+ HttpClientRequest,
7+ HttpClientResponse,
8+ HttpServerResponse,
9+} from "effect/unstable/http";
410 import { serveTestHttpApp } from "@executor-js/sdk/testing";
511
612 import { probeMcpEndpointShape } from "./probe-shape";
@@ -320,6 +326,91 @@ describe("probeMcpEndpointShape", () => { · 313 unmodified lines
320326 ),
321327 );
322328
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+
323414 it.effect("rejects 2xx with HTML body as wrong-shape", () =>
324415 withServer(
325416 () =>
packages/plugins/mcp/src/sdk/probe-shape.ts+51−10
@@ -13,7 +13,7 @@ · 12 unmodified lines
1313 // and (b) plenty of real MCP servers authenticate with static API
1414 // keys and publish no OAuth metadata at all (e.g. cubic.dev).
1515 //
16−// The probe issues an unauth JSON-RPC `initialize` POST and accepts
16+// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts
1717 // only the wire shapes a real MCP server can return:
1818 //
1919 // - 2xx with `Content-Type: text/event-stream` — streamable HTTP
@@ -30,8 +30,14 @@ · 10 unmodified lines
3030 // only accepts 2xx with `text/event-stream` or the same 401+Bearer
3131 // shape.
3232 //
33−// One `fetch` (occasionally two), no MCP-SDK session state, no OAuth
34−// round-trip, no DCR — every non-MCP endpoint exits here.
33+// If initialize ultimately has the wrong shape, a second JSON-RPC POST probes
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.
3541 // ---------------------------------------------------------------------------
3642
3743 import { Data, Duration, Effect, Layer, Option, Schema } from "effect";
@@ -48,12 +54,21 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines
4854 id: 1,
4955 method: "initialize",
5056 params: {
51− protocolVersion: "2025-06-18",
57+ protocolVersion: "2025-11-25",
5258 capabilities: {},
5359 clientInfo: { name: "executor-probe", version: "0" },
5460 },
5561 });
5662
63+const 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+
5772 /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s
5873 * `Response.headers` already lower-cases, but we normalise explicitly
5974 * to stay robust against test mocks that construct `Headers` loosely. */
@@ -385,10 +400,9 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines
385400 .execute(postRequest)
386401 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
387402
388− const postResult = yield* classify(postResponse, "POST");
389− if (postResult) return postResult;
403+ let initializeResult = yield* classify(postResponse, "POST");
390404
391− if ([404, 405, 406, 415].includes(postResponse.status)) {
405+ if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) {
392406 let getRequest = HttpClientRequest.get(url.toString()).pipe(
393407 HttpClientRequest.setHeader("accept", "text/event-stream"),
394408 );
@@ -398,15 +412,42 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines
398412 const getResponse = yield* client
399413 .execute(getRequest)
400414 .pipe(Effect.timeout(Duration.millis(timeoutMs)));
401− const getResult = yield* classify(getResponse, "GET");
402− if (getResult) return getResult;
415+ initializeResult = yield* classify(getResponse, "GET");
403416 }
404417
405− return {
418+ initializeResult ??= {
406419 kind: "not-mcp",
407420 category: "wrong-shape",
408421 reason: `unexpected status ${postResponse.status} for initialize`,
409422 } 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;
410451 }).pipe(
411452 Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer),
412453 Effect.mapError(
packages/plugins/mcp/src/sdk/stdio-connector.ts+4−3
@@ -3,8 +3,9 @@ · 2 unmodified lines
33 // ---------------------------------------------------------------------------
44 //
55 // Kept in its own module so `connection.ts` never imports it eagerly at
6−// module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in
7−// `node:child_process` at evaluation time; under `@cloudflare/vitest-pool-workers`
6+// module load. The v2 `@modelcontextprotocol/client/stdio` entry still eagerly
7+// evaluates Node-only process/stream imports and `cross-spawn` (which loads
8+// `node:child_process`); under `@cloudflare/vitest-pool-workers`
89 // that crashes workerd at module instantiation with SIGSEGV (prod bundles
910 // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not).
1011 //
@@ -13,7 +14,7 @@ · 2 unmodified lines
1314 // the import and therefore never touch `node:child_process`.
1415 // ---------------------------------------------------------------------------
1516
16−import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
17+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
1718
1819 export type StdioTransportConfig = {
1920 readonly command: string;
packages/plugins/mcp/src/sdk/testing-fixtures.test.ts+8−4
@@ -1,7 +1,6 @@
11 import { expect, layer } from "@effect/vitest";
22 import { Effect } from "effect";
3−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4−import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3+import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
54 import { OAuthTestServer } from "@executor-js/sdk/testing";
65
76 import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing";
@@ -16,7 +15,10 @@ const createGreetingMcpServer = () => · 8 unmodified lines
1615 });
1716
1817 const 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+ );
2022 const transport = new StreamableHTTPClientTransport(new URL(endpoint), {
2123 requestInit: {
2224 headers: { authorization: `Bearer ${accessToken}` },
@@ -47,7 +49,9 @@ layer(OAuthTestServer.layer(), { timeout: "15 seconds" })("MCP testing fixtures" · 24 unmodified lines
4749 expect(result).toMatchObject({
4850 content: [{ type: "text", text: "Hello Ada" }],
4951 });
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);
5155
5256 const requests = yield* server.requests;
5357 expect(