Unified · Split
| @@ -994,6 +994,8 @@ · 993 unmodified lines | ||
| 994 | 994 | "@effect/platform-node": "catalog:", |
| 995 | 995 | "@executor-js/config": "workspace:*", |
| 996 | 996 | "@executor-js/sdk": "workspace:*", |
| 997 | + "@modelcontextprotocol/client": "2.0.0", | |
| 998 | + "@modelcontextprotocol/core": "2.0.0", | |
| 997 | 999 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 998 | 1000 | "zod": "4.3.6", |
| 999 | 1001 | }, |
| @@ -2126,6 +2128,10 @@ · 1126 unmodified lines | ||
| 2126 | 2128 | |
| 2127 | 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 | 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 | 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 | 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 | 6133 | "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], |
| 6122 | 6134 | |
| 6123 | 6135 | "@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], |
| @@ -65,6 +65,8 @@ · 64 unmodified lines | ||
| 65 | 65 | "@effect/platform-node": "catalog:", |
| 66 | 66 | "@executor-js/config": "workspace:*", |
| 67 | 67 | "@executor-js/sdk": "workspace:*", |
| 68 | + "@modelcontextprotocol/client": "2.0.0", | |
| 69 | + "@modelcontextprotocol/core": "2.0.0", | |
| 68 | 70 | "@modelcontextprotocol/sdk": "^1.29.0", |
| 69 | 71 | "zod": "4.3.6" |
| 70 | 72 | }, |
| @@ -182,6 +182,13 @@ const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(Js · 181 unmodified lines | ||
| 182 | 182 | const jsonRpcResult = (request: JsonRpcRequest, result: unknown) => |
| 183 | 183 | HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: request.id ?? null, result }); |
| 184 | 184 | |
| 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 | + | |
| 185 | 192 | const pageTool = (name: string) => ({ |
| 186 | 193 | name, |
| 187 | 194 | description: `Tool ${name}`, |
| @@ -199,6 +206,7 @@ const servePaginatedListServer = () => · 11 unmodified lines | ||
| 199 | 206 | return Option.match(decodeJsonRpcRequest(body), { |
| 200 | 207 | onNone: () => HttpServerResponse.text("Invalid JSON-RPC fixture request", { status: 400 }), |
| 201 | 208 | onSome: (rpc) => { |
| 209 | + if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc); | |
| 202 | 210 | if (rpc.method === "initialize") { |
| 203 | 211 | return jsonRpcResult(rpc, { |
| 204 | 212 | protocolVersion: "2025-06-18", |
| @@ -7,6 +7,10 @@ import { createMcpConnectionPool } from "./connection-pool"; · 6 unmodified lines | ||
| 7 | 7 | import { invokeMcpTool } from "./invoke"; |
| 8 | 8 | import { 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. | |
| 12 | +const V1_FIXTURE_TRANSPORTS_PER_DIAL = 2; | |
| 13 | + | |
| 10 | 14 | const acceptAll: Elicit = () => |
| 11 | 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 | 59 | expect(first).toMatchObject({ content: [{ type: "text", text: "first" }] }); |
| 56 | 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 | 62 | yield* pool.close(); |
| 59 | 63 | }), |
| 60 | 64 | ), |
| @@ -88,7 +92,7 @@ describe("MCP connection pool", () => { · 27 unmodified lines | ||
| 88 | 92 | expect.objectContaining({ content: [{ type: "text", text: "left" }] }), |
| 89 | 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 | 96 | yield* pool.close(); |
| 93 | 97 | }), |
| 94 | 98 | ), |
| @@ -115,7 +119,7 @@ describe("MCP connection pool", () => { · 20 unmodified lines | ||
| 115 | 119 | }); |
| 116 | 120 | |
| 117 | 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 | 123 | yield* pool.close(); |
| 120 | 124 | }), |
| 121 | 125 | ), |
| @@ -148,7 +152,7 @@ describe("MCP connection pool", () => { · 26 unmodified lines | ||
| 148 | 152 | }); |
| 149 | 153 | |
| 150 | 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 | 156 | yield* pool.close(); |
| 153 | 157 | }), |
| 154 | 158 | ), |
| @@ -188,8 +192,8 @@ describe("MCP connection pool", () => { · 33 unmodified lines | ||
| 188 | 192 | }); |
| 189 | 193 | |
| 190 | 194 | 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); | |
| 193 | 197 | yield* pool.close(); |
| 194 | 198 | }), |
| 195 | 199 | ), |
| @@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | import type { McpConnection, McpConnector } from "./connection"; |
| 4 | 4 | import 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 | + | |
| 6 | 10 | const IDLE_TTL_MS = 5 * 60 * 1_000; |
| 7 | 11 | |
| 8 | 12 | type IdleConnection = { |
| @@ -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"; | |
| 7 | 9 | import { Effect, Layer, Predicate, Stream } from "effect"; |
| 8 | 10 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 9 | 11 | |
| 10 | 12 | // 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 | |
| 14 | 16 | // `dangerouslyAllowStdioMCP: false` and never reach the stdio branch below; |
| 15 | 17 | // prod bundles that DO use stdio load it via a dynamic import inside the |
| 16 | 18 | // stdio branch of `createMcpConnector`. |
| @@ -201,12 +203,13 @@ const fetchFromHttpClientLayer = ( · 184 unmodified lines | ||
| 201 | 203 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The |
| 202 | 204 | // cfworker validator does not use code generation and works in every |
| 203 | 205 | // runtime we ship to. |
| 204 | −const createClient = (): Client => | |
| 206 | +const createClient = (versionNegotiation?: { readonly mode: "auto" }): Client => | |
| 205 | 207 | new Client( |
| 206 | 208 | { name: "executor-mcp", version: "0.1.0" }, |
| 207 | 209 | { |
| 208 | 210 | capabilities: { elicitation: { form: {}, url: {} } }, |
| 209 | 211 | jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), |
| 212 | + ...(versionNegotiation === undefined ? {} : { versionNegotiation }), | |
| 210 | 213 | }, |
| 211 | 214 | ); |
| 212 | 215 | |
| @@ -247,9 +250,10 @@ const connectionFailure = ( · 34 unmodified lines | ||
| 247 | 250 | const connectClient = (input: { |
| 248 | 251 | transport: string; |
| 249 | 252 | createTransport: () => Parameters<Client["connect"]>[0]; |
| 253 | + versionNegotiation?: { readonly mode: "auto" }; | |
| 250 | 254 | }): Effect.Effect<McpConnection, McpConnectionError | McpOAuthReauthorizationRequired> => |
| 251 | 255 | Effect.gen(function* () { |
| 252 | − const client = createClient(); | |
| 256 | + const client = createClient(input.versionNegotiation); | |
| 253 | 257 | const transportInstance = input.createTransport(); |
| 254 | 258 | |
| 255 | 259 | yield* Effect.tryPromise({ |
| @@ -314,8 +318,12 @@ export const createMcpConnector = (input: ConnectorInput): McpConnector => { · 58 unmodified lines | ||
| 314 | 318 | |
| 315 | 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 | 324 | const connectStreamableHttp = connectClient({ |
| 318 | 325 | transport: "streamable-http", |
| 326 | + versionNegotiation: { mode: "auto" }, | |
| 319 | 327 | createTransport: () => |
| 320 | 328 | new StreamableHTTPClientTransport(endpoint, { |
| 321 | 329 | requestInit, |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | 2 | 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"; | |
| 5 | 5 | |
| 6 | 6 | import { |
| 7 | 7 | AuthTemplateSlug, |
| @@ -226,7 +226,7 @@ describe("MCP elicitation (end-to-end)", () => { · 218 unmodified lines | ||
| 226 | 226 | ]), |
| 227 | 227 | ); |
| 228 | 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 | 231 | const result = yield* executor.execute( |
| 232 | 232 | simpleEcho.address, |
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | +import { InsufficientScopeError, SdkErrorCode, SdkHttpError } from "@modelcontextprotocol/client"; | |
| 2 | 3 | |
| 3 | 4 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors |
| 4 | 5 | import { insufficientScopeFromCause } from "./http-status"; |
| @@ -9,7 +10,8 @@ import { insufficientScopeFromCause } from "./http-status"; · 4 unmodified lines | ||
| 9 | 10 | // - with an authProvider (the production OAuth path): the StreamableHTTP |
| 10 | 11 | // transport consumes the insufficient_scope challenge itself, retries |
| 11 | 12 | // 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. | |
| 13 | 15 | describe("insufficientScopeFromCause", () => { |
| 14 | 16 | it("detects the OAuth error body embedded in a transport message", () => { |
| 15 | 17 | expect( |
| @@ -31,9 +33,21 @@ describe("insufficientScopeFromCause", () => { · 15 unmodified lines | ||
| 31 | 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 | 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 | 51 | ).toBe(true); |
| 38 | 52 | }); |
| 39 | 53 | |
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | // --------------------------------------------------------------------------- |
| 2 | 2 | // 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. | |
| 5 | 6 | // Shared by the invoke path (classifies tool-call failures) and the connect |
| 6 | 7 | // path (so a 401/403 during the handshake reaches the liveness health check). |
| 7 | 8 | // --------------------------------------------------------------------------- |
| @@ -9,13 +10,13 @@ · 1 unmodified line | ||
| 9 | 10 | import { Option, Schema } from "effect"; |
| 10 | 11 | |
| 11 | 12 | import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; |
| 12 | −import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | |
| 13 | +import { InsufficientScopeError, SdkHttpError, SseError } from "@modelcontextprotocol/client"; | |
| 13 | 14 | |
| 14 | 15 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); |
| 15 | 16 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); |
| 16 | 17 | |
| 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). | |
| 19 | 20 | const statusFromSsePostError = (cause: unknown): number | undefined => |
| 20 | 21 | Option.match(decodeSsePostErrorCause(cause), { |
| 21 | 22 | onNone: () => undefined, |
| @@ -26,32 +27,36 @@ const statusFromSsePostError = (cause: unknown): number | undefined => · 4 unmodified lines | ||
| 26 | 27 | }, |
| 27 | 28 | }); |
| 28 | 29 | |
| 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; | |
| 34 | 37 | }; |
| 35 | 38 | |
| 36 | 39 | export const httpStatusFromCause = (cause: unknown): number | undefined => |
| 37 | − statusFromStreamableHttpError(cause) ?? statusFromSsePostError(cause); | |
| 40 | + statusFromTypedTransportError(cause) ?? statusFromSsePostError(cause); | |
| 38 | 41 | |
| 39 | 42 | // The SDK embeds the upstream response text in the transport error message |
| 40 | 43 | // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body |
| 41 | 44 | // 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 | |
| 48 | 51 | // (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\)$/; | |
| 51 | 55 | |
| 52 | 56 | export const insufficientScopeFromCause = (cause: unknown): boolean => |
| 57 | + InsufficientScopeError.isInstance(cause) || | |
| 53 | 58 | Option.match(decodeSsePostErrorCause(cause), { |
| 54 | 59 | onNone: () => false, |
| 55 | 60 | onSome: ({ message }) => |
| 56 | − insufficientScopeFromEmbeddedJson(message) || SDK_UPSCOPING_EXHAUSTED_RE.test(message), | |
| 61 | + insufficientScopeFromEmbeddedJson(message) || SDK_STEP_UP_EXHAUSTED_RE.test(message), | |
| 57 | 62 | }); |
| @@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | ||
| 2 | 2 | import { Effect, Predicate } from "effect"; |
| 3 | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | 4 | |
| 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"; | |
| 8 | 11 | import { ElicitationResponse } from "@executor-js/sdk"; |
| 9 | 12 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 10 | 13 | |
| @@ -108,14 +111,16 @@ const invocationRejectionCases = [ · 97 unmodified lines | ||
| 108 | 111 | name: "wraps callTool rejection with a stable message and status", |
| 109 | 112 | toolId: "blocked", |
| 110 | 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 | 117 | expectedStatus: 401 as number | undefined, |
| 113 | 118 | }, |
| 114 | 119 | { |
| 115 | 120 | name: "does not treat MCP protocol error codes as HTTP statuses", |
| 116 | 121 | toolId: "protocol_error", |
| 117 | 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 | 124 | expectedStatus: undefined, |
| 120 | 125 | }, |
| 121 | 126 | { |
| @@ -8,7 +8,7 @@ · 7 unmodified lines | ||
| 8 | 8 | // legitimately retain state in that session. The pool keeps one idle |
| 9 | 9 | // connection per resolved identity and leases it exclusively per invoke; |
| 10 | 10 | // 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 | |
| 12 | 12 | // MCP's elicit capability into the host's elicit function threaded via |
| 13 | 13 | // `InvokeToolInput.elicit`. |
| 14 | 14 | // 3. Calling `client.callTool({ name, arguments })`. |
| @@ -16,12 +16,7 @@ · 1 unmodified line | ||
| 16 | 16 | |
| 17 | 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; |
| 18 | 18 | |
| 19 | −import { | |
| 20 | − ElicitRequestSchema, | |
| 21 | − ErrorCode, | |
| 22 | − McpError, | |
| 23 | − ToolListChangedNotificationSchema, | |
| 24 | −} from "@modelcontextprotocol/sdk/types.js"; | |
| 19 | +import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; | |
| 25 | 20 | |
| 26 | 21 | import { |
| 27 | 22 | ElicitationId, |
| @@ -66,9 +61,10 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines | ||
| 66 | 61 | |
| 67 | 62 | const isUnknownToolCause = (cause: unknown, toolName: string): boolean => |
| 68 | 63 | // 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 | |
| 72 | 68 | isUnknownToolMessage(cause.message, toolName); |
| 73 | 69 | |
| 74 | 70 | // --------------------------------------------------------------------------- |
| @@ -93,6 +89,17 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines | ||
| 93 | 89 | type McpElicitParams = typeof McpElicitParams.Type; |
| 94 | 90 | |
| 95 | 91 | 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 | +); | |
| 96 | 103 | |
| 97 | 104 | const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => |
| 98 | 105 | params.mode === "url" |
| @@ -107,7 +114,7 @@ const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => · 8 unmodified lines | ||
| 107 | 114 | }); |
| 108 | 115 | |
| 109 | 116 | 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 }) => { | |
| 111 | 118 | const params = decodeElicitParams(request.params); |
| 112 | 119 | const req = toElicitationRequest(params); |
| 113 | 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 | 126 | const response = exit.value; |
| 120 | 127 | return { |
| 121 | 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 | 134 | const failure = exit.cause.reasons.find(Cause.isFailReason); |
| @@ -149,7 +158,7 @@ const installToolListChangedHandler = ( · 23 unmodified lines | ||
| 149 | 158 | onToolListChanged: (() => void) | undefined, |
| 150 | 159 | ): void => { |
| 151 | 160 | if (!onToolListChanged) return; |
| 152 | − client.setNotificationHandler(ToolListChangedNotificationSchema, () => { | |
| 161 | + client.setNotificationHandler("notifications/tools/list_changed", () => { | |
| 153 | 162 | onToolListChanged(); |
| 154 | 163 | }); |
| 155 | 164 | }; |
| @@ -189,8 +198,8 @@ const useConnection = ( · 33 unmodified lines | ||
| 189 | 198 | }); |
| 190 | 199 | } |
| 191 | 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 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; | |
| 194 | 203 | return new McpInvocationError({ |
| 195 | 204 | toolName, |
| 196 | 205 | message: `MCP tool call failed for ${toolName}`, |
| @@ -55,12 +55,20 @@ const jsonRpcResult = (request: JsonRpcRequest, result: unknown) => · 54 unmodified lines | ||
| 55 | 55 | result, |
| 56 | 56 | }); |
| 57 | 57 | |
| 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 | + | |
| 58 | 65 | // The call-tool fixtures share one JSON-RPC scaffold (handshake, tool listing, |
| 59 | 66 | // unknown-method rejection); only the `tools/call` response varies. Each |
| 60 | 67 | // scenario supplies that branch via a `CallToolResponder`. |
| 61 | 68 | type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType<typeof HttpServerResponse.text>; |
| 62 | 69 | |
| 63 | 70 | const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponder) => { |
| 71 | + if (rpc.method === "server/discover") return jsonRpcMethodNotFound(rpc); | |
| 64 | 72 | if (rpc.method === "initialize") { |
| 65 | 73 | return jsonRpcResult(rpc, { |
| 66 | 74 | protocolVersion: "2025-06-18", |
| @@ -1,9 +1,8 @@ | ||
| 1 | 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; |
| 2 | 2 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | 3 | |
| 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"; | |
| 7 | 6 | |
| 8 | 7 | import { |
| 9 | 8 | authToolFailure, |
| @@ -392,7 +391,7 @@ type JsonSchemaObject = Record<string, unknown> & { · 382 unmodified lines | ||
| 392 | 391 | readonly properties?: Record<string, unknown>; |
| 393 | 392 | }; |
| 394 | 393 | |
| 395 | −const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; | |
| 394 | +const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); | |
| 396 | 395 | |
| 397 | 396 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { |
| 398 | 397 | const defaultStructuredContentSchema = |
| @@ -493,7 +492,9 @@ export const userFacingProbeMessage = ( · 94 unmodified lines | ||
| 493 | 492 | // MCP-SDK OAuth provider adapter — wraps a pre-resolved access token so the |
| 494 | 493 | // transport sends it as a Bearer header. Refresh is core's responsibility |
| 495 | 494 | // (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 | |
| 499 | 500 | const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ |
| @@ -1,6 +1,12 @@ | ||
| 1 | 1 | 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"; | |
| 4 | 10 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 5 | 11 | |
| 6 | 12 | import { 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 | 414 | it.effect("rejects 2xx with HTML body as wrong-shape", () => |
| 324 | 415 | withServer( |
| 325 | 416 | () => |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | ||
| 13 | 13 | // and (b) plenty of real MCP servers authenticate with static API |
| 14 | 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 accepts | |
| 16 | +// The primary probe issues an unauth JSON-RPC `initialize` POST and accepts | |
| 17 | 17 | // only the wire shapes a real MCP server can return: |
| 18 | 18 | // |
| 19 | 19 | // - 2xx with `Content-Type: text/event-stream` — streamable HTTP |
| @@ -30,8 +30,14 @@ · 10 unmodified lines | ||
| 30 | 30 | // only accepts 2xx with `text/event-stream` or the same 401+Bearer |
| 31 | 31 | // shape. |
| 32 | 32 | // |
| 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. | |
| 35 | 41 | // --------------------------------------------------------------------------- |
| 36 | 42 | |
| 37 | 43 | import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; |
| @@ -48,12 +54,21 @@ const INITIALIZE_BODY = JSON.stringify({ · 10 unmodified lines | ||
| 48 | 54 | id: 1, |
| 49 | 55 | method: "initialize", |
| 50 | 56 | params: { |
| 51 | − protocolVersion: "2025-06-18", | |
| 57 | + protocolVersion: "2025-11-25", | |
| 52 | 58 | capabilities: {}, |
| 53 | 59 | clientInfo: { name: "executor-probe", version: "0" }, |
| 54 | 60 | }, |
| 55 | 61 | }); |
| 56 | 62 | |
| 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 | + | |
| 57 | 72 | /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s |
| 58 | 73 | * `Response.headers` already lower-cases, but we normalise explicitly |
| 59 | 74 | * to stay robust against test mocks that construct `Headers` loosely. */ |
| @@ -385,10 +400,9 @@ export const probeMcpEndpointShape = ( · 325 unmodified lines | ||
| 385 | 400 | .execute(postRequest) |
| 386 | 401 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); |
| 387 | 402 | |
| 388 | − const postResult = yield* classify(postResponse, "POST"); | |
| 389 | − if (postResult) return postResult; | |
| 403 | + let initializeResult = yield* classify(postResponse, "POST"); | |
| 390 | 404 | |
| 391 | − if ([404, 405, 406, 415].includes(postResponse.status)) { | |
| 405 | + if (initializeResult === null && [404, 405, 406, 415].includes(postResponse.status)) { | |
| 392 | 406 | let getRequest = HttpClientRequest.get(url.toString()).pipe( |
| 393 | 407 | HttpClientRequest.setHeader("accept", "text/event-stream"), |
| 394 | 408 | ); |
| @@ -398,15 +412,42 @@ export const probeMcpEndpointShape = ( · 3 unmodified lines | ||
| 398 | 412 | const getResponse = yield* client |
| 399 | 413 | .execute(getRequest) |
| 400 | 414 | .pipe(Effect.timeout(Duration.millis(timeoutMs))); |
| 401 | − const getResult = yield* classify(getResponse, "GET"); | |
| 402 | − if (getResult) return getResult; | |
| 415 | + initializeResult = yield* classify(getResponse, "GET"); | |
| 403 | 416 | } |
| 404 | 417 | |
| 405 | − return { | |
| 418 | + initializeResult ??= { | |
| 406 | 419 | kind: "not-mcp", |
| 407 | 420 | category: "wrong-shape", |
| 408 | 421 | reason: `unexpected status ${postResponse.status} for initialize`, |
| 409 | 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 | 451 | }).pipe( |
| 411 | 452 | Effect.provide(options.httpClientLayer ?? FetchHttpClient.layer), |
| 412 | 453 | Effect.mapError( |
| @@ -3,8 +3,9 @@ · 2 unmodified lines | ||
| 3 | 3 | // --------------------------------------------------------------------------- |
| 4 | 4 | // |
| 5 | 5 | // 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` | |
| 8 | 9 | // that crashes workerd at module instantiation with SIGSEGV (prod bundles |
| 9 | 10 | // tree-shake it away when `dangerouslyAllowStdioMCP: false`, tests do not). |
| 10 | 11 | // |
| @@ -13,7 +14,7 @@ · 2 unmodified lines | ||
| 13 | 14 | // the import and therefore never touch `node:child_process`. |
| 14 | 15 | // --------------------------------------------------------------------------- |
| 15 | 16 | |
| 16 | −import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | |
| 17 | +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; | |
| 17 | 18 | |
| 18 | 19 | export type StdioTransportConfig = { |
| 19 | 20 | readonly command: string; |
| @@ -1,7 +1,6 @@ | ||
| 1 | 1 | import { expect, layer } from "@effect/vitest"; |
| 2 | 2 | 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"; | |
| 5 | 4 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 6 | 5 | |
| 7 | 6 | import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; |
| @@ -16,7 +15,10 @@ const createGreetingMcpServer = () => · 8 unmodified lines | ||
| 16 | 15 | }); |
| 17 | 16 | |
| 18 | 17 | 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 | + ); | |
| 20 | 22 | const transport = new StreamableHTTPClientTransport(new URL(endpoint), { |
| 21 | 23 | requestInit: { |
| 22 | 24 | headers: { authorization: `Bearer ${accessToken}` }, |
| @@ -47,7 +49,9 @@ layer(OAuthTestServer.layer(), { timeout: "15 seconds" })("MCP testing fixtures" · 24 unmodified lines | ||
| 47 | 49 | expect(result).toMatchObject({ |
| 48 | 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 | 56 | const requests = yield* server.requests; |
| 53 | 57 | expect( |