Skip to content

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

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.

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