Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/mcp-pre-initialize-method-not-found.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@executor-js/host-mcp": patch
"@executor-js/local": patch
---

**An unsupported method probed before `initialize` no longer kills the MCP connection**

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.

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.
110 changes: 110 additions & 0 deletions apps/local/src/mcp-pre-initialize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// ---------------------------------------------------------------------------
// Local app × the pre-initialize dispatch guard — through the real handler
// ---------------------------------------------------------------------------
//
// `executor mcp` bridges a stdio client to this handler's `/mcp` endpoint, so
// what the handler answers on a session-less POST decides whether the client's
// connection survives its first probe:
//
// test → createMcpRequestHandler().handleRequest(Request)
// → pre-initialize guard (an unknown method -> -32601 on a 200)
// → WebStandardStreamableHTTPServerTransport (everything else)
//
// The guard replaces exactly ONE transport answer — the connection-killing
// `400 -32000 Server not initialized` for a method that is not `initialize`.
// These assert that replacement AND that no other transport answer is shadowed:
// a request that fails content negotiation still gets the transport's 415/406,
// and a structurally invalid JSON-RPC message still gets its parse error, not a
// "method not found" that was never true.
// ---------------------------------------------------------------------------

import { describe, expect, it } from "@effect/vitest";
import { Effect } from "effect";

import type { ExecutionEngine } from "@executor-js/execution";

import { createMcpRequestHandler } from "./mcp";

/** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */
const MCP_POST_HEADERS = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
} as const;

/** No code ever runs here: these requests are answered before any tool call. */
const stubEngine: ExecutionEngine<never> = {
execute: () => Effect.succeed({ result: "unused" }),
executeWithPause: () => Effect.succeed({ status: "completed", result: { result: "unused" } }),
resume: () => Effect.succeed(null),
getPausedExecution: () => Effect.succeed(null),
pausedExecutionCount: () => Effect.succeed(0),
hasPausedExecutions: () => Effect.succeed(false),
getDescription: Effect.succeed("test executor"),
shutdown: Effect.void,
};

const post = (
body: unknown,
headers: Record<string, string> = MCP_POST_HEADERS,
): Promise<Response> =>
createMcpRequestHandler({ engine: stubEngine }).handleRequest(
new Request("http://local.test/mcp", { method: "POST", headers, body: JSON.stringify(body) }),
);

interface JsonRpcErrorBody {
readonly error: { readonly code: number; readonly message: string };
}

describe("local MCP handler, pre-initialize", () => {
it("answers a valid unknown pre-session method with -32601 on a 200", async () => {
const response = await post({ jsonrpc: "2.0", id: 7, method: "server/discover", params: {} });

// 200, not the transport's 400: a per-request error the bridged client
// survives, so it can still fall back to `initialize`.
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
jsonrpc: "2.0",
id: 7,
error: { code: -32601, message: "Method not found" },
});
});

it("passes a pre-session notification to the transport", async () => {
const response = await post({ jsonrpc: "2.0", method: "notifications/initialized" });

// A notification carries no id, so the guard may not answer it at all;
// whatever comes back is the transport's own answer.
const body = (await response.json()) as JsonRpcErrorBody;
expect(body.error.code).not.toBe(-32601);
expect(body.error.code).toBe(-32000);
});

it("leaves a structurally invalid request to the transport's parse error", async () => {
// A fractional id is not a JSON-RPC id, so this is not a request the guard
// may report an unknown method for.
const response = await post({ jsonrpc: "2.0", id: 1.5, method: "server/discover" });

expect(response.status).toBe(400);
const body = (await response.json()) as JsonRpcErrorBody;
expect(body.error.code).toBe(-32700);
expect(body.error.code).not.toBe(-32601);
});

it("leaves a wrong Content-Type to the transport's 415", async () => {
const response = await post(
{ jsonrpc: "2.0", id: 1, method: "server/discover" },
{ "content-type": "text/plain", accept: MCP_POST_HEADERS.accept },
);

expect(response.status).toBe(415);
});

it("leaves an incomplete Accept to the transport's 406", async () => {
const response = await post(
{ jsonrpc: "2.0", id: 1, method: "server/discover" },
{ "content-type": "application/json", accept: "application/json" },
);

expect(response.status).toBe(406);
});
});
8 changes: 8 additions & 0 deletions apps/local/src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
defaultMcpResource,
jsonRpcErrorBody,
mcpResourceKey,
preInitializeMethodNotFound,
type McpResource,
} from "@executor-js/host-mcp";
import {
Expand Down Expand Up @@ -181,6 +182,13 @@ export const createMcpRequestHandler = (
return transport.handleRequest(request);
}

// Pre-initialize dispatch: only `initialize` opens a session here, so a
// probe for anything else is answered -32601 instead of the transport's
// fatal 400. `executor mcp` bridges this endpoint to stdio, so that 400
// would close the client's pipe before it could fall back to initialize.
const unsupported = await Effect.runPromise(preInitializeMethodNotFound(request));
if (unsupported) return unsupported;

let created: McpServer | undefined;
let createdSessionId: string | null = null;
let resourceConfig: LocalMcpServerConfig | null = null;
Expand Down
140 changes: 140 additions & 0 deletions packages/hosts/mcp/src/envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
McpServingRoutes,
McpDiscoveryRoutes,
McpSessionStore,
preInitializeMethodNotFound,
type McpResource,
type McpDispatchResult,
type Principal,
Expand Down Expand Up @@ -204,6 +205,145 @@ it("dispatches toolkit MCP routes with the parsed toolkit resource", async () =>
});
});

// ---------------------------------------------------------------------------
// The pre-initialize dispatch guard. Session-less, only `initialize` is servable,
// and the transport's answer for everything else is a connection-killing HTTP
// 400. These lock in the -32601-on-200 replacement and, just as importantly,
// everything it must NOT intercept.
// ---------------------------------------------------------------------------

/** The headers a streamable-HTTP client must send on a POST; less is a 406/415. */
const MCP_POST_HEADERS = {
"content-type": "application/json",
accept: "application/json, text/event-stream",
} as const;

const postBody = (body: unknown, headers: Record<string, string> = MCP_POST_HEADERS): Request =>
new Request("https://host.test/mcp", {
method: "POST",
headers,
body: JSON.stringify(body),
});

const guard = (request: Request): Promise<Response | null> =>
Effect.runPromise(preInitializeMethodNotFound(request));

describe("preInitializeMethodNotFound", () => {
it("answers an unknown pre-init method with -32601 on a 200, echoing the id", async () => {
const response = await guard(
postBody({ jsonrpc: "2.0", id: 7, method: "server/discover", params: {} }),
);
expect(response).not.toBeNull();
// 200, not 400: a per-request error the client can survive, which is the
// entire point — a 400 makes clients tear the transport down.
expect(response!.status).toBe(200);
expect(response!.headers.get("content-type")).toContain("application/json");
expect(await response!.json()).toEqual({
jsonrpc: "2.0",
id: 7,
error: { code: -32601, message: "Method not found" },
});
});

it("generalizes past server/discover to any unknown method, including a string id", async () => {
const response = await guard(
postBody({ jsonrpc: "2.0", id: "abc", method: "some/futureProbe" }),
);
expect(await response!.json()).toEqual({
jsonrpc: "2.0",
id: "abc",
error: { code: -32601, message: "Method not found" },
});
});

it("lets initialize through to the transport", async () => {
expect(
await guard(postBody({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} })),
).toBeNull();
});

it("lets a notification through — there is no id to answer", async () => {
expect(
await guard(postBody({ jsonrpc: "2.0", method: "notifications/initialized" })),
).toBeNull();
});

it("lets non-POST, non-JSON, and non-JSON-RPC bodies through", async () => {
expect(await guard(new Request("https://host.test/mcp"))).toBeNull();
expect(
await guard(
new Request("https://host.test/mcp", {
method: "POST",
headers: MCP_POST_HEADERS,
body: "not json",
}),
),
).toBeNull();
expect(await guard(postBody({ id: 1, method: "tools/list" }))).toBeNull();
expect(await guard(postBody([]))).toBeNull();
});

// The guard replaces ONE transport answer (-32000 on a 400) and must not
// shadow the others. A structurally invalid JSON-RPC request is the
// transport's 400 parse error to give, not ours to call "method not found".
it("lets a structurally invalid JSON-RPC request through to the transport", async () => {
// A fractional id is not a request id (the SDK's RequestIdSchema is
// string | integer), so the transport rejects the whole message.
expect(await guard(postBody({ jsonrpc: "2.0", id: 1.5, method: "tools/list" }))).toBeNull();
// `params` must be an object when present.
expect(
await guard(postBody({ jsonrpc: "2.0", id: 1, method: "tools/list", params: 5 })),
).toBeNull();
// The request schema is strict: an unknown top-level field is invalid.
expect(
await guard(postBody({ jsonrpc: "2.0", id: 1, method: "tools/list", extra: true })),
).toBeNull();
// A wrong protocol version, and a batch, which the transport unpacks itself.
expect(await guard(postBody({ jsonrpc: "1.0", id: 1, method: "tools/list" }))).toBeNull();
expect(await guard(postBody([{ jsonrpc: "2.0", id: 1, method: "tools/list" }]))).toBeNull();
});

// Answering 200 here would bypass the transport's content negotiation, which
// runs before it ever looks at the body.
it("lets a request that fails the transport's content negotiation through", async () => {
const valid = { jsonrpc: "2.0", id: 1, method: "server/discover" };
// Content-Type is not application/json, or is absent -> the 415.
expect(
await guard(
postBody(valid, { "content-type": "text/plain", accept: MCP_POST_HEADERS.accept }),
),
).toBeNull();
expect(await guard(postBody(valid, { accept: MCP_POST_HEADERS.accept }))).toBeNull();
// Accept misses one of the two required types, or is absent -> the 406.
expect(
await guard(postBody(valid, { ...MCP_POST_HEADERS, accept: "application/json" })),
).toBeNull();
expect(
await guard(postBody(valid, { ...MCP_POST_HEADERS, accept: "text/event-stream" })),
).toBeNull();
expect(await guard(postBody(valid, { "content-type": "application/json" }))).toBeNull();
});

it("still fires when the negotiated headers carry parameters", async () => {
const response = await guard(
postBody(
{ jsonrpc: "2.0", id: 3, method: "server/discover" },
{
"content-type": "application/json; charset=utf-8",
accept: "application/json;q=0.9, text/event-stream;q=1.0",
},
),
);
expect(response?.status).toBe(200);
});

it("leaves the caller's body readable for the transport", async () => {
const request = postBody({ jsonrpc: "2.0", id: 1, method: "server/discover" });
await guard(request);
expect(await request.json()).toEqual({ jsonrpc: "2.0", id: 1, method: "server/discover" });
});
});

describe("McpDiscoveryRoutes (discovery-only, no session store)", () => {
// Builds with the auth seam ALONE — no McpSessionStore. This is the cloud
// shape: the Agent bridge serves /mcp transport, the envelope only publishes
Expand Down
Loading
Loading