From 1f54f366a30c13d9f760da53727ddaba80e672a8 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 6 Aug 2026 08:07:37 +0900 Subject: [PATCH] fix: route MCP sessions to their owning Fly machine and 404 lost sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hosted kit.openiap.dev/mcp endpoint keeps StreamableHTTP sessions in a per-process Map, so with more than one Fly machine behind the proxy a valid mcp-session-id was rejected with 400 "initialize first" whenever the request landed on a sibling machine (~65% of calls in the issue repro, consistent with 3-machine round-robin). - Prefix session ids with FLY_MACHINE_ID and answer requests for a foreign machine's session with a fly-replay header so Fly's proxy re-routes them to the owner. No shared store needed; the transport object holds live SSE state and cannot be serialized anyway. - Never replay twice (fly-replay-src guard) and never replay to self, so stale machine ids after a deploy cannot loop. - Answer 404 (-32001 Session not found) instead of 400 for a session this process genuinely cannot serve — the MCP spec makes clients transparently re-initialize on 404, so restarts now self-heal. - Add packages/mcp-server/** to deploy-kit.yml triggers: kit's Fly binary imports the MCP handler from source, so MCP fixes previously merged without ever deploying. Also run the MCP server's own vitest suite in the verify job — no CI ran it before. Fixes #287 Co-Authored-By: Claude Opus 5 --- .github/workflows/deploy-kit.yml | 14 ++ packages/mcp-server/src/http.ts | 68 ++++++- packages/mcp-server/src/session-routing.ts | 73 +++++++ packages/mcp-server/src/web.ts | 63 +++++- packages/mcp-server/test/http.test.ts | 51 ++++- .../mcp-server/test/session-routing.test.ts | 92 +++++++++ packages/mcp-server/test/web.test.ts | 184 ++++++++++++++++++ 7 files changed, 539 insertions(+), 6 deletions(-) create mode 100644 packages/mcp-server/src/session-routing.ts create mode 100644 packages/mcp-server/test/session-routing.test.ts create mode 100644 packages/mcp-server/test/web.test.ts diff --git a/.github/workflows/deploy-kit.yml b/.github/workflows/deploy-kit.yml index 48ca3d2a7..c788e1a97 100644 --- a/.github/workflows/deploy-kit.yml +++ b/.github/workflows/deploy-kit.yml @@ -9,12 +9,17 @@ on: branches: [main] paths: - "packages/kit/**" + # kit.openiap.dev/mcp is served by kit's Fly binary importing + # @hyodotdev/openiap-mcp-server/web straight from source, so an + # MCP-server change must redeploy kit or it never ships (issue #287). + - "packages/mcp-server/**" - ".github/workflows/deploy-kit.yml" - "bun.lock" - "package.json" pull_request: paths: - "packages/kit/**" + - "packages/mcp-server/**" - ".github/workflows/deploy-kit.yml" - "bun.lock" - "package.json" @@ -56,6 +61,15 @@ jobs: - name: Run tests (convex + server unit tests) run: bun run test + - name: Lint + test MCP server (served by kit's /mcp route) + # kit's Fly binary imports @hyodotdev/openiap-mcp-server/web from + # source, so its regressions ship with kit deploys. This workflow + # is the only CI that runs the MCP server's own suite. + working-directory: packages/mcp-server + run: | + bun run lint + bun run test + - name: Vite build env: VITE_KIT_CONVEX_URL: https://placeholder-build-1.convex.cloud diff --git a/packages/mcp-server/src/http.ts b/packages/mcp-server/src/http.ts index 63fc9cb55..6f59fcadc 100644 --- a/packages/mcp-server/src/http.ts +++ b/packages/mcp-server/src/http.ts @@ -21,6 +21,11 @@ import { IAPKIT_MCP_SERVER_NAME, IAPKIT_MCP_SERVER_VERSION, } from "./mcp.js"; +import { + buildSessionId, + currentMachineId, + routeUnknownSession, +} from "./session-routing.js"; const DEFAULT_MCP_PATH = "/mcp"; const DEFAULT_PORT = 3939; @@ -48,6 +53,13 @@ export interface RemoteMcpHttpServerOptions { allowedOrigins?: string[]; /** Logger for lifecycle and request failures. Defaults to console. */ logger?: Pick; + /** + * Identity of this process for session affinity. Defaults to + * FLY_MACHINE_ID; session ids are prefixed with it so a follow-up + * request landing on a sibling machine can be replayed to the owner + * (GitHub issue #287). Undefined disables replay routing. + */ + machineId?: string; } /** Runtime handle for an IAPKit remote MCP HTTP server. */ @@ -72,6 +84,7 @@ export function createRemoteMcpHttpServer( const allowedOrigins = options.allowedOrigins ?? parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS); + const machineId = options.machineId ?? currentMachineId(); const transports = new Map(); const server = createServer(async (req, res) => { @@ -134,12 +147,13 @@ export function createRemoteMcpHttpServer( res, transports, logger, + machineId, ); return; } if (req.method === "GET" || req.method === "DELETE") { - await handleExistingMcpSession(req, res, transports); + await handleExistingMcpSession(req, res, transports, machineId); return; } @@ -223,6 +237,7 @@ async function handleMcpPost( res: ServerResponse, transports: Map, logger: Pick, + machineId: string | undefined, ): Promise { const sessionId = headerString(req.headers["mcp-session-id"]); const body = await readJsonBody(req); @@ -233,7 +248,12 @@ async function handleMcpPost( return; } - if (sessionId || !isInitializeRequest(body)) { + if (sessionId) { + writeUnknownSessionResponse(req, res, sessionId, machineId); + return; + } + + if (!isInitializeRequest(body)) { writeJsonRpcError( res, 400, @@ -245,7 +265,7 @@ async function handleMcpPost( let transport!: StreamableHTTPServerTransport; transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + sessionIdGenerator: () => buildSessionId(machineId, randomUUID()), onsessioninitialized: (initializedSessionId) => { transports.set(initializedSessionId, transport); logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`); @@ -269,11 +289,16 @@ async function handleExistingMcpSession( req: IncomingMessage, res: ServerResponse, transports: Map, + machineId: string | undefined, ): Promise { const sessionId = headerString(req.headers["mcp-session-id"]); const transport = sessionId ? transports.get(sessionId) : undefined; if (!transport) { + if (sessionId) { + writeUnknownSessionResponse(req, res, sessionId, machineId); + return; + } writeJsonRpcError(res, 400, -32000, "Invalid or missing mcp-session-id"); return; } @@ -281,6 +306,43 @@ async function handleExistingMcpSession( await transport.handleRequest(req as AuthenticatedRequest, res); } +/** + * Answers a request whose session id isn't in this process's transport + * map: replay it to the machine that minted the id when possible, + * otherwise 404 so a spec-compliant client transparently re-initializes. + * (The previous 400 "initialize first" reply broke that recovery path — + * GitHub issue #287.) + */ +function writeUnknownSessionResponse( + req: IncomingMessage, + res: ServerResponse, + sessionId: string, + machineId: string | undefined, +): void { + const routing = routeUnknownSession({ + sessionId, + machineId, + alreadyReplayed: req.headers["fly-replay-src"] !== undefined, + }); + + if (routing.action === "replay") { + // Fly's proxy intercepts any response carrying `fly-replay` and + // re-sends the original request to the named machine; the client + // never sees this interim response. + res + .writeHead(204, { "fly-replay": `instance=${routing.targetMachineId}` }) + .end(); + return; + } + + writeJsonRpcError( + res, + 404, + -32001, + "Session not found — initialize a new MCP session.", + ); +} + function attachAuthInfo(req: AuthenticatedRequest): void { const bearerToken = parseBearerToken(headerString(req.headers.authorization)); if (!bearerToken) return; diff --git a/packages/mcp-server/src/session-routing.ts b/packages/mcp-server/src/session-routing.ts new file mode 100644 index 000000000..2309577f9 --- /dev/null +++ b/packages/mcp-server/src/session-routing.ts @@ -0,0 +1,73 @@ +// MCP session ids are held in per-process memory (the transport object +// itself is stateful — an SSE stream can't be serialized into a shared +// store), so a session created on one Fly machine is invisible to its +// siblings. Fix (GitHub issue #287): embed the creating machine's id in +// the session id, and when a request lands on the wrong machine, answer +// with a `fly-replay` header so Fly's proxy re-routes the original +// request to the owner. Off Fly (no FLY_MACHINE_ID) session ids stay +// plain UUIDs and routing always resolves to `not-found`. + +/** + * Fly machine ids are lowercase hex today, but only shape-check them: + * the prefix is attacker-controlled (it arrives inside the client's + * `mcp-session-id` header), so the pattern also guards the value we + * echo back inside the `fly-replay` response header. + */ +const MACHINE_ID_PATTERN = /^[A-Za-z0-9]{1,32}$/; + +const SESSION_MACHINE_SEPARATOR = "."; + +/** Reads the Fly machine identity, or undefined when not running on Fly. */ +export function currentMachineId( + env: Record = process.env, +): string | undefined { + const raw = env.FLY_MACHINE_ID; + return raw && MACHINE_ID_PATTERN.test(raw) ? raw : undefined; +} + +/** Builds a session id that carries the creating machine's identity. */ +export function buildSessionId( + machineId: string | undefined, + uuid: string, +): string { + return machineId ? `${machineId}${SESSION_MACHINE_SEPARATOR}${uuid}` : uuid; +} + +/** Routing decision for a session id this process doesn't recognize. */ +export type UnknownSessionRouting = + | { action: "replay"; targetMachineId: string } + | { action: "not-found" }; + +/** + * Decides what to do with a session id that isn't in the local + * transport map. + * + * @param options.sessionId Session id from the `mcp-session-id` header. + * @param options.machineId This process's machine id (undefined off Fly). + * @param options.alreadyReplayed True when the request carries + * `fly-replay-src`, i.e. it was already replayed once — never replay + * again or two stale machines could bounce a request forever. + * @returns `replay` toward the owning machine, or `not-found` (the + * caller answers 404 so the client re-initializes per the MCP spec). + */ +export function routeUnknownSession(options: { + sessionId: string; + machineId: string | undefined; + alreadyReplayed: boolean; +}): UnknownSessionRouting { + if (!options.machineId || options.alreadyReplayed) { + return { action: "not-found" }; + } + + const separatorIndex = options.sessionId.indexOf(SESSION_MACHINE_SEPARATOR); + if (separatorIndex <= 0) return { action: "not-found" }; + + const prefix = options.sessionId.slice(0, separatorIndex); + if (!MACHINE_ID_PATTERN.test(prefix) || prefix === options.machineId) { + // Malformed prefix, or the session was minted by this very machine + // (map lost to a restart/deploy) — replaying to ourselves would loop. + return { action: "not-found" }; + } + + return { action: "replay", targetMachineId: prefix }; +} diff --git a/packages/mcp-server/src/web.ts b/packages/mcp-server/src/web.ts index dc3a73fcb..00b226660 100644 --- a/packages/mcp-server/src/web.ts +++ b/packages/mcp-server/src/web.ts @@ -9,6 +9,11 @@ import { isPublishableApiKey, } from "./auth.js"; import { createIapKitMcpServer } from "./mcp.js"; +import { + buildSessionId, + currentMachineId, + routeUnknownSession, +} from "./session-routing.js"; const MAX_MCP_BODY_BYTES = 1024 * 1024; const MCP_BODY_TOO_LARGE_ERROR = "MCP request body is too large"; @@ -24,6 +29,13 @@ const DEFAULT_ALLOWED_ORIGINS = [ export interface IapKitWebMcpHandlerOptions { allowedOrigins?: string[]; logger?: Pick; + /** + * Identity of this process for session affinity. Defaults to + * FLY_MACHINE_ID; session ids are prefixed with it so a follow-up + * request landing on a sibling machine can be replayed to the owner + * (GitHub issue #287). Undefined disables replay routing. + */ + machineId?: string; } export function createIapKitWebMcpHandler( @@ -33,6 +45,7 @@ export function createIapKitWebMcpHandler( const allowedOrigins = options.allowedOrigins ?? parseAllowedOrigins(process.env.IAPKIT_MCP_ALLOWED_ORIGINS); + const machineId = options.machineId ?? currentMachineId(); const transports = new Map< string, WebStandardStreamableHTTPServerTransport @@ -69,6 +82,7 @@ export function createIapKitWebMcpHandler( transports, logger, authInfo, + machineId, ); return withCors(request, response, allowedOrigins); } @@ -78,6 +92,7 @@ export function createIapKitWebMcpHandler( request, transports, authInfo, + machineId, ); return withCors(request, response, allowedOrigins); } @@ -117,6 +132,7 @@ async function handlePost( transports: Map, logger: Pick, authInfo: AuthInfo | undefined, + machineId: string | undefined, ): Promise { const sessionId = request.headers.get("mcp-session-id") ?? undefined; const body = await readJsonBody(request); @@ -129,7 +145,11 @@ async function handlePost( }); } - if (sessionId || !isInitializeRequest(body)) { + if (sessionId) { + return unknownSessionResponse(request, sessionId, machineId); + } + + if (!isInitializeRequest(body)) { return jsonRpcError( 400, -32000, @@ -139,7 +159,7 @@ async function handlePost( let transport!: WebStandardStreamableHTTPServerTransport; transport = new WebStandardStreamableHTTPServerTransport({ - sessionIdGenerator: () => randomUUID(), + sessionIdGenerator: () => buildSessionId(machineId, randomUUID()), onsessioninitialized: (initializedSessionId) => { transports.set(initializedSessionId, transport); logger.info(`IAPKit MCP session initialized: ${initializedSessionId}`); @@ -167,17 +187,56 @@ async function handleExistingSession( request: Request, transports: Map, authInfo: AuthInfo | undefined, + machineId: string | undefined, ): Promise { const sessionId = request.headers.get("mcp-session-id") ?? undefined; const transport = sessionId ? transports.get(sessionId) : undefined; if (!transport) { + if (sessionId) { + return unknownSessionResponse(request, sessionId, machineId); + } return jsonRpcError(400, -32000, "Invalid or missing mcp-session-id"); } return transport.handleRequest(request, { authInfo }); } +/** + * Answers a request whose session id isn't in this process's transport + * map: replay it to the machine that minted the id when possible, + * otherwise 404 so a spec-compliant client transparently re-initializes. + * (The previous 400 "initialize first" reply broke that recovery path — + * GitHub issue #287.) + */ +function unknownSessionResponse( + request: Request, + sessionId: string, + machineId: string | undefined, +): Response { + const routing = routeUnknownSession({ + sessionId, + machineId, + alreadyReplayed: request.headers.has("fly-replay-src"), + }); + + if (routing.action === "replay") { + // Fly's proxy intercepts any response carrying `fly-replay` and + // re-sends the original request to the named machine; the client + // never sees this interim response. + return new Response(null, { + status: 204, + headers: { "fly-replay": `instance=${routing.targetMachineId}` }, + }); + } + + return jsonRpcError( + 404, + -32001, + "Session not found — initialize a new MCP session.", + ); +} + function authInfoFromRequest(request: Request): AuthInfo | undefined { const token = parseBearerToken(request.headers.get("authorization")); if (!token) return undefined; diff --git a/packages/mcp-server/test/http.test.ts b/packages/mcp-server/test/http.test.ts index 049052602..034d329e9 100644 --- a/packages/mcp-server/test/http.test.ts +++ b/packages/mcp-server/test/http.test.ts @@ -599,6 +599,54 @@ describe("remote MCP HTTP server", () => { expect(payload.info).toContain("/v1/webhooks/{publishableKey}"); }); + it("replays foreign-machine sessions and 404s unrecoverable ones (issue #287)", async () => { + const baseUrl = await startServer({ machineId: "self42" }); + + const init = await postMcp(baseUrl, { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "vitest", version: "0.0.0" }, + }, + }); + expect(init.headers.get("mcp-session-id")).toMatch( + /^self42\.[0-9a-f-]{36}$/, + ); + await init.text(); + + const foreign = await postMcp( + baseUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + "other77.7e33e2b1-9a45-4c8e-b1de-000000000000", + ); + expect(foreign.status).toBe(204); + expect(foreign.headers.get("fly-replay")).toBe("instance=other77"); + + const replayed = await postMcp( + baseUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + "other77.7e33e2b1-9a45-4c8e-b1de-000000000000", + { "fly-replay-src": "instance=other77;state=;t=1754400000000000" }, + ); + expect(replayed.status).toBe(404); + await expect(replayed.json()).resolves.toMatchObject({ + error: { + code: -32001, + message: "Session not found — initialize a new MCP session.", + }, + }); + + const lostOwn = await postMcp( + baseUrl, + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + "self42.7e33e2b1-9a45-4c8e-b1de-000000000000", + ); + expect(lostOwn.status).toBe(404); + }); + it("returns client errors for invalid JSON and oversized payloads", async () => { const baseUrl = await startServer(); @@ -722,12 +770,13 @@ describe("remote MCP HTTP server", () => { }); }); -async function startServer(): Promise { +async function startServer(options?: { machineId?: string }): Promise { remote = createRemoteMcpHttpServer({ logger: { error: () => undefined, info: () => undefined, }, + ...options, }); await new Promise((resolve) => { diff --git a/packages/mcp-server/test/session-routing.test.ts b/packages/mcp-server/test/session-routing.test.ts new file mode 100644 index 000000000..6e39d2c58 --- /dev/null +++ b/packages/mcp-server/test/session-routing.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + buildSessionId, + currentMachineId, + routeUnknownSession, +} from "../src/session-routing"; + +describe("currentMachineId", () => { + it("reads a well-formed FLY_MACHINE_ID", () => { + expect(currentMachineId({ FLY_MACHINE_ID: "17811953c25489" })).toBe( + "17811953c25489", + ); + }); + + it("returns undefined off Fly or for malformed ids", () => { + expect(currentMachineId({})).toBeUndefined(); + expect(currentMachineId({ FLY_MACHINE_ID: "" })).toBeUndefined(); + expect(currentMachineId({ FLY_MACHINE_ID: "bad.value" })).toBeUndefined(); + expect(currentMachineId({ FLY_MACHINE_ID: "a".repeat(33) })).toBeUndefined(); + }); +}); + +describe("buildSessionId", () => { + it("prefixes the machine id when present", () => { + expect(buildSessionId("m1", "uuid-1")).toBe("m1.uuid-1"); + }); + + it("returns the bare uuid off Fly", () => { + expect(buildSessionId(undefined, "uuid-1")).toBe("uuid-1"); + }); +}); + +describe("routeUnknownSession", () => { + it("replays to the machine that minted the session id", () => { + expect( + routeUnknownSession({ + sessionId: "other77.uuid-1", + machineId: "self42", + alreadyReplayed: false, + }), + ).toEqual({ action: "replay", targetMachineId: "other77" }); + }); + + it("never replays a request that was already replayed once", () => { + expect( + routeUnknownSession({ + sessionId: "other77.uuid-1", + machineId: "self42", + alreadyReplayed: true, + }), + ).toEqual({ action: "not-found" }); + }); + + it("never replays to itself (map lost to a restart)", () => { + expect( + routeUnknownSession({ + sessionId: "self42.uuid-1", + machineId: "self42", + alreadyReplayed: false, + }), + ).toEqual({ action: "not-found" }); + }); + + it("does not replay off Fly", () => { + expect( + routeUnknownSession({ + sessionId: "other77.uuid-1", + machineId: undefined, + alreadyReplayed: false, + }), + ).toEqual({ action: "not-found" }); + }); + + it("rejects unprefixed or malformed session ids", () => { + for (const sessionId of [ + "plain-uuid-without-prefix", + ".uuid-1", + "bad prefix.uuid-1", + `${"a".repeat(33)}.uuid-1`, + "inject=1\r\n.uuid-1", + ]) { + expect( + routeUnknownSession({ + sessionId, + machineId: "self42", + alreadyReplayed: false, + }), + ).toEqual({ action: "not-found" }); + } + }); +}); diff --git a/packages/mcp-server/test/web.test.ts b/packages/mcp-server/test/web.test.ts new file mode 100644 index 000000000..c0323dceb --- /dev/null +++ b/packages/mcp-server/test/web.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; + +import { createIapKitWebMcpHandler } from "../src/web"; + +// Regression suite for GitHub issue #287: the hosted /mcp endpoint kept +// per-process session state, so a valid mcp-session-id landing on a +// sibling Fly machine was rejected with 400 "initialize first". The web +// handler must instead (a) mint machine-prefixed session ids, (b) replay +// foreign-machine sessions via `fly-replay`, and (c) answer 404 (not +// 400) for sessions it genuinely cannot serve so spec-compliant clients +// transparently re-initialize. + +const silentLogger = { error: () => undefined, info: () => undefined }; + +function createHandler(machineId?: string) { + return createIapKitWebMcpHandler({ logger: silentLogger, machineId }); +} + +function initializeRequest(sessionId?: string): Request { + return mcpRequest( + { + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "vitest", version: "0.0.0" }, + }, + }, + sessionId, + ); +} + +function toolsListRequest( + sessionId: string, + headers: Record = {}, +): Request { + return mcpRequest( + { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }, + sessionId, + headers, + ); +} + +function mcpRequest( + body: unknown, + sessionId?: string, + headers: Record = {}, +): Request { + return new Request("http://localhost/mcp", { + method: "POST", + headers: { + accept: "application/json, text/event-stream", + "content-type": "application/json", + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + ...headers, + }, + body: JSON.stringify(body), + }); +} + +describe("web MCP handler session routing", () => { + it("prefixes session ids with the machine id on Fly", async () => { + const handler = createHandler("self42"); + const response = await handler(initializeRequest()); + + expect(response.status).toBe(200); + const sessionId = response.headers.get("mcp-session-id"); + expect(sessionId).toMatch(/^self42\.[0-9a-f-]{36}$/); + }); + + it("keeps bare-UUID session ids off Fly", async () => { + const handler = createHandler(undefined); + const response = await handler(initializeRequest()); + + expect(response.status).toBe(200); + expect(response.headers.get("mcp-session-id")).toMatch(/^[0-9a-f-]{36}$/); + }); + + it("serves follow-up requests on a session it owns", async () => { + const handler = createHandler("self42"); + const init = await handler(initializeRequest()); + const sessionId = init.headers.get("mcp-session-id") ?? ""; + await init.text(); + + const list = await handler(toolsListRequest(sessionId)); + expect(list.status).toBe(200); + }); + + it("replays a foreign machine's session via fly-replay", async () => { + const handler = createHandler("self42"); + const response = await handler( + toolsListRequest("other77.7e33e2b1-9a45-4c8e-b1de-000000000000"), + ); + + expect(response.status).toBe(204); + expect(response.headers.get("fly-replay")).toBe("instance=other77"); + }); + + it("returns 404 instead of replaying twice", async () => { + const handler = createHandler("self42"); + const response = await handler( + toolsListRequest("other77.7e33e2b1-9a45-4c8e-b1de-000000000000", { + "fly-replay-src": "instance=other77;state=;t=1754400000000000", + }), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: -32001, + message: "Session not found — initialize a new MCP session.", + }, + }); + }); + + it("returns 404 for its own session id after a restart wiped the map", async () => { + const handler = createHandler("self42"); + const response = await handler( + toolsListRequest("self42.7e33e2b1-9a45-4c8e-b1de-000000000000"), + ); + + expect(response.status).toBe(404); + }); + + it("returns 404 for unknown sessions off Fly", async () => { + const handler = createHandler(undefined); + const response = await handler( + toolsListRequest("7e33e2b1-9a45-4c8e-b1de-000000000000"), + ); + + expect(response.status).toBe(404); + await expect(response.json()).resolves.toMatchObject({ + error: { code: -32001 }, + }); + }); + + it("routes GET and DELETE for foreign sessions the same way", async () => { + const handler = createHandler("self42"); + + for (const method of ["GET", "DELETE"] as const) { + const response = await handler( + new Request("http://localhost/mcp", { + method, + headers: { + accept: "application/json, text/event-stream", + "mcp-session-id": "other77.7e33e2b1-9a45-4c8e-b1de-000000000000", + }, + }), + ); + expect(response.status).toBe(204); + expect(response.headers.get("fly-replay")).toBe("instance=other77"); + } + }); + + it("still 400s a POST that has no session and is not initialize", async () => { + const handler = createHandler("self42"); + const response = await handler( + mcpRequest({ jsonrpc: "2.0", id: 2, method: "tools/list", params: {} }), + ); + + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ + error: { + code: -32000, + message: + "Bad Request: initialize first, then send mcp-session-id on follow-up requests.", + }, + }); + }); + + it("still 400s GET/DELETE without any session id", async () => { + const handler = createHandler("self42"); + const response = await handler( + new Request("http://localhost/mcp", { + method: "DELETE", + headers: { accept: "application/json, text/event-stream" }, + }), + ); + + expect(response.status).toBe(400); + }); +});