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+
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.
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+
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+});
apps/local/src/mcp.ts+8−0
@@ -7,6 +7,7 @@ import { · 6 unmodified lines
77 defaultMcpResource,
88 jsonRpcErrorBody,
99 mcpResourceKey,
10+ preInitializeMethodNotFound,
1011 type McpResource,
1112 } from "@executor-js/host-mcp";
1213 import {
@@ -181,6 +182,13 @@ export const createMcpRequestHandler = ( · 168 unmodified lines
181182 return transport.handleRequest(request);
182183 }
183184
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+
184192 let created: McpServer | undefined;
185193 let createdSessionId: string | null = null;
186194 let resourceConfig: LocalMcpServerConfig | null = null;
packages/hosts/mcp/src/envelope.test.ts+140−0
@@ -22,6 +22,7 @@ import { · 21 unmodified lines
2222 McpServingRoutes,
2323 McpDiscoveryRoutes,
2424 McpSessionStore,
25+ preInitializeMethodNotFound,
2526 type McpResource,
2627 type McpDispatchResult,
2728 type Principal,
@@ -204,6 +205,145 @@ it("dispatches toolkit MCP routes with the parsed toolkit resource", async () => · 176 unmodified lines
204205 });
205206 });
206207
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+
207347 describe("McpDiscoveryRoutes (discovery-only, no session store)", () => {
208348 // Builds with the auth seam ALONE — no McpSessionStore. This is the cloud
209349 // shape: the Agent bridge serves /mcp transport, the envelope only publishes
packages/hosts/mcp/src/envelope.ts+115−13
@@ -1,3 +1,4 @@
1+import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
12 import { Effect, Match, Predicate } from "effect";
23 import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
34
@@ -107,25 +108,35 @@ export const jsonRpcErrorBody = ( · 103 unmodified lines
107108 readonly cors?: boolean;
108109 readonly challenge?: string;
109110 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;
110118 },
111119 ): Response => {
112120 const cors = opts?.cors ?? true;
113121 const challenge = opts?.challenge;
114122 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+ },
127138 },
128− });
139+ );
129140 };
130141
131142 /**
@@ -135,6 +146,97 @@ export const jsonRpcErrorBody = ( · 3 unmodified lines
135146 */
136147 export const UNAVAILABLE_RETRY_AFTER_SECONDS = 2;
137148
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+
138240 /** The envelope's own CORS-on JSON-RPC error `Response`, optionally carrying a challenge. */
139241 const jsonRpcResponse = (
140242 status: number,
packages/hosts/mcp/src/in-memory-session-store.test.ts+155−1
@@ -1,12 +1,16 @@
1−import { expect, it } from "@effect/vitest";
1+import { describe, expect, it } from "@effect/vitest";
22 import { Effect } from "effect";
33
4+import type { ExecutionEngine } from "@executor-js/execution";
5+
46 import {
57 makeInMemoryMcpSessionStore,
68 McpEngineBuildError,
9+ type McpBuildServer,
710 type McpBuildServerOptions,
811 } from "./in-memory-session-store";
912 import { defaultMcpResource, type Principal } from "./seams";
13+import { createExecutorMcpServer } from "./tool-server";
1014
1115 const TEST_PRINCIPAL: Principal = {
1216 accountId: "acct_test",
@@ -52,3 +56,153 @@ it("preserves native elicitation mode when creating an in-memory MCP session", a · 39 unmodified lines
5256 expect((result as Response).status).toBe(500);
5357 expect(buildOptions?.elicitationMode).toEqual({ mode: "native" });
5458 });
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+});
packages/hosts/mcp/src/in-memory-session-store.ts+21−2
@@ -16,7 +16,7 @@ import { · 15 unmodified lines
1616 makeInProcessBrowserApprovalStore,
1717 type InProcessBrowserApprovalStore,
1818 } from "./browser-approval-store";
19−import { jsonRpcErrorBody } from "./envelope";
19+import { jsonRpcErrorBody, preInitializeMethodNotFound } from "./envelope";
2020 import {
2121 McpSessionStore,
2222 defaultMcpResource,
@@ -252,7 +252,7 @@ export const makeInMemoryMcpSessionStore = ( · 229 unmodified lines
252252 };
253253
254254 /** Open a new session: build the server, connect a transport, drive the request. */
255− const create = (
255+ const openSession = (
256256 principal: Principal,
257257 resource: McpResource,
258258 request: Request,
@@ -296,6 +296,25 @@ export const makeInMemoryMcpSessionStore = ( · 37 unmodified lines
296296 );
297297 };
298298
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+
299318 const store: McpSessionStore["Service"] = {
300319 dispatch: ({ request, principal, resource, sessionId }: McpDispatchInput) =>
301320 sessionId
packages/hosts/mcp/src/index.ts+1−0
@@ -40,5 +40,6 @@ export { · 39 unmodified lines
4040 McpServingRoutes,
4141 McpDiscoveryRoutes,
4242 jsonRpcErrorBody,
43+ preInitializeMethodNotFound,
4344 UNAVAILABLE_RETRY_AFTER_SECONDS,
4445 } from "./envelope";
packages/hosts/mcp/src/stdio-integration.test.ts+181−21
@@ -1,8 +1,13 @@
11 import { describe, expect, it } from "@effect/vitest";
2−import { Client } from "@modelcontextprotocol/sdk/client/index.js";
32 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";
611 import { tmpdir } from "node:os";
712 import { join, resolve } from "node:path";
813
@@ -10,42 +15,197 @@ const repoRoot = resolve(import.meta.dirname, "../../../.."); · 1 unmodified line
1015 const cliEntry = resolve(repoRoot, "apps/cli/src/main.ts");
1116 const testScope = resolve(repoRoot, "apps/local");
1217
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+
13102 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.
14107 it.effect(
15− "execute tool returns result over stdio transport",
108+ "unsupported discovery keeps the connection open for initialization and tool calls",
16109 () =>
17110 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;
22112 const transport = new StdioClientTransport({
23113 command: "bun",
24114 args: ["run", cliEntry, "mcp", "--scope", testScope],
25115 env: { ...process.env, EXECUTOR_DATA_DIR: dataDir },
26116 });
27−
28− const client = new Client({ name: "test-client", version: "1.0.0" }, { capabilities: {} });
117+ const responses = messageQueue(transport);
29118
30119 yield* Effect.acquireRelease(
31− Effect.promise(() => client.connect(transport)),
120+ Effect.promise(() => transport.start()),
32121 () => Effect.promise(() => transport.close()),
33122 );
34123
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+ );
37141
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: {},
42182 }),
43183 );
44184
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+ );
48208 }).pipe(Effect.scoped),
49− { timeout: 30_000 },
209+ { timeout: 120_000 },
50210 );
51211 });