Unified · Split
| @@ -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=="], |
| @@ -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 | }, |
| @@ -182,6 +182,13 @@ const decodeJsonRpcRequest = Schema.decodeUnknownOption(Schema.fromJsonString(Js · 181 unmodified lines | |||
| 182 | const jsonRpcResult = (request: JsonRpcRequest, result: unknown) => | 182 | const 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 | ||
| 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 | const pageTool = (name: string) => ({ | 192 | const 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", |
| @@ -7,6 +7,10 @@ import { createMcpConnectionPool } from "./connection-pool"; · 6 unmodified lines | |||
| 7 | import { invokeMcpTool } from "./invoke"; | 7 | import { invokeMcpTool } from "./invoke"; |
| 8 | import { makeEchoMcpServer, serveMcpServer } from "../testing"; | 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 | const acceptAll: Elicit = () => | 14 | const 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 | ), |
| @@ -3,6 +3,10 @@ import { Cause, Effect, Exit, Predicate } from "effect"; · 2 unmodified lines | |||
| 3 | import type { McpConnection, McpConnector } from "./connection"; | 3 | import type { McpConnection, McpConnector } from "./connection"; |
| 4 | import type { McpInvocationError } from "./errors"; | 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 | const IDLE_TTL_MS = 5 * 60 * 1_000; | 10 | const IDLE_TTL_MS = 5 * 60 * 1_000; |
| 7 | 11 | ||
| 8 | type IdleConnection = { | 12 | type IdleConnection = { |
| @@ -1,16 +1,18 @@ | |||
| 1 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 1 | import { |
| 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 2 | Client, |
| 3 | import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; | 3 | SSEClientTransport, |
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 4 | StreamableHTTPClientTransport, |
| 5 | import type { FetchLike } from "@modelcontextprotocol/sdk/shared/transport.js"; | 5 | type FetchLike, |
| 6 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; | 6 | type OAuthClientProvider, |
| 7 | } from "@modelcontextprotocol/client"; | ||
| 8 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; | ||
| 7 | import { Effect, Layer, Predicate, Stream } from "effect"; | 9 | import { Effect, Layer, Predicate, Stream } from "effect"; |
| 8 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 10 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 9 | 11 | ||
| 10 | // NOTE: `StdioClientTransport` is NOT imported eagerly. The upstream module | 12 | // 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) at | 14 | // `cross-spawn` eagerly at evaluation time, which crashes workerd (including |
| 13 | // SIGSEGV on module instantiation. Cloud callers set | 15 | // 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 the | 17 | // 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). The | 203 | // MCP plugin runs inside a Cloudflare Worker (executor.sh). The |
| 202 | // cfworker validator does not use code generation and works in every | 204 | // cfworker validator does not use code generation and works in every |
| 203 | // runtime we ship to. | 205 | // runtime we ship to. |
| 204 | const createClient = (): Client => | 206 | const 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 | |||
| 247 | const connectClient = (input: { | 250 | const 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, |
| @@ -1,7 +1,7 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Predicate, Schema, Semaphore } from "effect"; | 2 | import { Effect, Predicate, Schema, Semaphore } from "effect"; |
| 3 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker"; | 3 | import type { JsonSchemaType } from "@modelcontextprotocol/client"; |
| 4 | import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; | 4 | import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/client/validators/cf-worker"; |
| 5 | 5 | ||
| 6 | import { | 6 | import { |
| 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, |
| @@ -1,4 +1,5 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { 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 Errors | 4 | // oxlint-disable executor/no-error-constructor -- boundary: these tests reproduce the MCP SDK's own transport rejections, which are built-in Errors |
| 4 | import { insufficientScopeFromCause } from "./http-status"; | 5 | import { 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 StreamableHTTP | 10 | // - with an authProvider (the production OAuth path): the StreamableHTTP |
| 10 | // transport consumes the insufficient_scope challenge itself, retries | 11 | // transport consumes the insufficient_scope challenge itself, retries |
| 11 | // with the broader scope, and only when THAT fails throws the fixed | 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 | describe("insufficientScopeFromCause", () => { | 15 | describe("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 | ||
| @@ -1,7 +1,8 @@ | |||
| 1 | // --------------------------------------------------------------------------- | 1 | // --------------------------------------------------------------------------- |
| 2 | // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces | 2 | // Extract the HTTP status from an MCP SDK transport error. The SDK surfaces |
| 3 | // transport failures two ways: a `StreamableHTTPError` subclass carrying a | 3 | // 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 connect | 6 | // 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 | |||
| 9 | import { Option, Schema } from "effect"; | 10 | import { Option, Schema } from "effect"; |
| 10 | 11 | ||
| 11 | import { insufficientScopeFromEmbeddedJson } from "@executor-js/sdk/core"; | 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 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); | 15 | const SsePostErrorCause = Schema.Struct({ message: Schema.String }); |
| 15 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); | 16 | const decodeSsePostErrorCause = Schema.decodeUnknownOption(SsePostErrorCause); |
| 16 | 17 | ||
| 17 | // Matches the SDK's SSEClientTransport POST-failure message (sse.js); re-verify | 18 | // 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). |
| 19 | const statusFromSsePostError = (cause: unknown): number | undefined => | 20 | const 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 | ||
| 29 | const statusFromStreamableHttpError = (cause: unknown): number | undefined => { | 30 | const 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 code | 31 | 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 | ||
| 36 | export const httpStatusFromCause = (cause: unknown): number | undefined => | 39 | export 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 message | 42 | // 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 body | 43 | // ("Error POSTing to endpoint: <body>"), which is the only place a 403's body |
| 41 | // survives for connections without an authProvider. For OAuth connections the | 44 | // 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 upscoped | 46 | // V2 throws `InsufficientScopeError` when configured not to reauthorize; after |
| 44 | // retry still 403s does it throw — with the fixed message matched below | 47 | // exhausting its step-up retries it throws `SdkHttpError` with the exact fixed |
| 45 | // (verified against @modelcontextprotocol/sdk streamableHttp.js; re-verify on | 48 | // message matched below (verified against the installed v2 transport source). |
| 46 | // SDK bumps). Both paths mean the same thing: the grant does not cover the | 49 | // 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 matching | 50 | // re-running the identical flow cannot help. Strict matching |
| 48 | // (exact serialized field forms via the shared core detector, or the SDK's | 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. | 52 | // exact step-up message) — a miss stays on the generic auth path. |
| 50 | const SDK_UPSCOPING_EXHAUSTED_RE = /Server returned 403 after trying upscoping/; | 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 | export const insufficientScopeFromCause = (cause: unknown): boolean => | 56 | export 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 | }); |
| @@ -2,9 +2,12 @@ import { describe, expect, it } from "@effect/vitest"; · 1 unmodified line | |||
| 2 | import { Effect, Predicate } from "effect"; | 2 | import { Effect, Predicate } from "effect"; |
| 3 | import { HttpServerResponse } from "effect/unstable/http"; | 3 | import { HttpServerResponse } from "effect/unstable/http"; |
| 4 | 4 | ||
| 5 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 5 | import { |
| 6 | import { StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | 6 | ProtocolError, |
| 7 | import { McpError } from "@modelcontextprotocol/sdk/types.js"; | 7 | SdkErrorCode, |
| 8 | SdkHttpError, | ||
| 9 | type OAuthClientProvider, | ||
| 10 | } from "@modelcontextprotocol/client"; | ||
| 8 | import { ElicitationResponse } from "@executor-js/sdk"; | 11 | import { ElicitationResponse } from "@executor-js/sdk"; |
| 9 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; | 12 | import { 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 | { |
| @@ -8,7 +8,7 @@ · 7 unmodified lines | |||
| 8 | // legitimately retain state in that session. The pool keeps one idle | 8 | // 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 bridges | 11 | // 2. Installing a per-invocation `elicitation/create` handler that bridges |
| 12 | // MCP's elicit capability into the host's elicit function threaded via | 12 | // 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 | ||
| 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; | 17 | import { Cause, Effect, Exit, Option, Predicate, Schema } from "effect"; |
| 18 | 18 | ||
| 19 | import { | 19 | import { ProtocolError, ProtocolErrorCode } from "@modelcontextprotocol/client"; |
| 20 | ElicitRequestSchema, | ||
| 21 | ErrorCode, | ||
| 22 | McpError, | ||
| 23 | ToolListChangedNotificationSchema, | ||
| 24 | } from "@modelcontextprotocol/sdk/types.js"; | ||
| 25 | 20 | ||
| 26 | import { | 21 | import { |
| 27 | ElicitationId, | 22 | ElicitationId, |
| @@ -66,9 +61,10 @@ export const isUnknownToolMessage = (message: string, toolName: string): boolean · 38 unmodified lines | |||
| 66 | 61 | ||
| 67 | const isUnknownToolCause = (cause: unknown, toolName: string): boolean => | 62 | const 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 subclass | 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 && | 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 provides | 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 | isUnknownToolMessage(cause.message, toolName); | 68 | isUnknownToolMessage(cause.message, toolName); |
| 73 | 69 | ||
| 74 | // --------------------------------------------------------------------------- | 70 | // --------------------------------------------------------------------------- |
| @@ -93,6 +89,17 @@ const McpElicitParams = Schema.Union([ · 18 unmodified lines | |||
| 93 | type McpElicitParams = typeof McpElicitParams.Type; | 89 | type McpElicitParams = typeof McpElicitParams.Type; |
| 94 | 90 | ||
| 95 | const decodeElicitParams = Schema.decodeUnknownSync(McpElicitParams); | 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 | const toElicitationRequest = (params: McpElicitParams): ElicitationRequest => | 104 | const 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 | ||
| 109 | const installElicitationHandler = (client: McpConnection["client"], elicit: Elicit): void => { | 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 | 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 shapes | 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 |
| 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}`, |
| @@ -55,12 +55,20 @@ const jsonRpcResult = (request: JsonRpcRequest, result: unknown) => · 54 unmodified lines | |||
| 55 | result, | 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 | // 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. Each | 66 | // 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`. |
| 61 | type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType<typeof HttpServerResponse.text>; | 68 | type CallToolResponder = (rpc: JsonRpcRequest) => ReturnType<typeof HttpServerResponse.text>; |
| 62 | 69 | ||
| 63 | const callToolFixtureResponse = (rpc: JsonRpcRequest, callTool: CallToolResponder) => { | 70 | const 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", |
| @@ -1,9 +1,8 @@ | |||
| 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; | 1 | import { Effect, Layer, Option, Result, Schema } from "effect"; |
| 2 | import type { HttpClient } from "effect/unstable/http"; | 2 | import type { HttpClient } from "effect/unstable/http"; |
| 3 | 3 | ||
| 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"; | 4 | import type { OAuthClientProvider } from "@modelcontextprotocol/client"; |
| 5 | import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js"; | 5 | import { CallToolResultSchema } from "@modelcontextprotocol/core"; |
| 6 | import * as z from "zod/v4"; | ||
| 7 | 6 | ||
| 8 | import { | 7 | import { |
| 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 | ||
| 395 | const McpCallToolResultJsonSchema = z.toJSONSchema(CallToolResultSchema) as JsonSchemaObject; | 394 | const McpCallToolResultJsonSchema: JsonSchemaObject = CallToolResultSchema.toJSONSchema(); |
| 396 | 395 | ||
| 397 | const mcpCallToolResultOutputSchema = (structuredContentSchema?: unknown): JsonSchemaObject => { | 396 | const 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 the | 492 | // 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 responsibility | 493 | // transport sends it as a Bearer header. Refresh is core's responsibility |
| 495 | // (the connection row carries the OAuth grant); this adapter never initiates | 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 | const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ | 500 | const makeOAuthProvider = (accessToken: string): OAuthClientProvider => ({ |
| @@ -1,6 +1,12 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect, Ref } from "effect"; | 2 | import { Effect, Layer, Predicate, Ref } from "effect"; |
| 3 | import { HttpServerResponse } from "effect/unstable/http"; | 3 | import { |
| 4 | HttpClient, | ||
| 5 | HttpClientError, | ||
| 6 | HttpClientRequest, | ||
| 7 | HttpClientResponse, | ||
| 8 | HttpServerResponse, | ||
| 9 | } from "effect/unstable/http"; | ||
| 4 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; | 10 | import { serveTestHttpApp } from "@executor-js/sdk/testing"; |
| 5 | 11 | ||
| 6 | import { probeMcpEndpointShape } from "./probe-shape"; | 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 | 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 | () => |
| @@ -13,7 +13,7 @@ · 12 unmodified lines | |||
| 13 | // and (b) plenty of real MCP servers authenticate with static API | 13 | // 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 accepts | 16 | // 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 HTTP | 19 | // - 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+Bearer | 30 | // 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 OAuth | 33 | // 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 | ||
| 37 | import { Data, Duration, Effect, Layer, Option, Schema } from "effect"; | 43 | import { 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 | ||
| 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 | /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s | 72 | /** Header-name lookup is case-insensitive per RFC 7230. `fetch`'s |
| 58 | * `Response.headers` already lower-cases, but we normalise explicitly | 73 | * `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* client | 412 | 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( |
| @@ -3,8 +3,9 @@ · 2 unmodified lines | |||
| 3 | // --------------------------------------------------------------------------- | 3 | // --------------------------------------------------------------------------- |
| 4 | // | 4 | // |
| 5 | // Kept in its own module so `connection.ts` never imports it eagerly at | 5 | // Kept in its own module so `connection.ts` never imports it eagerly at |
| 6 | // module load. `@modelcontextprotocol/sdk/client/stdio.js` pulls in | 6 | // 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 bundles | 9 | // 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 | ||
| 16 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | 17 | import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; |
| 17 | 18 | ||
| 18 | export type StdioTransportConfig = { | 19 | export type StdioTransportConfig = { |
| 19 | readonly command: string; | 20 | readonly command: string; |
| @@ -1,7 +1,6 @@ | |||
| 1 | import { expect, layer } from "@effect/vitest"; | 1 | import { expect, layer } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | 3 | import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client"; |
| 4 | import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; | ||
| 5 | import { OAuthTestServer } from "@executor-js/sdk/testing"; | 4 | import { OAuthTestServer } from "@executor-js/sdk/testing"; |
| 6 | 5 | ||
| 7 | import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; | 6 | import { makeEchoMcpServer, serveMcpServerWithOAuth } from "../testing"; |
| @@ -16,7 +15,10 @@ const createGreetingMcpServer = () => · 8 unmodified lines | |||
| 16 | }); | 15 | }); |
| 17 | 16 | ||
| 18 | const makeClient = (endpoint: string, accessToken: string) => { | 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 | 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( |