Skip to content

McpServer.layerHttp: support a stateless Streamable HTTP mode for serverless runtimes #6752

Open

Description

@craigsmitham

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:

  1. never issues Mcp-Session-Id on initialize responses, and
  2. 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.

Activity

  1. pullfrog commented on Jul 29, 2026

    @pullfrog
    Contributor
  2. spencerbeggs commented on Aug 23, 2026

    @spencerbeggs
    Contributor

    @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.

  3. maxprilutskiy commented on Oct 15, 2026

    @maxprilutskiy
    Contributor

    Would love to see this solved soon 🙏

  4. artile commented on Oct 25, 2026

    @artile

    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):

    • initialize always returns Mcp-Session-Id. A later request without it gets 400; with an id that the isolate does not know, 404. There is no option to turn this off for the 2025 revisions.
    • A server built per request avoids shared state, but the session that initialize makes ends with that server, so the next call gets 404.

    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 from MCP-Protocol-Version on each request. We would move back to McpServer when that mode lands.

    One thing to keep in the design: in stateless mode, the per-request visibility from #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.

  5. 5 remaining items

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions