History

allow initialization after unsupported mcp discovery (#1458)

* 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. ---------

Tyler Gibbs committed 2 months ago

Commit 1908dd61 parent 8c20c339 files · +741 −37Browse files

Unified · Split

9 files
  1. .changeset/mcp-pre-initialize-method-not-found.md +10 −0
  2. apps/local/src/mcp-pre-initialize.test.ts +110 −0
  3. apps/local/src/mcp.ts +8 −0
  4. packages/hosts/mcp/src/envelope.test.ts +140 −0
  5. packages/hosts/mcp/src/envelope.ts +115 −13
  6. packages/hosts/mcp/src/in-memory-session-store.test.ts +155 −1
  7. packages/hosts/mcp/src/in-memory-session-store.ts +21 −2
  8. packages/hosts/mcp/src/index.ts +1 −0
  9. packages/hosts/mcp/src/stdio-integration.test.ts +181 −21
.changeset/mcp-pre-initialize-method-not-found.md+10−0
@@ -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
8Only `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
10Pre-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.
apps/local/src/mcp-pre-initialize.test.ts+110−0
@@ -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
21import { describe, expect, it } from "@effect/vitest";
22import { Effect } from "effect";
23
24import type { ExecutionEngine } from "@executor-js/execution";
25
26import { createMcpRequestHandler } from "./mcp";
27
28/** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */
29const 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. */
35const 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
46const 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
54interface JsonRpcErrorBody {
55 readonly error: { readonly code: number; readonly message: string };
56}
57
58describe("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});
apps/local/src/mcp.ts+8−0
@@ -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";
12import {13import {
@@ -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;
packages/hosts/mcp/src/envelope.test.ts+140−0
@@ -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. */
216const MCP_POST_HEADERS = {
217 "content-type": "application/json",
218 accept: "application/json, text/event-stream",
219} as const;
220
221const 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
228const guard = (request: Request): Promise<Response | null> =>
229 Effect.runPromise(preInitializeMethodNotFound(request));
230
231describe("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
207describe("McpDiscoveryRoutes (discovery-only, no session store)", () => {347describe("McpDiscoveryRoutes (discovery-only, no session store)", () => {
208 // Builds with the auth seam ALONE — no McpSessionStore. This is the cloud348 // Builds with the auth seam ALONE — no McpSessionStore. This is the cloud
209 // shape: the Agent bridge serves /mcp transport, the envelope only publishes349 // shape: the Agent bridge serves /mcp transport, the envelope only publishes
packages/hosts/mcp/src/envelope.ts+115−13
@@ -1,3 +1,4 @@
1import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
1import { Effect, Match, Predicate } from "effect";2import { Effect, Match, Predicate } from "effect";
2import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";3import { 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 ...(challenge128 "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 */
136export const UNAVAILABLE_RETRY_AFTER_SECONDS = 2;147export const UNAVAILABLE_RETRY_AFTER_SECONDS = 2;
137 148
149/** JSON-RPC's own code for "this server does not implement that method". */
150const 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 */
172const 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 */
216export 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}. */
229const 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. */
139const jsonRpcResponse = (241const jsonRpcResponse = (
140 status: number,242 status: number,
packages/hosts/mcp/src/in-memory-session-store.test.ts+155−1
@@ -1,12 +1,16 @@
1import { expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Effect } from "effect";2import { Effect } from "effect";
3 3
4import type { ExecutionEngine } from "@executor-js/execution";
5
4import {6import {
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";
9import { defaultMcpResource, type Principal } from "./seams";12import { defaultMcpResource, type Principal } from "./seams";
13import { createExecutorMcpServer } from "./tool-server";
10 14
11const TEST_PRINCIPAL: Principal = {15const 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. */
70const 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. */
76const 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. */
88const 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
100const 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
126interface JsonRpcErrorBody {
127 readonly error: { readonly code: number; readonly message: string };
128}
129
130describe("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});
packages/hosts/mcp/src/in-memory-session-store.ts+21−2
@@ -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";
19import { jsonRpcErrorBody } from "./envelope";19import { jsonRpcErrorBody, preInitializeMethodNotFound } from "./envelope";
20import {20import {
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 sessionId320 sessionId
packages/hosts/mcp/src/index.ts+1−0
@@ -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";
packages/hosts/mcp/src/stdio-integration.test.ts+181−21
@@ -1,8 +1,13 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";2import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
4import { Effect } from "effect";3import {
5import { mkdtempSync } from "node:fs";4 ErrorCode,
5 LATEST_PROTOCOL_VERSION,
6 type JSONRPCMessage,
7} from "@modelcontextprotocol/sdk/types.js";
8import { Effect, Option, Schema } from "effect";
9import { spawnSync } from "node:child_process";
10import { mkdtempSync, readFileSync, rmSync } from "node:fs";
6import { tmpdir } from "node:os";11import { tmpdir } from "node:os";
7import { join, resolve } from "node:path";12import { join, resolve } from "node:path";
8 13
@@ -10,42 +15,197 @@ const repoRoot = resolve(import.meta.dirname, "../../../.."); · 1 unmodified line
10const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");15const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");
11const testScope = resolve(repoRoot, "apps/local");16const testScope = resolve(repoRoot, "apps/local");
12 17
18const decodeServerManifest = Schema.decodeUnknownOption(
19 Schema.fromJsonString(Schema.Struct({ pid: Schema.optional(Schema.Number) })),
20);
21
22const 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 */
46const 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. */
57const 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
69const 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
81const 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
13describe("MCP stdio integration", () => {102describe("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's111 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});