* answer unsupported discovery before mcp initialization * Answer unknown pre-initialize MCP methods with -32601 in the daemon Only initialize can open a session, so the streamable-HTTP transport rejected every other pre-session method with HTTP 400 + -32000 Server not initialized. Clients read that 400 as a transport failure and dropped the connection, so a client opening with an optional server/discover probe never got to fall back to initialize; over `executor mcp` it closed the bridge outright. Pre-session dispatch now answers any method other than initialize with -32601 Method not found on a 200. Done in the shared host-mcp package and wired into both session-creating sites, so the CLI bridge needs no shim. * Narrow the pre-initialize guard to requests the transport would accept The guard intercepted any POST body whose method was not initialize. That answered 200 before the streamable-HTTP transport could run its own checks, so a POST with a wrong Content-Type or an incomplete Accept got a success instead of the transport 415/406, and a structurally invalid JSON-RPC message (a fractional id, a non-object params, an unknown top-level field) got -32601 instead of the transport parse error. It now fires only where -32601 is the whole story: a POST that clears the transport content negotiation and carries a valid JSON-RPC 2.0 request. Validity comes from the SDK isJSONRPCRequest predicate, the same one behind the transport JSONRPCMessageSchema.parse, so the two cannot drift. Tests cover the real caller paths: the in-memory session store and the local app handler both answer -32601 for a valid probe, and pass a notification, a malformed id, a wrong Content-Type, and an incomplete Accept to the transport. ---------
Unified · Split
| @@ -0,0 +1,10 @@ | |||
| 1 | --- | ||
| 2 | "@executor-js/host-mcp": patch | ||
| 3 | "@executor-js/local": patch | ||
| 4 | --- | ||
| 5 | |||
| 6 | **An unsupported method probed before `initialize` no longer kills the MCP connection** | ||
| 7 | |||
| 8 | Only `initialize` can open a session, so the streamable-HTTP transport answered every other pre-session method with HTTP 400 + `-32000 Server not initialized`. A 400 is a transport-level failure, so clients dropped the connection instead of treating it as one request failing — a client that opens with an optional probe (MCP 2026-07-28 clients lead with `server/discover`) was disconnected before it could fall back to `initialize`. Over `executor mcp`, which bridges this endpoint to stdio, that closed the client's pipe outright. | ||
| 9 | |||
| 10 | Pre-session dispatch now answers any method other than `initialize` with `-32601 Method not found` on a normal 200, which is a per-request error, so the connection survives and the handshake proceeds. This replaces only that one answer: a POST with a bad `Accept` or `Content-Type` still gets the transport's 406 or 415, and a message that is not a valid JSON-RPC request still gets its parse error. | ||
| @@ -0,0 +1,110 @@ | |||
| 1 | // --------------------------------------------------------------------------- | ||
| 2 | // Local app × the pre-initialize dispatch guard — through the real handler | ||
| 3 | // --------------------------------------------------------------------------- | ||
| 4 | // | ||
| 5 | // `executor mcp` bridges a stdio client to this handler's `/mcp` endpoint, so | ||
| 6 | // what the handler answers on a session-less POST decides whether the client's | ||
| 7 | // connection survives its first probe: | ||
| 8 | // | ||
| 9 | // test → createMcpRequestHandler().handleRequest(Request) | ||
| 10 | // → pre-initialize guard (an unknown method -> -32601 on a 200) | ||
| 11 | // → WebStandardStreamableHTTPServerTransport (everything else) | ||
| 12 | // | ||
| 13 | // The guard replaces exactly ONE transport answer — the connection-killing | ||
| 14 | // `400 -32000 Server not initialized` for a method that is not `initialize`. | ||
| 15 | // These assert that replacement AND that no other transport answer is shadowed: | ||
| 16 | // a request that fails content negotiation still gets the transport's 415/406, | ||
| 17 | // and a structurally invalid JSON-RPC message still gets its parse error, not a | ||
| 18 | // "method not found" that was never true. | ||
| 19 | // --------------------------------------------------------------------------- | ||
| 20 | |||
| 21 | import { describe, expect, it } from "@effect/vitest"; | ||
| 22 | import { Effect } from "effect"; | ||
| 23 | |||
| 24 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 25 | |||
| 26 | import { createMcpRequestHandler } from "./mcp"; | ||
| 27 | |||
| 28 | /** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */ | ||
| 29 | const MCP_POST_HEADERS = { | ||
| 30 | "content-type": "application/json", | ||
| 31 | accept: "application/json, text/event-stream", | ||
| 32 | } as const; | ||
| 33 | |||
| 34 | /** No code ever runs here: these requests are answered before any tool call. */ | ||
| 35 | const stubEngine: ExecutionEngine<never> = { | ||
| 36 | execute: () => Effect.succeed({ result: "unused" }), | ||
| 37 | executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "unused" } }), | ||
| 38 | resume: () => Effect.succeed(null), | ||
| 39 | getPausedExecution: () => Effect.succeed(null), | ||
| 40 | pausedExecutionCount: () => Effect.succeed(0), | ||
| 41 | hasPausedExecutions: () => Effect.succeed(false), | ||
| 42 | getDescription: Effect.succeed("test executor"), | ||
| 43 | shutdown: Effect.void, | ||
| 44 | }; | ||
| 45 | |||
| 46 | const post = ( | ||
| 47 | body: unknown, | ||
| 48 | headers: Record<string, string> = MCP_POST_HEADERS, | ||
| 49 | ): Promise<Response> => | ||
| 50 | createMcpRequestHandler({ engine: stubEngine }).handleRequest( | ||
| 51 | new Request("http://local.test/mcp", { method: "POST", headers, body: JSON.stringify(body) }), | ||
| 52 | ); | ||
| 53 | |||
| 54 | interface JsonRpcErrorBody { | ||
| 55 | readonly error: { readonly code: number; readonly message: string }; | ||
| 56 | } | ||
| 57 | |||
| 58 | describe("local MCP handler, pre-initialize", () => { | ||
| 59 | it("answers a valid unknown pre-session method with -32601 on a 200", async () => { | ||
| 60 | const response = await post({ jsonrpc: "2.0", id: 7, method: "server/discover", params: {} }); | ||
| 61 | |||
| 62 | // 200, not the transport's 400: a per-request error the bridged client | ||
| 63 | // survives, so it can still fall back to `initialize`. | ||
| 64 | expect(response.status).toBe(200); | ||
| 65 | expect(await response.json()).toEqual({ | ||
| 66 | jsonrpc: "2.0", | ||
| 67 | id: 7, | ||
| 68 | error: { code: -32601, message: "Method not found" }, | ||
| 69 | }); | ||
| 70 | }); | ||
| 71 | |||
| 72 | it("passes a pre-session notification to the transport", async () => { | ||
| 73 | const response = await post({ jsonrpc: "2.0", method: "notifications/initialized" }); | ||
| 74 | |||
| 75 | // A notification carries no id, so the guard may not answer it at all; | ||
| 76 | // whatever comes back is the transport's own answer. | ||
| 77 | const body = (await response.json()) as JsonRpcErrorBody; | ||
| 78 | expect(body.error.code).not.toBe(-32601); | ||
| 79 | expect(body.error.code).toBe(-32000); | ||
| 80 | }); | ||
| 81 | |||
| 82 | it("leaves a structurally invalid request to the transport's parse error", async () => { | ||
| 83 | // A fractional id is not a JSON-RPC id, so this is not a request the guard | ||
| 84 | // may report an unknown method for. | ||
| 85 | const response = await post({ jsonrpc: "2.0", id: 1.5, method: "server/discover" }); | ||
| 86 | |||
| 87 | expect(response.status).toBe(400); | ||
| 88 | const body = (await response.json()) as JsonRpcErrorBody; | ||
| 89 | expect(body.error.code).toBe(-32700); | ||
| 90 | expect(body.error.code).not.toBe(-32601); | ||
| 91 | }); | ||
| 92 | |||
| 93 | it("leaves a wrong Content-Type to the transport's 415", async () => { | ||
| 94 | const response = await post( | ||
| 95 | { jsonrpc: "2.0", id: 1, method: "server/discover" }, | ||
| 96 | { "content-type": "text/plain", accept: MCP_POST_HEADERS.accept }, | ||
| 97 | ); | ||
| 98 | |||
| 99 | expect(response.status).toBe(415); | ||
| 100 | }); | ||
| 101 | |||
| 102 | it("leaves an incomplete Accept to the transport's 406", async () => { | ||
| 103 | const response = await post( | ||
| 104 | { jsonrpc: "2.0", id: 1, method: "server/discover" }, | ||
| 105 | { "content-type": "application/json", accept: "application/json" }, | ||
| 106 | ); | ||
| 107 | |||
| 108 | expect(response.status).toBe(406); | ||
| 109 | }); | ||
| 110 | }); | ||
| @@ -7,6 +7,7 @@ import { · 6 unmodified lines | |||
| 7 | defaultMcpResource, | 7 | defaultMcpResource, |
| 8 | jsonRpcErrorBody, | 8 | jsonRpcErrorBody, |
| 9 | mcpResourceKey, | 9 | mcpResourceKey, |
| 10 | preInitializeMethodNotFound, | ||
| 10 | type McpResource, | 11 | type McpResource, |
| 11 | } from "@executor-js/host-mcp"; | 12 | } from "@executor-js/host-mcp"; |
| 12 | import { | 13 | import { |
| @@ -181,6 +182,13 @@ export const createMcpRequestHandler = ( · 168 unmodified lines | |||
| 181 | return transport.handleRequest(request); | 182 | return transport.handleRequest(request); |
| 182 | } | 183 | } |
| 183 | 184 | ||
| 185 | // Pre-initialize dispatch: only `initialize` opens a session here, so a | ||
| 186 | // probe for anything else is answered -32601 instead of the transport's | ||
| 187 | // fatal 400. `executor mcp` bridges this endpoint to stdio, so that 400 | ||
| 188 | // would close the client's pipe before it could fall back to initialize. | ||
| 189 | const unsupported = await Effect.runPromise(preInitializeMethodNotFound(request)); | ||
| 190 | if (unsupported) return unsupported; | ||
| 191 | |||
| 184 | let created: McpServer | undefined; | 192 | let created: McpServer | undefined; |
| 185 | let createdSessionId: string | null = null; | 193 | let createdSessionId: string | null = null; |
| 186 | let resourceConfig: LocalMcpServerConfig | null = null; | 194 | let resourceConfig: LocalMcpServerConfig | null = null; |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | |||
| 22 | McpServingRoutes, | 22 | McpServingRoutes, |
| 23 | McpDiscoveryRoutes, | 23 | McpDiscoveryRoutes, |
| 24 | McpSessionStore, | 24 | McpSessionStore, |
| 25 | preInitializeMethodNotFound, | ||
| 25 | type McpResource, | 26 | type McpResource, |
| 26 | type McpDispatchResult, | 27 | type McpDispatchResult, |
| 27 | type Principal, | 28 | type Principal, |
| @@ -204,6 +205,145 @@ it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => · 176 unmodified lines | |||
| 204 | }); | 205 | }); |
| 205 | }); | 206 | }); |
| 206 | 207 | ||
| 208 | // --------------------------------------------------------------------------- | ||
| 209 | // The pre-initialize dispatch guard. Session-less, only `initialize` is servable, | ||
| 210 | // and the transport's answer for everything else is a connection-killing HTTP | ||
| 211 | // 400. These lock in the -32601-on-200 replacement and, just as importantly, | ||
| 212 | // everything it must NOT intercept. | ||
| 213 | // --------------------------------------------------------------------------- | ||
| 214 | |||
| 215 | /** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */ | ||
| 216 | const MCP_POST_HEADERS = { | ||
| 217 | "content-type": "application/json", | ||
| 218 | accept: "application/json, text/event-stream", | ||
| 219 | } as const; | ||
| 220 | |||
| 221 | const postBody = (body: unknown, headers: Record<string, string> = MCP_POST_HEADERS): Request => | ||
| 222 | new Request("https://host.test/mcp", { | ||
| 223 | method: "POST", | ||
| 224 | headers, | ||
| 225 | body: JSON.stringify(body), | ||
| 226 | }); | ||
| 227 | |||
| 228 | const guard = (request: Request): Promise<Response | null> => | ||
| 229 | Effect.runPromise(preInitializeMethodNotFound(request)); | ||
| 230 | |||
| 231 | describe("preInitializeMethodNotFound", () => { | ||
| 232 | it("answers an unknown pre-init method with -32601 on a 200, echoing the id", async () => { | ||
| 233 | const response = await guard( | ||
| 234 | postBody({ jsonrpc: "2.0", id: 7, method: "server/discover", params: {} }), | ||
| 235 | ); | ||
| 236 | expect(response).not.toBeNull(); | ||
| 237 | // 200, not 400: a per-request error the client can survive, which is the | ||
| 238 | // entire point — a 400 makes clients tear the transport down. | ||
| 239 | expect(response!.status).toBe(200); | ||
| 240 | expect(response!.headers.get("content-type")).toContain("application/json"); | ||
| 241 | expect(await response!.json()).toEqual({ | ||
| 242 | jsonrpc: "2.0", | ||
| 243 | id: 7, | ||
| 244 | error: { code: -32601, message: "Method not found" }, | ||
| 245 | }); | ||
| 246 | }); | ||
| 247 | |||
| 248 | it("generalizes past server/discover to any unknown method, including a string id", async () => { | ||
| 249 | const response = await guard( | ||
| 250 | postBody({ jsonrpc: "2.0", id: "abc", method: "some/futureProbe" }), | ||
| 251 | ); | ||
| 252 | expect(await response!.json()).toEqual({ | ||
| 253 | jsonrpc: "2.0", | ||
| 254 | id: "abc", | ||
| 255 | error: { code: -32601, message: "Method not found" }, | ||
| 256 | }); | ||
| 257 | }); | ||
| 258 | |||
| 259 | it("lets initialize through to the transport", async () => { | ||
| 260 | expect( | ||
| 261 | await guard(postBody({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })), | ||
| 262 | ).toBeNull(); | ||
| 263 | }); | ||
| 264 | |||
| 265 | it("lets a notification through — there is no id to answer", async () => { | ||
| 266 | expect( | ||
| 267 | await guard(postBody({ jsonrpc: "2.0", method: "notifications/initialized" })), | ||
| 268 | ).toBeNull(); | ||
| 269 | }); | ||
| 270 | |||
| 271 | it("lets non-POST, non-JSON, and non-JSON-RPC bodies through", async () => { | ||
| 272 | expect(await guard(new Request("https://host.test/mcp"))).toBeNull(); | ||
| 273 | expect( | ||
| 274 | await guard( | ||
| 275 | new Request("https://host.test/mcp", { | ||
| 276 | method: "POST", | ||
| 277 | headers: MCP_POST_HEADERS, | ||
| 278 | body: "not json", | ||
| 279 | }), | ||
| 280 | ), | ||
| 281 | ).toBeNull(); | ||
| 282 | expect(await guard(postBody({ id: 1, method: "tools/list" }))).toBeNull(); | ||
| 283 | expect(await guard(postBody([]))).toBeNull(); | ||
| 284 | }); | ||
| 285 | |||
| 286 | // The guard replaces ONE transport answer (-32000 on a 400) and must not | ||
| 287 | // shadow the others. A structurally invalid JSON-RPC request is the | ||
| 288 | // transport's 400 parse error to give, not ours to call "method not found". | ||
| 289 | it("lets a structurally invalid JSON-RPC request through to the transport", async () => { | ||
| 290 | // A fractional id is not a request id (the SDK's RequestIdSchema is | ||
| 291 | // string | integer), so the transport rejects the whole message. | ||
| 292 | expect(await guard(postBody({ jsonrpc: "2.0", id: 1.5, method: "tools/list" }))).toBeNull(); | ||
| 293 | // `params` must be an object when present. | ||
| 294 | expect( | ||
| 295 | await guard(postBody({ jsonrpc: "2.0", id: 1, method: "tools/list", params: 5 })), | ||
| 296 | ).toBeNull(); | ||
| 297 | // The request schema is strict: an unknown top-level field is invalid. | ||
| 298 | expect( | ||
| 299 | await guard(postBody({ jsonrpc: "2.0", id: 1, method: "tools/list", extra: true })), | ||
| 300 | ).toBeNull(); | ||
| 301 | // A wrong protocol version, and a batch, which the transport unpacks itself. | ||
| 302 | expect(await guard(postBody({ jsonrpc: "1.0", id: 1, method: "tools/list" }))).toBeNull(); | ||
| 303 | expect(await guard(postBody([{ jsonrpc: "2.0", id: 1, method: "tools/list" }]))).toBeNull(); | ||
| 304 | }); | ||
| 305 | |||
| 306 | // Answering 200 here would bypass the transport's content negotiation, which | ||
| 307 | // runs before it ever looks at the body. | ||
| 308 | it("lets a request that fails the transport's content negotiation through", async () => { | ||
| 309 | const valid = { jsonrpc: "2.0", id: 1, method: "server/discover" }; | ||
| 310 | // Content-Type is not application/json, or is absent -> the 415. | ||
| 311 | expect( | ||
| 312 | await guard( | ||
| 313 | postBody(valid, { "content-type": "text/plain", accept: MCP_POST_HEADERS.accept }), | ||
| 314 | ), | ||
| 315 | ).toBeNull(); | ||
| 316 | expect(await guard(postBody(valid, { accept: MCP_POST_HEADERS.accept }))).toBeNull(); | ||
| 317 | // Accept misses one of the two required types, or is absent -> the 406. | ||
| 318 | expect( | ||
| 319 | await guard(postBody(valid, { ...MCP_POST_HEADERS, accept: "application/json" })), | ||
| 320 | ).toBeNull(); | ||
| 321 | expect( | ||
| 322 | await guard(postBody(valid, { ...MCP_POST_HEADERS, accept: "text/event-stream" })), | ||
| 323 | ).toBeNull(); | ||
| 324 | expect(await guard(postBody(valid, { "content-type": "application/json" }))).toBeNull(); | ||
| 325 | }); | ||
| 326 | |||
| 327 | it("still fires when the negotiated headers carry parameters", async () => { | ||
| 328 | const response = await guard( | ||
| 329 | postBody( | ||
| 330 | { jsonrpc: "2.0", id: 3, method: "server/discover" }, | ||
| 331 | { | ||
| 332 | "content-type": "application/json; charset=utf-8", | ||
| 333 | accept: "application/json;q=0.9, text/event-stream;q=1.0", | ||
| 334 | }, | ||
| 335 | ), | ||
| 336 | ); | ||
| 337 | expect(response?.status).toBe(200); | ||
| 338 | }); | ||
| 339 | |||
| 340 | it("leaves the caller's body readable for the transport", async () => { | ||
| 341 | const request = postBody({ jsonrpc: "2.0", id: 1, method: "server/discover" }); | ||
| 342 | await guard(request); | ||
| 343 | expect(await request.json()).toEqual({ jsonrpc: "2.0", id: 1, method: "server/discover" }); | ||
| 344 | }); | ||
| 345 | }); | ||
| 346 | |||
| 207 | describe("McpDiscoveryRoutes (discovery-only, no session store)", () => { | 347 | describe("McpDiscoveryRoutes (discovery-only, no session store)", () => { |
| 208 | // Builds with the auth seam ALONE — no McpSessionStore. This is the cloud | 348 | // Builds with the auth seam ALONE — no McpSessionStore. This is the cloud |
| 209 | // shape: the Agent bridge serves /mcp transport, the envelope only publishes | 349 | // shape: the Agent bridge serves /mcp transport, the envelope only publishes |
| @@ -1,3 +1,4 @@ | |||
| 1 | import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js"; | ||
| 1 | import { Effect, Match, Predicate } from "effect"; | 2 | import { Effect, Match, Predicate } from "effect"; |
| 2 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | 3 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 4 | ||
| @@ -107,25 +108,35 @@ export const jsonRpcErrorBody = ( · 103 unmodified lines | |||
| 107 | readonly cors?: boolean; | 108 | readonly cors?: boolean; |
| 108 | readonly challenge?: string; | 109 | readonly challenge?: string; |
| 109 | readonly retryAfterSeconds?: number; | 110 | readonly retryAfterSeconds?: number; |
| 111 | /** | ||
| 112 | * The id to echo. Envelope-level errors have no request to answer and stay | ||
| 113 | * at the default `null`; only an error that answers ONE identified JSON-RPC | ||
| 114 | * request (the pre-initialize guard below) sets it, since a client matches | ||
| 115 | * the response to its pending request by id. | ||
| 116 | */ | ||
| 117 | readonly id?: string | number | null; | ||
| 110 | }, | 118 | }, |
| 111 | ): Response => { | 119 | ): Response => { |
| 112 | const cors = opts?.cors ?? true; | 120 | const cors = opts?.cors ?? true; |
| 113 | const challenge = opts?.challenge; | 121 | const challenge = opts?.challenge; |
| 114 | const retryAfterSeconds = opts?.retryAfterSeconds; | 122 | const retryAfterSeconds = opts?.retryAfterSeconds; |
| 115 | return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), { | 123 | return new Response( |
| 116 | status, | 124 | JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: opts?.id ?? null }), |
| 117 | headers: { | 125 | { |
| 118 | "content-type": "application/json", | 126 | status, |
| 119 | ...(cors ? { "access-control-allow-origin": "*" } : {}), | 127 | headers: { |
| 120 | ...(challenge | 128 | "content-type": "application/json", |
| 121 | ? { | 129 | ...(cors ? { "access-control-allow-origin": "*" } : {}), |
| 122 | "www-authenticate": challenge, | 130 | ...(challenge |
| 123 | "access-control-expose-headers": "WWW-Authenticate", | 131 | ? { |
| 124 | } | 132 | "www-authenticate": challenge, |
| 125 | : {}), | 133 | "access-control-expose-headers": "WWW-Authenticate", |
| 126 | ...(retryAfterSeconds === undefined ? {} : { "retry-after": String(retryAfterSeconds) }), | 134 | } |
| 135 | : {}), | ||
| 136 | ...(retryAfterSeconds === undefined ? {} : { "retry-after": String(retryAfterSeconds) }), | ||
| 137 | }, | ||
| 127 | }, | 138 | }, |
| 128 | }); | 139 | ); |
| 129 | }; | 140 | }; |
| 130 | 141 | ||
| 131 | /** | 142 | /** |
| @@ -135,6 +146,97 @@ export const jsonRpcErrorBody = ( · 3 unmodified lines | |||
| 135 | */ | 146 | */ |
| 136 | export const UNAVAILABLE_RETRY_AFTER_SECONDS = 2; | 147 | export const UNAVAILABLE_RETRY_AFTER_SECONDS = 2; |
| 137 | 148 | ||
| 149 | /** JSON-RPC's own code for "this server does not implement that method". */ | ||
| 150 | const METHOD_NOT_FOUND = -32601; | ||
| 151 | |||
| 152 | /** | ||
| 153 | * The transport's POST content negotiation, mirrored. | ||
| 154 | * | ||
| 155 | * `WebStandardStreamableHTTPServerTransport.handlePostRequest` refuses a POST | ||
| 156 | * on its headers alone, BEFORE it reads the body: | ||
| 157 | * | ||
| 158 | * - `Accept` must list BOTH `application/json` and `text/event-stream`, else | ||
| 159 | * 406 Not Acceptable. | ||
| 160 | * - `Content-Type` must include `application/json`, else 415 Unsupported | ||
| 161 | * Media Type. | ||
| 162 | * | ||
| 163 | * The guard below answers 200 without ever consulting the transport, so it must | ||
| 164 | * not fire on a request the transport would have refused — that would turn a | ||
| 165 | * 406 or a 415 into a success. These are the SDK's own substring tests on the | ||
| 166 | * raw header values, copied so the two agree; a request that fails either falls | ||
| 167 | * through untouched and gets the transport's real 406/415. | ||
| 168 | * | ||
| 169 | * The transport's DNS-rebinding check runs earlier still, but it is inert | ||
| 170 | * unless a host passes `enableDnsRebindingProtection`, which no host here does. | ||
| 171 | */ | ||
| 172 | const passesTransportContentNegotiation = (request: Request): boolean => { | ||
| 173 | const accept = request.headers.get("accept"); | ||
| 174 | if (!accept?.includes("application/json") || !accept.includes("text/event-stream")) return false; | ||
| 175 | const contentType = request.headers.get("content-type"); | ||
| 176 | return contentType !== null && contentType.includes("application/json"); | ||
| 177 | }; | ||
| 178 | |||
| 179 | /** | ||
| 180 | * The pre-initialize dispatch guard, for the session-less POST every host | ||
| 181 | * handles before it hands the request to a fresh streamable-HTTP transport. | ||
| 182 | * | ||
| 183 | * Only `initialize` can open a session, so the transport answers every OTHER | ||
| 184 | * method with HTTP **400** + `-32000 Server not initialized`. A 400 is a | ||
| 185 | * transport-level failure: clients tear the connection down rather than treat | ||
| 186 | * it as one request failing. So a client that opens with an optional probe — | ||
| 187 | * MCP 2026-07-28 clients lead with `server/discover` — is disconnected before | ||
| 188 | * it can fall back to `initialize`, and the handshake never happens. | ||
| 189 | * | ||
| 190 | * JSON-RPC already has the right answer for a method the dispatcher doesn't | ||
| 191 | * know: `-32601 Method not found`, carried on a normal HTTP 200. It is a | ||
| 192 | * per-request error, so the connection survives and the client falls back. | ||
| 193 | * | ||
| 194 | * This deliberately covers ANY method other than `initialize` rather than | ||
| 195 | * naming `server/discover`: pre-session, `initialize` is the only method this | ||
| 196 | * dispatcher implements, so -32601 is the literal truth for the rest. It also | ||
| 197 | * can't silently mask a future real `server/discover` — implementing it means | ||
| 198 | * it stops being unknown here, instead of a special case going stale. | ||
| 199 | * | ||
| 200 | * The guard REPLACES one transport answer and must not shadow any of the | ||
| 201 | * others, so it fires only where it is the whole story: a POST that clears the | ||
| 202 | * transport's content negotiation AND carries a structurally valid JSON-RPC 2.0 | ||
| 203 | * request. Everything else succeeds with `null` and reaches the transport | ||
| 204 | * untouched, keeping the transport's own response — a non-POST, a bad | ||
| 205 | * `Accept`/`Content-Type` (406/415), unparseable JSON or a message that is not | ||
| 206 | * a valid JSON-RPC request (400 parse error: a fractional id, a non-object | ||
| 207 | * `params`, an unknown top-level field), a batch, a notification or a response | ||
| 208 | * (no id to answer), and `initialize` itself. | ||
| 209 | * | ||
| 210 | * Structural validity is decided by the SDK's own `isJSONRPCRequest`, the exact | ||
| 211 | * predicate behind the transport's `JSONRPCMessageSchema.parse`, rather than a | ||
| 212 | * hand-rolled re-implementation that could drift from it. | ||
| 213 | * | ||
| 214 | * Reads a clone, leaving the caller's request body intact for the transport. | ||
| 215 | */ | ||
| 216 | export const preInitializeMethodNotFound = (request: Request): Effect.Effect<Response | null> => | ||
| 217 | request.method !== "POST" || !passesTransportContentNegotiation(request) | ||
| 218 | ? Effect.succeed(null) | ||
| 219 | : Effect.tryPromise({ | ||
| 220 | try: (): Promise<unknown> => request.clone().json(), | ||
| 221 | catch: () => null, | ||
| 222 | }).pipe( | ||
| 223 | // A body we cannot read is simply not ours to answer; hand it on. | ||
| 224 | Effect.orElseSucceed(() => null), | ||
| 225 | Effect.map(renderPreInitializeMethodNotFound), | ||
| 226 | ); | ||
| 227 | |||
| 228 | /** The pure decision behind {@link preInitializeMethodNotFound}. */ | ||
| 229 | const renderPreInitializeMethodNotFound = (body: unknown): Response | null => { | ||
| 230 | if (!isJSONRPCRequest(body)) return null; | ||
| 231 | if (body.method === "initialize") return null; | ||
| 232 | // An INNER response like every other store/handler error body: the host's | ||
| 233 | // outer envelope owns the CORS headers on the way out. | ||
| 234 | return jsonRpcErrorBody(200, METHOD_NOT_FOUND, "Method not found", { | ||
| 235 | cors: false, | ||
| 236 | id: body.id, | ||
| 237 | }); | ||
| 238 | }; | ||
| 239 | |||
| 138 | /** The envelope's own CORS-on JSON-RPC error `Response`, optionally carrying a challenge. */ | 240 | /** The envelope's own CORS-on JSON-RPC error `Response`, optionally carrying a challenge. */ |
| 139 | const jsonRpcResponse = ( | 241 | const jsonRpcResponse = ( |
| 140 | status: number, | 242 | status: number, |
| @@ -1,12 +1,16 @@ | |||
| 1 | import { expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Effect } from "effect"; | 2 | import { Effect } from "effect"; |
| 3 | 3 | ||
| 4 | import type { ExecutionEngine } from "@executor-js/execution"; | ||
| 5 | |||
| 4 | import { | 6 | import { |
| 5 | makeInMemoryMcpSessionStore, | 7 | makeInMemoryMcpSessionStore, |
| 6 | McpEngineBuildError, | 8 | McpEngineBuildError, |
| 9 | type McpBuildServer, | ||
| 7 | type McpBuildServerOptions, | 10 | type McpBuildServerOptions, |
| 8 | } from "./in-memory-session-store"; | 11 | } from "./in-memory-session-store"; |
| 9 | import { defaultMcpResource, type Principal } from "./seams"; | 12 | import { defaultMcpResource, type Principal } from "./seams"; |
| 13 | import { createExecutorMcpServer } from "./tool-server"; | ||
| 10 | 14 | ||
| 11 | const TEST_PRINCIPAL: Principal = { | 15 | const TEST_PRINCIPAL: Principal = { |
| 12 | accountId: "acct_test", | 16 | accountId: "acct_test", |
| @@ -52,3 +56,153 @@ it("preserves native elicitation mode when creating an in-memory MCP session", a · 39 unmodified lines | |||
| 52 | expect((result as Response).status).toBe(500); | 56 | expect((result as Response).status).toBe(500); |
| 53 | expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); | 57 | expect(buildOptions?.elicitationMode).toEqual({ mode: "native" }); |
| 54 | }); | 58 | }); |
| 59 | |||
| 60 | // --------------------------------------------------------------------------- | ||
| 61 | // The pre-initialize guard, through the real store path. | ||
| 62 | // | ||
| 63 | // `store.dispatch` with no session id runs the guard and, when the guard | ||
| 64 | // declines, builds a real MCP server and drives a real streamable-HTTP | ||
| 65 | // transport. So these assert BOTH halves of the contract: the one answer the | ||
| 66 | // guard replaces, and the transport answers it must not shadow. | ||
| 67 | // --------------------------------------------------------------------------- | ||
| 68 | |||
| 69 | /** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */ | ||
| 70 | const MCP_POST_HEADERS = { | ||
| 71 | "content-type": "application/json", | ||
| 72 | accept: "application/json, text/event-stream", | ||
| 73 | } as const; | ||
| 74 | |||
| 75 | /** No code ever runs here: these requests are answered before any tool call. */ | ||
| 76 | const stubEngine: ExecutionEngine<never> = { | ||
| 77 | execute: () => Effect.succeed({ result: "unused" }), | ||
| 78 | executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "unused" } }), | ||
| 79 | resume: () => Effect.succeed(null), | ||
| 80 | getPausedExecution: () => Effect.succeed(null), | ||
| 81 | pausedExecutionCount: () => Effect.succeed(0), | ||
| 82 | hasPausedExecutions: () => Effect.succeed(false), | ||
| 83 | getDescription: Effect.succeed("test executor"), | ||
| 84 | shutdown: Effect.void, | ||
| 85 | }; | ||
| 86 | |||
| 87 | /** A store whose sessions are real: a real MCP server on a real transport. */ | ||
| 88 | const makeServingStore = () => { | ||
| 89 | let builds = 0; | ||
| 90 | const buildServer: McpBuildServer = () => | ||
| 91 | Effect.sync(() => { | ||
| 92 | builds += 1; | ||
| 93 | }).pipe( | ||
| 94 | Effect.flatMap(() => createExecutorMcpServer({ engine: stubEngine })), | ||
| 95 | Effect.map((mcpServer) => ({ mcpServer, engine: stubEngine })), | ||
| 96 | ); | ||
| 97 | return { sessions: makeInMemoryMcpSessionStore(buildServer), buildCount: (): number => builds }; | ||
| 98 | }; | ||
| 99 | |||
| 100 | const dispatchPost = ( | ||
| 101 | sessions: ReturnType<typeof makeServingStore>["sessions"], | ||
| 102 | body: unknown, | ||
| 103 | headers: Record<string, string> = MCP_POST_HEADERS, | ||
| 104 | ): Promise<Response> => | ||
| 105 | Effect.runPromise( | ||
| 106 | sessions.store | ||
| 107 | .dispatch({ | ||
| 108 | request: new Request("https://executor.test/mcp", { | ||
| 109 | method: "POST", | ||
| 110 | headers, | ||
| 111 | body: JSON.stringify(body), | ||
| 112 | }), | ||
| 113 | principal: TEST_PRINCIPAL, | ||
| 114 | resource: defaultMcpResource, | ||
| 115 | sessionId: null, | ||
| 116 | method: "POST", | ||
| 117 | }) | ||
| 118 | .pipe( | ||
| 119 | Effect.map((result) => { | ||
| 120 | expect(result).toBeInstanceOf(Response); | ||
| 121 | return result as Response; | ||
| 122 | }), | ||
| 123 | ), | ||
| 124 | ); | ||
| 125 | |||
| 126 | interface JsonRpcErrorBody { | ||
| 127 | readonly error: { readonly code: number; readonly message: string }; | ||
| 128 | } | ||
| 129 | |||
| 130 | describe("pre-initialize dispatch through the in-memory session store", () => { | ||
| 131 | it("answers a valid unknown pre-session method with -32601 on a 200", async () => { | ||
| 132 | const { sessions, buildCount } = makeServingStore(); | ||
| 133 | const response = await dispatchPost(sessions, { | ||
| 134 | jsonrpc: "2.0", | ||
| 135 | id: 7, | ||
| 136 | method: "server/discover", | ||
| 137 | params: {}, | ||
| 138 | }); | ||
| 139 | |||
| 140 | // 200, not the transport's 400: a per-request error the client survives. | ||
| 141 | expect(response.status).toBe(200); | ||
| 142 | expect(await response.json()).toEqual({ | ||
| 143 | jsonrpc: "2.0", | ||
| 144 | id: 7, | ||
| 145 | error: { code: -32601, message: "Method not found" }, | ||
| 146 | }); | ||
| 147 | // The guard short-circuits before any engine is built. | ||
| 148 | expect(buildCount()).toBe(0); | ||
| 149 | await sessions.close(); | ||
| 150 | }); | ||
| 151 | |||
| 152 | it("passes a pre-session notification to the transport", async () => { | ||
| 153 | const { sessions, buildCount } = makeServingStore(); | ||
| 154 | const response = await dispatchPost(sessions, { | ||
| 155 | jsonrpc: "2.0", | ||
| 156 | method: "notifications/initialized", | ||
| 157 | }); | ||
| 158 | |||
| 159 | // A notification carries no id, so the guard may not answer it at all; | ||
| 160 | // whatever comes back is the transport's own answer. | ||
| 161 | const body = (await response.json()) as JsonRpcErrorBody; | ||
| 162 | expect(body.error.code).not.toBe(-32601); | ||
| 163 | expect(body.error.code).toBe(-32000); | ||
| 164 | expect(buildCount()).toBe(1); | ||
| 165 | await sessions.close(); | ||
| 166 | }); | ||
| 167 | |||
| 168 | it("leaves a structurally invalid request to the transport's parse error", async () => { | ||
| 169 | const { sessions } = makeServingStore(); | ||
| 170 | // A fractional id is not a JSON-RPC id, so this is not a request the guard | ||
| 171 | // may report an unknown method for. | ||
| 172 | const response = await dispatchPost(sessions, { | ||
| 173 | jsonrpc: "2.0", | ||
| 174 | id: 1.5, | ||
| 175 | method: "server/discover", | ||
| 176 | }); | ||
| 177 | |||
| 178 | expect(response.status).toBe(400); | ||
| 179 | const body = (await response.json()) as JsonRpcErrorBody; | ||
| 180 | expect(body.error.code).toBe(-32700); | ||
| 181 | expect(body.error.code).not.toBe(-32601); | ||
| 182 | await sessions.close(); | ||
| 183 | }); | ||
| 184 | |||
| 185 | it("leaves a wrong Content-Type to the transport's 415", async () => { | ||
| 186 | const { sessions } = makeServingStore(); | ||
| 187 | const response = await dispatchPost( | ||
| 188 | sessions, | ||
| 189 | { jsonrpc: "2.0", id: 1, method: "server/discover" }, | ||
| 190 | { "content-type": "text/plain", accept: MCP_POST_HEADERS.accept }, | ||
| 191 | ); | ||
| 192 | |||
| 193 | expect(response.status).toBe(415); | ||
| 194 | await sessions.close(); | ||
| 195 | }); | ||
| 196 | |||
| 197 | it("leaves an incomplete Accept to the transport's 406", async () => { | ||
| 198 | const { sessions } = makeServingStore(); | ||
| 199 | const response = await dispatchPost( | ||
| 200 | sessions, | ||
| 201 | { jsonrpc: "2.0", id: 1, method: "server/discover" }, | ||
| 202 | { "content-type": "application/json", accept: "application/json" }, | ||
| 203 | ); | ||
| 204 | |||
| 205 | expect(response.status).toBe(406); | ||
| 206 | await sessions.close(); | ||
| 207 | }); | ||
| 208 | }); | ||
| @@ -16,7 +16,7 @@ import { · 15 unmodified lines | |||
| 16 | makeInProcessBrowserApprovalStore, | 16 | makeInProcessBrowserApprovalStore, |
| 17 | type InProcessBrowserApprovalStore, | 17 | type InProcessBrowserApprovalStore, |
| 18 | } from "./browser-approval-store"; | 18 | } from "./browser-approval-store"; |
| 19 | import { jsonRpcErrorBody } from "./envelope"; | 19 | import { jsonRpcErrorBody, preInitializeMethodNotFound } from "./envelope"; |
| 20 | import { | 20 | import { |
| 21 | McpSessionStore, | 21 | McpSessionStore, |
| 22 | defaultMcpResource, | 22 | defaultMcpResource, |
| @@ -252,7 +252,7 @@ export const makeInMemoryMcpSessionStore = ( · 229 unmodified lines | |||
| 252 | }; | 252 | }; |
| 253 | 253 | ||
| 254 | /** Open a new session: build the server, connect a transport, drive the request. */ | 254 | /** Open a new session: build the server, connect a transport, drive the request. */ |
| 255 | const create = ( | 255 | const openSession = ( |
| 256 | principal: Principal, | 256 | principal: Principal, |
| 257 | resource: McpResource, | 257 | resource: McpResource, |
| 258 | request: Request, | 258 | request: Request, |
| @@ -296,6 +296,25 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines | |||
| 296 | ); | 296 | ); |
| 297 | }; | 297 | }; |
| 298 | 298 | ||
| 299 | /** | ||
| 300 | * The session-less POST path: answer a probe for anything but `initialize` | ||
| 301 | * with -32601 (see `preInitializeMethodNotFound`) rather than let the | ||
| 302 | * transport reject it with a connection-killing 400, and only then build a | ||
| 303 | * server and open a session. | ||
| 304 | */ | ||
| 305 | const create = ( | ||
| 306 | principal: Principal, | ||
| 307 | resource: McpResource, | ||
| 308 | request: Request, | ||
| 309 | ): Effect.Effect<McpDispatchResult> => | ||
| 310 | preInitializeMethodNotFound(request).pipe( | ||
| 311 | Effect.flatMap((unsupported) => | ||
| 312 | unsupported | ||
| 313 | ? Effect.succeed<McpDispatchResult>(unsupported) | ||
| 314 | : openSession(principal, resource, request), | ||
| 315 | ), | ||
| 316 | ); | ||
| 317 | |||
| 299 | const store: McpSessionStore["Service"] = { | 318 | const store: McpSessionStore["Service"] = { |
| 300 | dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => | 319 | dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => |
| 301 | sessionId | 320 | sessionId |
| @@ -40,5 +40,6 @@ export { · 39 unmodified lines | |||
| 40 | McpServingRoutes, | 40 | McpServingRoutes, |
| 41 | McpDiscoveryRoutes, | 41 | McpDiscoveryRoutes, |
| 42 | jsonRpcErrorBody, | 42 | jsonRpcErrorBody, |
| 43 | preInitializeMethodNotFound, | ||
| 43 | UNAVAILABLE_RETRY_AFTER_SECONDS, | 44 | UNAVAILABLE_RETRY_AFTER_SECONDS, |
| 44 | } from "./envelope"; | 45 | } from "./envelope"; |
| @@ -1,8 +1,13 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | ||
| 3 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; | 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 4 | import { Effect } from "effect"; | 3 | import { |
| 5 | import { mkdtempSync } from "node:fs"; | 4 | ErrorCode, |
| 5 | LATEST_PROTOCOL_VERSION, | ||
| 6 | type JSONRPCMessage, | ||
| 7 | } from "@modelcontextprotocol/sdk/types.js"; | ||
| 8 | import { Effect, Option, Schema } from "effect"; | ||
| 9 | import { spawnSync } from "node:child_process"; | ||
| 10 | import { mkdtempSync, readFileSync, rmSync } from "node:fs"; | ||
| 6 | import { tmpdir } from "node:os"; | 11 | import { tmpdir } from "node:os"; |
| 7 | import { join, resolve } from "node:path"; | 12 | import { join, resolve } from "node:path"; |
| 8 | 13 | ||
| @@ -10,42 +15,197 @@ const repoRoot = resolve(import.meta.dirname, "../../../.."); · 1 unmodified line | |||
| 10 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); | 15 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); |
| 11 | const testScope = resolve(repoRoot, "apps/local"); | 16 | const testScope = resolve(repoRoot, "apps/local"); |
| 12 | 17 | ||
| 18 | const decodeServerManifest = Schema.decodeUnknownOption( | ||
| 19 | Schema.fromJsonString(Schema.Struct({ pid: Schema.optional(Schema.Number) })), | ||
| 20 | ); | ||
| 21 | |||
| 22 | const manifestPid = (dataDir: string): number | undefined => | ||
| 23 | Effect.runSync( | ||
| 24 | Effect.try({ | ||
| 25 | try: () => readFileSync(join(dataDir, "server-control", "server.json"), "utf8"), | ||
| 26 | catch: () => undefined, | ||
| 27 | }).pipe( | ||
| 28 | Effect.map((text) => { | ||
| 29 | const manifest = decodeServerManifest(text); | ||
| 30 | return Option.isSome(manifest) ? manifest.value.pid : undefined; | ||
| 31 | }), | ||
| 32 | Effect.orElseSucceed(() => undefined), | ||
| 33 | ), | ||
| 34 | ); | ||
| 35 | |||
| 36 | /** | ||
| 37 | * Own the daemon this test bridges to, on its own port. | ||
| 38 | * | ||
| 39 | * `executor mcp` otherwise elects a daemon on the shared default port, and will | ||
| 40 | * adopt one that is already listening there — including a daemon belonging to a | ||
| 41 | * different `EXECUTOR_DATA_DIR`, whose bearer token this test does not have. A | ||
| 42 | * dedicated port keeps the test hermetic and parallel-safe. `daemon run` falls | ||
| 43 | * back to a free port if this one is taken, and writes the port it chose into | ||
| 44 | * the manifest that `executor mcp` reads, so the exact number is not load-bearing. | ||
| 45 | */ | ||
| 46 | const startDaemon = (dataDir: string): void => { | ||
| 47 | const port = 20_000 + Math.floor(Math.random() * 20_000); | ||
| 48 | const result = spawnSync( | ||
| 49 | "bun", | ||
| 50 | ["run", cliEntry, "daemon", "run", "--port", String(port), "--hostname", "127.0.0.1"], | ||
| 51 | { env: { ...process.env, EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: testScope } }, | ||
| 52 | ); | ||
| 53 | expect(result.status, `daemon run failed: ${result.stderr?.toString() ?? ""}`).toBe(0); | ||
| 54 | }; | ||
| 55 | |||
| 56 | /** Stop the daemon started above; the manifest carries its pid. */ | ||
| 57 | const stopDaemon = (dataDir: string): Effect.Effect<void> => | ||
| 58 | Effect.sync(() => manifestPid(dataDir)).pipe( | ||
| 59 | Effect.flatMap((pid) => | ||
| 60 | pid | ||
| 61 | ? Effect.try({ try: () => process.kill(pid, "SIGTERM"), catch: () => undefined }).pipe( | ||
| 62 | Effect.ignore, | ||
| 63 | ) | ||
| 64 | : Effect.void, | ||
| 65 | ), | ||
| 66 | Effect.ignore, | ||
| 67 | ); | ||
| 68 | |||
| 69 | const withDaemon = Effect.acquireRelease( | ||
| 70 | Effect.sync(() => { | ||
| 71 | const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-discover-test-")); | ||
| 72 | startDaemon(dataDir); | ||
| 73 | return dataDir; | ||
| 74 | }), | ||
| 75 | (dataDir) => | ||
| 76 | stopDaemon(dataDir).pipe( | ||
| 77 | Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true }))), | ||
| 78 | ), | ||
| 79 | ); | ||
| 80 | |||
| 81 | const messageQueue = (transport: StdioClientTransport) => { | ||
| 82 | const messages: Array<JSONRPCMessage> = []; | ||
| 83 | const waiters: Array<(message: JSONRPCMessage) => void> = []; | ||
| 84 | |||
| 85 | transport.onmessage = (message) => { | ||
| 86 | const waiter = waiters.shift(); | ||
| 87 | if (waiter) { | ||
| 88 | waiter(message); | ||
| 89 | } else { | ||
| 90 | messages.push(message); | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | return { | ||
| 95 | next: (): Promise<JSONRPCMessage> => { | ||
| 96 | const message = messages.shift(); | ||
| 97 | return message ? Promise.resolve(message) : new Promise((resolve) => waiters.push(resolve)); | ||
| 98 | }, | ||
| 99 | }; | ||
| 100 | }; | ||
| 101 | |||
| 13 | describe("MCP stdio integration", () => { | 102 | describe("MCP stdio integration", () => { |
| 103 | // The regression: a client that opens with an unsupported probe used to get | ||
| 104 | // the transport's fatal `-32000 Server not initialized` on an HTTP 400, which | ||
| 105 | // tore the bridge down before it could fall back to `initialize`. The whole | ||
| 106 | // handshake must survive the probe on ONE connection, through to a tool call. | ||
| 14 | it.effect( | 107 | it.effect( |
| 15 | "execute tool returns result over stdio transport", | 108 | "unsupported discovery keeps the connection open for initialization and tool calls", |
| 16 | () => | 109 | () => |
| 17 | Effect.gen(function* () { | 110 | Effect.gen(function* () { |
| 18 | // Fresh temp dir so the test doesn't migrate against the developer's | 111 | const dataDir = yield* withDaemon; |
| 19 | // real ~/.executor/data.db. | ||
| 20 | const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); | ||
| 21 | |||
| 22 | const transport = new StdioClientTransport({ | 112 | const transport = new StdioClientTransport({ |
| 23 | command: "bun", | 113 | command: "bun", |
| 24 | args: ["run", cliEntry, "mcp", "--scope", testScope], | 114 | args: ["run", cliEntry, "mcp", "--scope", testScope], |
| 25 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, | 115 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, |
| 26 | }); | 116 | }); |
| 27 | 117 | const responses = messageQueue(transport); | |
| 28 | const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); | ||
| 29 | 118 | ||
| 30 | yield* Effect.acquireRelease( | 119 | yield* Effect.acquireRelease( |
| 31 | Effect.promise(() => client.connect(transport)), | 120 | Effect.promise(() => transport.start()), |
| 32 | () => Effect.promise(() => transport.close()), | 121 | () => Effect.promise(() => transport.close()), |
| 33 | ); | 122 | ); |
| 34 | 123 | ||
| 35 | const { tools } = yield* Effect.promise(() => client.listTools()); | 124 | yield* Effect.promise(() => |
| 36 | expect(tools.map((t) => t.name)).toContain("execute"); | 125 | transport.send({ |
| 126 | jsonrpc: "2.0", | ||
| 127 | id: 1, | ||
| 128 | method: "server/discover", | ||
| 129 | params: { | ||
| 130 | _meta: { | ||
| 131 | "io.modelcontextprotocol/protocolVersion": "2026-07-28", | ||
| 132 | "io.modelcontextprotocol/clientInfo": { | ||
| 133 | name: "discovery-test-client", | ||
| 134 | version: "1.0.0", | ||
| 135 | }, | ||
| 136 | "io.modelcontextprotocol/clientCapabilities": {}, | ||
| 137 | }, | ||
| 138 | }, | ||
| 139 | }), | ||
| 140 | ); | ||
| 37 | 141 | ||
| 38 | const result = yield* Effect.promise(() => | 142 | expect(yield* Effect.promise(() => responses.next())).toEqual({ |
| 39 | client.callTool({ | 143 | jsonrpc: "2.0", |
| 40 | name: "execute", | 144 | id: 1, |
| 41 | arguments: { code: "return 2+2" }, | 145 | error: { |
| 146 | code: ErrorCode.MethodNotFound, | ||
| 147 | message: "Method not found", | ||
| 148 | }, | ||
| 149 | }); | ||
| 150 | |||
| 151 | yield* Effect.promise(() => | ||
| 152 | transport.send({ | ||
| 153 | jsonrpc: "2.0", | ||
| 154 | id: 2, | ||
| 155 | method: "initialize", | ||
| 156 | params: { | ||
| 157 | protocolVersion: LATEST_PROTOCOL_VERSION, | ||
| 158 | capabilities: {}, | ||
| 159 | clientInfo: { | ||
| 160 | name: "discovery-test-client", | ||
| 161 | version: "1.0.0", | ||
| 162 | }, | ||
| 163 | }, | ||
| 164 | }), | ||
| 165 | ); | ||
| 166 | |||
| 167 | const initialize = yield* Effect.promise(() => responses.next()); | ||
| 168 | expect(initialize).toHaveProperty("result.protocolVersion"); | ||
| 169 | |||
| 170 | yield* Effect.promise(() => | ||
| 171 | transport.send({ | ||
| 172 | jsonrpc: "2.0", | ||
| 173 | method: "notifications/initialized", | ||
| 174 | }), | ||
| 175 | ); | ||
| 176 | yield* Effect.promise(() => | ||
| 177 | transport.send({ | ||
| 178 | jsonrpc: "2.0", | ||
| 179 | id: 3, | ||
| 180 | method: "tools/list", | ||
| 181 | params: {}, | ||
| 42 | }), | 182 | }), |
| 43 | ); | 183 | ); |
| 44 | 184 | ||
| 45 | const text = (result.content as Array<{ type: string; text: string }>)[0]?.text; | 185 | const listed = yield* Effect.promise(() => responses.next()); |
| 46 | expect(text).toContain("4"); | 186 | expect(listed).toHaveProperty( |
| 47 | expect(result.isError).toBeFalsy(); | 187 | "result.tools", |
| 188 | expect.arrayContaining([expect.objectContaining({ name: "execute" })]), | ||
| 189 | ); | ||
| 190 | |||
| 191 | yield* Effect.promise(() => | ||
| 192 | transport.send({ | ||
| 193 | jsonrpc: "2.0", | ||
| 194 | id: 4, | ||
| 195 | method: "tools/call", | ||
| 196 | params: { | ||
| 197 | name: "execute", | ||
| 198 | arguments: { code: "return 2+2" }, | ||
| 199 | }, | ||
| 200 | }), | ||
| 201 | ); | ||
| 202 | |||
| 203 | const called = yield* Effect.promise(() => responses.next()); | ||
| 204 | expect(called).toHaveProperty( | ||
| 205 | "result.content", | ||
| 206 | expect.arrayContaining([expect.objectContaining({ text: expect.stringContaining("4") })]), | ||
| 207 | ); | ||
| 48 | }).pipe(Effect.scoped), | 208 | }).pipe(Effect.scoped), |
| 49 | { timeout: 30_000 }, | 209 | { timeout: 120_000 }, |
| 50 | ); | 210 | ); |
| 51 | }); | 211 | }); |