Repository navigation
Description
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-Idoninitializeresponses, and - for requests arriving without usable session state, synthesizes a conservative initialize payload from the negotiated
MCP-Protocol-Versionheader 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.
Activity
pullfrog commentedon Jul 29, 2026
spencerbeggs commentedon Aug 23, 2026
@craigsmitham I was thinking something along the same lines last night. We might think about being able to provide a service to the later that performs the session look up. Seems like every stateless mcp that uses session is going to re-role that concept by hand.
maxprilutskiy commentedon Oct 15, 2026
Would love to see this solved soon 🙏
Merge main while preserving legacy stateless MCP behavior
artile commentedon Oct 25, 2026
Another production case for this, with numbers.
We run an MCP endpoint for a property-management app: 140 tools for an admin, 17 for a restricted role, a bearer check on every request, and no server-side session. Claude Code sends
MCP-Protocol-Version: 2025-06-18. We are moving the endpoint to Cloudflare Workers, so any request can reach a fresh isolate.What we found on
effect@4.0.0(spike, not production code):initializealways returnsMcp-Session-Id. A later request without it gets400; with an id that the isolate does not know,404. There is no option to turn this off for the 2025 revisions.initializemakes ends with that server, so the next call gets404.So we wrote our own small stateless endpoint on Effect instead of
McpServer. It does what the RFC in #8856 describes: no session id, a client profile fromMCP-Protocol-Versionon each request. We would move back toMcpServerwhen that mode lands.One thing to keep in the design: in stateless mode, the per-request
visibilityfrom #9661 and the per-request services from HTTP middleware (#9854) must still work, because each request carries its own bearer token and the tool list depends on the user.Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
5 remaining items
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements
Support legacy stateless Streamable HTTP across backend replacements