// Cloud: regression test for a session-DO startup race. // // 1. Open an MCP session (real SDK client init, capture mcp-session-id), // make a call, close the client. // 2. Idle past the session timeout so the DO's alarm runs disposeIdleRuntime // (runtime torn down, `initialized = false`, transport cleared). // 3. Fire an SSE GET (listen stream) and a POST (tools/list) concurrently on // the same session id. // 4. The session must survive: the concurrent pair and follow-up calls // should succeed. // // Mechanism guarded against: a request carrying a session id runs // validateMcpSessionOwner, which restores via onStart when the runtime is // disposed. The SDK's serve() streaming handler separately wakes the DO // through agent.fetch, and the Agent base also drives onStart. Without // serialization across both entry paths, two onStart calls interleave and the // second server.connect throws "Already connected to a transport", after // which every request on the session fails the same way. The concurrent pair // itself can answer 200/200 (one restore wins the race), so the assertions // cover the follow-up calls, which is where the failure persists. // import { writeFileSync } from "node:fs"; import { join } from "node:path"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { scenario } from "../src/scenario"; import { Mcp, RunDir, Target } from "../src/services"; import type { Identity } from "../src/target"; import { configuredMcpSessionTimeoutMs } from "../setup/mcp-session-timeouts"; const emailOf = (identity: Identity): string => identity.credentials?.email ?? identity.label; // Give the DO's idle alarm room to fire disposeIdleRuntime after the timeout. const IDLE_DISPOSE_BUFFER_MS = 2_000; const IDLE_DISPOSE_GAP_MS = configuredMcpSessionTimeoutMs() + IDLE_DISPOSE_BUFFER_MS; const SCENARIO_TIMEOUT_MS = IDLE_DISPOSE_GAP_MS + 120_000; interface Connected { readonly client: Client; readonly transport: StreamableHTTPClientTransport; } const connectClient = async (mcpUrl: string, bearer: string): Promise => { const client = new Client({ name: "executor-e2e-brick", version: "0.0.1" }, { capabilities: {} }); const transport = new StreamableHTTPClientTransport(new URL(mcpUrl), { requestInit: { headers: { authorization: `Bearer ${bearer}` } }, }); await client.connect(transport); return { client, transport }; }; interface RawResult { readonly status: number; readonly body: string; } /** A raw POST of a JSON-RPC request onto an existing session id. */ const rawPost = async ( mcpUrl: string, bearer: string, sessionId: string, message: unknown, ): Promise => { const res = await fetch(mcpUrl, { method: "POST", headers: { authorization: `Bearer ${bearer}`, "content-type": "application/json", // The streamable-http transport requires the client accept both. accept: "application/json, text/event-stream", "mcp-session-id": sessionId, "mcp-protocol-version": "2025-06-18", }, body: JSON.stringify(message), }); const body = await res.text().catch(() => ""); return { status: res.status, body }; }; /** A raw SSE GET (the "listen" stream) on an existing session id. We only need * the response head โ€” status + start of body โ€” to see whether it 500s. */ const rawSseGet = async (mcpUrl: string, bearer: string, sessionId: string): Promise => { const res = await fetch(mcpUrl, { method: "GET", headers: { authorization: `Bearer ${bearer}`, accept: "text/event-stream", "mcp-session-id": sessionId, "mcp-protocol-version": "2025-06-18", }, }); // A 200 opens a long-lived stream; don't hang reading it. A 500 has a short // (usually empty) body we can read fully. let body = ""; if (res.status !== 200) { body = await res.text().catch(() => ""); } else { // Cancel the stream immediately; we only wanted the status. await res.body?.cancel().catch(() => undefined); } return { status: res.status, body }; }; const toolsListMessage = { jsonrpc: "2.0" as const, id: "brick-tools-list", method: "tools/list" }; const isBrick = (r: RawResult): boolean => r.status >= 500 || /"code"\s*:\s*-32603/.test(r.body) || /Already connected/i.test(r.body); scenario( "REGRESSION ยท concurrent SSE GET + POST after idle-dispose keeps the session alive", { timeout: SCENARIO_TIMEOUT_MS }, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; const runDir = yield* RunDir; const identity = yield* target.newIdentity(); const bearer = yield* mcp.mintBearer(emailOf(identity)); const diagnostics: unknown[] = []; const record = (entry: Record): void => { diagnostics.push(entry); console.log(JSON.stringify(entry)); // Vitest swallows worker stdout on passing runs; the artifact dir is the // durable copy of what each attempt observed. writeFileSync(join(runDir, "repro-diagnostics.json"), JSON.stringify(diagnostics, null, 2)); }; // 1. Open a real session (SDK init handshake persists the session in the // DO), do a call, close the client. const first = yield* Effect.promise(() => connectClient(target.mcpUrl, bearer)); const sessionId = first.transport.sessionId; expect(sessionId, "the client got a session id").toEqual(expect.any(String)); if (sessionId === undefined) return yield* Effect.die("missing session id"); yield* Effect.promise(() => first.client.callTool({ name: "execute", arguments: { code: "return 1;" } }), ); yield* Effect.promise(() => first.client.close().catch(() => undefined)); // 2. Idle past the timeout so the DO's alarm runs disposeIdleRuntime. yield* Effect.sleep(IDLE_DISPOSE_GAP_MS); // 3. Fire the SSE GET and the POST CONCURRENTLY on the same session id. // Raw fetch gives precise concurrency control (both start before either // resolves) that the SDK client's request queue would serialize away. const [getResult, postResult] = yield* Effect.promise(() => Promise.all([ rawSseGet(target.mcpUrl, bearer, sessionId), rawPost(target.mcpUrl, bearer, sessionId, toolsListMessage), ]), ); record({ event: "repro_concurrent_pair", sessionId, get: getResult, post: postResult, }); const concurrentBricked = isBrick(getResult) || isBrick(postResult); // 4. Prove the session survives: subsequent SEQUENTIAL POSTs also succeed. // This is the same control path as the idle-restore scenario from #1302. // // Observed locally: the concurrent pair frequently returns 200/200 (one // of the two restores wins the race and answers), but the race leaves // the DO's McpServer in the "Already connected to a transport" state, so // the very NEXT request 500s โ€” and every request after that. The // regression gate checks the same two follow-up calls now stay healthy. const followUp = yield* Effect.promise(() => rawPost(target.mcpUrl, bearer, sessionId, { ...toolsListMessage, id: "brick-follow-up", }), ); const followUp2 = yield* Effect.promise(() => rawPost(target.mcpUrl, bearer, sessionId, { ...toolsListMessage, id: "brick-follow-up-2", }), ); record({ event: "repro_follow_up", sessionId, followUp, followUp2 }); const permanentlyBricked = isBrick(followUp) && isBrick(followUp2); expect(getResult.status, "the concurrent SSE GET opens successfully").toBe(200); expect(postResult.status, "the concurrent POST answers successfully").toBe(200); expect(isBrick(getResult), "the concurrent SSE GET does not expose the transport brick").toBe( false, ); expect(isBrick(postResult), "the concurrent POST does not expose the transport brick").toBe( false, ); expect(followUp.status, "the first follow-up POST answers successfully").toBe(200); expect(followUp2.status, "the second follow-up POST answers successfully").toBe(200); expect(isBrick(followUp), "the first follow-up POST does not expose the transport brick").toBe( false, ); expect( isBrick(followUp2), "the second follow-up POST does not expose the transport brick", ).toBe(false); expect( permanentlyBricked, "after the concurrent SSE-GET + POST post-idle, the session remains usable", ).toBe(false); // Diagnostic (not a gate): whether the collision surfaced on the concurrent // pair itself vs. only on the follow-up depends on which restore won the // race. Record it so the artifact shows the timing that occurred this run. record({ event: "repro_variant", concurrentBricked, permanentlyBricked }); }), );