* 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 | 7 | defaultMcpResource, |
| 8 | 8 | jsonRpcErrorBody, |
| 9 | 9 | mcpResourceKey, |
| 10 | + preInitializeMethodNotFound, | |
| 10 | 11 | type McpResource, |
| 11 | 12 | } from "@executor-js/host-mcp"; |
| 12 | 13 | import { |
| @@ -181,6 +182,13 @@ export const createMcpRequestHandler = ( · 168 unmodified lines | ||
| 181 | 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 | 192 | let created: McpServer | undefined; |
| 185 | 193 | let createdSessionId: string | null = null; |
| 186 | 194 | let resourceConfig: LocalMcpServerConfig | null = null; |
| @@ -22,6 +22,7 @@ import { · 21 unmodified lines | ||
| 22 | 22 | McpServingRoutes, |
| 23 | 23 | McpDiscoveryRoutes, |
| 24 | 24 | McpSessionStore, |
| 25 | + preInitializeMethodNotFound, | |
| 25 | 26 | type McpResource, |
| 26 | 27 | type McpDispatchResult, |
| 27 | 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 | 347 | describe("McpDiscoveryRoutes (discovery-only, no session store)", () => { |
| 208 | 348 | // Builds with the auth seam ALONE — no McpSessionStore. This is the cloud |
| 209 | 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 | 2 | import { Effect, Match, Predicate } from "effect"; |
| 2 | 3 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; |
| 3 | 4 | |
| @@ -107,25 +108,35 @@ export const jsonRpcErrorBody = ( · 103 unmodified lines | ||
| 107 | 108 | readonly cors?: boolean; |
| 108 | 109 | readonly challenge?: string; |
| 109 | 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 | 119 | ): Response => { |
| 112 | 120 | const cors = opts?.cors ?? true; |
| 113 | 121 | const challenge = opts?.challenge; |
| 114 | 122 | const retryAfterSeconds = opts?.retryAfterSeconds; |
| 115 | − return new Response(JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: null }), { | |
| 116 | − status, | |
| 117 | − headers: { | |
| 118 | − "content-type": "application/json", | |
| 119 | − ...(cors ? { "access-control-allow-origin": "*" } : {}), | |
| 120 | − ...(challenge | |
| 121 | − ? { | |
| 122 | − "www-authenticate": challenge, | |
| 123 | − "access-control-expose-headers": "WWW-Authenticate", | |
| 124 | − } | |
| 125 | − : {}), | |
| 126 | − ...(retryAfterSeconds === undefined ? {} : { "retry-after": String(retryAfterSeconds) }), | |
| 123 | + return new Response( | |
| 124 | + JSON.stringify({ jsonrpc: "2.0", error: { code, message }, id: opts?.id ?? null }), | |
| 125 | + { | |
| 126 | + status, | |
| 127 | + headers: { | |
| 128 | + "content-type": "application/json", | |
| 129 | + ...(cors ? { "access-control-allow-origin": "*" } : {}), | |
| 130 | + ...(challenge | |
| 131 | + ? { | |
| 132 | + "www-authenticate": challenge, | |
| 133 | + "access-control-expose-headers": "WWW-Authenticate", | |
| 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 | 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 | 240 | /** The envelope's own CORS-on JSON-RPC error `Response`, optionally carrying a challenge. */ |
| 139 | 241 | const jsonRpcResponse = ( |
| 140 | 242 | status: number, |
| @@ -1,12 +1,16 @@ | ||
| 1 | −import { expect, it } from "@effect/vitest"; | |
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | 2 | import { Effect } from "effect"; |
| 3 | 3 | |
| 4 | +import type { ExecutionEngine } from "@executor-js/execution"; | |
| 5 | + | |
| 4 | 6 | import { |
| 5 | 7 | makeInMemoryMcpSessionStore, |
| 6 | 8 | McpEngineBuildError, |
| 9 | + type McpBuildServer, | |
| 7 | 10 | type McpBuildServerOptions, |
| 8 | 11 | } from "./in-memory-session-store"; |
| 9 | 12 | import { defaultMcpResource, type Principal } from "./seams"; |
| 13 | +import { createExecutorMcpServer } from "./tool-server"; | |
| 10 | 14 | |
| 11 | 15 | const TEST_PRINCIPAL: Principal = { |
| 12 | 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 | 56 | expect((result as Response).status).toBe(500); |
| 53 | 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 | 16 | makeInProcessBrowserApprovalStore, |
| 17 | 17 | type InProcessBrowserApprovalStore, |
| 18 | 18 | } from "./browser-approval-store"; |
| 19 | −import { jsonRpcErrorBody } from "./envelope"; | |
| 19 | +import { jsonRpcErrorBody, preInitializeMethodNotFound } from "./envelope"; | |
| 20 | 20 | import { |
| 21 | 21 | McpSessionStore, |
| 22 | 22 | defaultMcpResource, |
| @@ -252,7 +252,7 @@ export const makeInMemoryMcpSessionStore = ( · 229 unmodified lines | ||
| 252 | 252 | }; |
| 253 | 253 | |
| 254 | 254 | /** Open a new session: build the server, connect a transport, drive the request. */ |
| 255 | − const create = ( | |
| 255 | + const openSession = ( | |
| 256 | 256 | principal: Principal, |
| 257 | 257 | resource: McpResource, |
| 258 | 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 | 318 | const store: McpSessionStore["Service"] = { |
| 300 | 319 | dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) => |
| 301 | 320 | sessionId |
| @@ -40,5 +40,6 @@ export { · 39 unmodified lines | ||
| 40 | 40 | McpServingRoutes, |
| 41 | 41 | McpDiscoveryRoutes, |
| 42 | 42 | jsonRpcErrorBody, |
| 43 | + preInitializeMethodNotFound, | |
| 43 | 44 | UNAVAILABLE_RETRY_AFTER_SECONDS, |
| 44 | 45 | } from "./envelope"; |
| @@ -1,8 +1,13 @@ | ||
| 1 | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | −import { Client } from "@modelcontextprotocol/sdk/client/index.js"; | |
| 3 | 2 | import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; |
| 4 | −import { Effect } from "effect"; | |
| 5 | −import { mkdtempSync } from "node:fs"; | |
| 3 | +import { | |
| 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 | 11 | import { tmpdir } from "node:os"; |
| 7 | 12 | import { join, resolve } from "node:path"; |
| 8 | 13 | |
| @@ -10,42 +15,197 @@ const repoRoot = resolve(import.meta.dirname, "../../../.."); · 1 unmodified line | ||
| 10 | 15 | const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts"); |
| 11 | 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 | 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 | 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 | 110 | Effect.gen(function* () { |
| 18 | − // Fresh temp dir so the test doesn't migrate against the developer's | |
| 19 | − // real ~/.executor/data.db. | |
| 20 | − const dataDir = mkdtempSync(join(tmpdir(), "executor-mcp-test-")); | |
| 21 | − | |
| 111 | + const dataDir = yield* withDaemon; | |
| 22 | 112 | const transport = new StdioClientTransport({ |
| 23 | 113 | command: "bun", |
| 24 | 114 | args: ["run", cliEntry, "mcp", "--scope", testScope], |
| 25 | 115 | env: { ...process.env, EXECUTOR_DATA_DIR: dataDir }, |
| 26 | 116 | }); |
| 27 | − | |
| 28 | − const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} }); | |
| 117 | + const responses = messageQueue(transport); | |
| 29 | 118 | |
| 30 | 119 | yield* Effect.acquireRelease( |
| 31 | − Effect.promise(() => client.connect(transport)), | |
| 120 | + Effect.promise(() => transport.start()), | |
| 32 | 121 | () => Effect.promise(() => transport.close()), |
| 33 | 122 | ); |
| 34 | 123 | |
| 35 | − const { tools } = yield* Effect.promise(() => client.listTools()); | |
| 36 | − expect(tools.map((t) => t.name)).toContain("execute"); | |
| 124 | + yield* Effect.promise(() => | |
| 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(() => | |
| 39 | − client.callTool({ | |
| 40 | − name: "execute", | |
| 41 | − arguments: { code: "return 2+2" }, | |
| 142 | + expect(yield* Effect.promise(() => responses.next())).toEqual({ | |
| 143 | + jsonrpc: "2.0", | |
| 144 | + id: 1, | |
| 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; | |
| 46 | − expect(text).toContain("4"); | |
| 47 | − expect(result.isError).toBeFalsy(); | |
| 185 | + const listed = yield* Effect.promise(() => responses.next()); | |
| 186 | + expect(listed).toHaveProperty( | |
| 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 | 208 | }).pipe(Effect.scoped), |
| 49 | − { timeout: 30_000 }, | |
| 209 | + { timeout: 120_000 }, | |
| 50 | 210 | ); |
| 51 | 211 | }); |