Summary
McpServer.layerHttp implements the MCP Streamable HTTP transport with per-process, in-memory session state: initialize mints a session id, stores the client's initialize payload in a Map, and returns Mcp-Session-Id; later requests look the payload up by that id. On serverless runtimes (Cloudflare Workers, Lambda, or any horizontally scaled deployment without sticky routing), the follow-up request routinely lands on a fresh isolate whose map is empty, so tools/list / tools/call fail even though the client did everything right.
The MCP spec (2025-06-18, Streamable HTTP) explicitly allows stateless servers — a server MAY choose not to issue a session id, and clients must handle servers that don't. There is currently no supported way to run layerHttp in that mode.
Reproduction (effect@4.0.0-beta.101)
import { McpServer } from "effect/unstable/ai"
import { HttpRouter } from "effect/unstable/http"
import * as Layer from "effect/Layer"
const ServerLayer = McpServer.layerHttp({ name: "repro", version: "1.0.0", path: "/mcp" })
// build a web handler per isolate, as a Workers runtime does:
const { handler } = HttpRouter.toWebHandler(Layer.mergeAll(ServerLayer /* + toolkit */))
// Isolate A: initialize succeeds, session id S returned.
// Isolate B (fresh handler, same client): tools/list with Mcp-Session-Id: S
// -> session lookup fails; request is not served as initialized.
const res = await handler(new Request("http://local/mcp", {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json, text/event-stream",
"mcp-protocol-version": "2025-06-18",
},
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list" }),
}))
Ask
A first-class stateless mode on layerHttp, e.g.:
McpServer.layerHttp({ name, version, path, sessionMode: "stateless" })
which:
- never issues
Mcp-Session-Id on initialize responses, and
- for requests arriving without usable session state, synthesizes a conservative initialize payload from the negotiated
MCP-Protocol-Version header instead of failing.
What we run in production today
We patch McpServer with exactly that fallback (works well on Workers; happy to turn this into a PR if the direction is acceptable):
const statelessHttpInitializePayload = (
headers: Headers.Headers
): typeof Initialize.payloadSchema.Type | undefined => {
const protocolVersion = headers[mcpProtocolVersionHeader]
if (protocolVersion === undefined) return undefined
// Streamable HTTP requests can land on a fresh Worker isolate, so the
// in-memory initialize payload may be gone between requests. Fall back to a
// conservative client payload derived from the negotiated protocol version.
return {
protocolVersion,
capabilities: {},
clientInfo: { name: "streamable-http-client", version: "unknown" }
}
}
const getInitializedClient = (sessions, clientId, headers) => {
const sessionId = headers[mcpSessionIdHeader]
if (sessionId === undefined) {
return sessions.get(String(clientId)) ?? statelessHttpInitializePayload(headers)
}
return sessions.get(sessionId) ?? statelessHttpInitializePayload(headers)
}
plus removing the Mcp-Session-Id response-header write on initialize.
Summary
McpServer.layerHttpimplements the MCP Streamable HTTP transport with per-process, in-memory session state:initializemints a session id, stores the client's initialize payload in aMap, and returnsMcp-Session-Id; later requests look the payload up by that id. On serverless runtimes (Cloudflare Workers, Lambda, or any horizontally scaled deployment without sticky routing), the follow-up request routinely lands on a fresh isolate whose map is empty, sotools/list/tools/callfail even though the client did everything right.The MCP spec (2025-06-18, Streamable HTTP) explicitly allows stateless servers — a server MAY choose not to issue a session id, and clients must handle servers that don't. There is currently no supported way to run
layerHttpin that mode.Reproduction (
effect@4.0.0-beta.101)Ask
A first-class stateless mode on
layerHttp, e.g.:which:
Mcp-Session-Idoninitializeresponses, andMCP-Protocol-Versionheader instead of failing.What we run in production today
We patch
McpServerwith exactly that fallback (works well on Workers; happy to turn this into a PR if the direction is acceptable):plus removing the
Mcp-Session-Idresponse-header write oninitialize.