← back/blog/mcp-as-transport

the Room Protocol · transport

MCP as transport: stateless HTTP over long-lived SSE

A coordination protocol is only as useful as the number of agents that can speak it. Riding on MCP meant every capable agent could join with zero custom client code — but only if I got the transport and the tool surface right.

10 min read·shreyaspadmakiran.com

The four earlier parts of this series were about what the room is: the primitives, the concurrency, the single-store architecture, the typed context. This one is about how an agent actually reaches it. That question has a boring-sounding answer with a lot riding on it, because the transport decision is really a distribution decision. A coordination substrate that agents can't easily connect to is a coordination substrate nobody uses.

Act I — Why a protocol, not an SDK

The distribution problem

Set aside the internals and ask the plain question: how does a Claude Code instance call read_plan? The honest options were three.

A REST APIShip endpoints and hope agents call them. But an agent doesn't natively know your REST API exists or what it means — you'd have to prompt every agent with your entire API surface and pray it forms correct requests. No discovery, no typed inputs, all glue.
A custom SDKWrap the API in a library. Now you're maintaining and versioning a client per language, and every user has to install and wire it up. That's a real adoption tax for what is supposed to be a two-line setup.
MCPExpose the operations as MCP tools. Any MCP-capable agent discovers them from the server, with names, descriptions, and typed input schemas, and can call them immediately. No SDK, no per-language client, no API docs to stuff into a prompt.

MCP wins because it's the layer agents already speak for tool use. Discovery and typed schemas come with the protocol, so the agent learns what tools exist and how to call them by asking the server, not by reading my documentation. The moment roomd — the reference server — is in an agent's config, all 19 coordination tools are available to it with no further work. That's the difference between “integrate our SDK” and “paste this into settings.”

// the entire client-side integration — two lines of real config
{
  "mcpServers": {
    "room-protocol": {
      "type": "http",
      "url": "https://roomd.sh/mcp",
      "headers": { "Authorization": "Bearer YOUR_SECRET" }
    }
  }
}

The payoff is zero client lock-in and near-zero integration cost. It drops into whatever agent stack a team already runs, which is the cheapest possible path to adoption: you never ask anyone to change their tools, you ride the ones they already have.

Choosing MCP settles the “what protocol” question. It leaves a sharper one: MCP can be spoken over more than one transport, and the obvious default is the wrong one here.
Act II — Stateless HTTP over long-lived SSE

The transport most examples reach for

The familiar way to run an MCP server is a long-lived connection: the client opens a Server-Sent Events stream, a session is established, and it stays open with per-connection state held in the server's memory. It's the default in a lot of examples. It also quietly reintroduces every problem that statelessness was chosen to avoid: sessions pinned to one instance, sticky routing, connections dropped on every deploy, session state to migrate when scaling out.

What the Room Protocol uses instead

roomd uses the modern streamable-HTTP transport in stateless mode. There is no session to establish and no stream to keep alive. Every tool call is an independent HTTP request that builds a fresh server, does its work against Redis, and returns:

const transport = new WebStandardStreamableHTTPServerTransport({
  sessionIdGenerator: undefined,   // stateless: no session id, no pinning
});
const server = createMcpServer(keyCtx);  // fresh per request
await server.connect(transport);
return transport.handleRequest(c.req.raw);

This is the transport-level twin of the architecture bet. A stateless server over a stateless transport talking to a store over stateless HTTP requests is one coherent design from top to bottom. It's also why the client config says "type": "http" and not "sse"— a small line that reflects the whole stance. The cost is that the server can't push to the client, so agents poll for updates. For the current design that's an acceptable trade, and it's the honest limitation I'll come back to.

Long-lived SSE session versus stateless per-request transportLeft: a client pinned to one server instance by a persistent session. Right: independent requests fanning out to any of several interchangeable instances.long-lived SSE sessionagentinstance 1pinnedsticky · dies on deploystateless per requestagentinstance 1instance 2instance 3any instance · restart-safe
A session ties an agent to one instance. Stateless requests let any instance answer, which is what makes restart and scale-out free.
Act III — The tool surface is the UX

Tools designed for how agents actually work

With MCP, the tools arethe interface an agent sees. So their design is product design, not just API surface. Two tools exist purely because of how agents behave, and they're the clearest examples of treating the tool list as UX.

An agent frequently starts cold — a fresh session with no memory of the room. The naive path is five calls: read the plan, get my tasks, get unread events, count context, check presence. So there's one tool, get_my_summary, that returns all of it in a single round trip. It exists because a cold start is the most common and most latency-sensitive moment an agent has, and collapsing five calls into one is a real UX win for a machine just as much as for a person.

The second is get_unread_events, which reads from a per-agent cursor and advances it, so each agent gets every event exactly once without tracking anything client-side. The protocol encodes the intended usage pattern directly into the tool, rather than leaving the agent to implement “what have I already seen” itself. Good protocol design pushes the fiddly bookkeeping into the server where it's done once and correctly.

Under the hood each tool is registered with a description and a typed input schema, and every handler runs the room-access check before doing anything, so access control isn't a separate middleware an agent could route around — it's baked into every operation on shared state:

server.registerTool(
  "read_plan",
  { description: "Read the current plan (tasks, status, owners) for a room",
    inputSchema: readPlanInput.shape },
  async (input) => {
    await assertRoomAccess(input.roomId, keyCtx);   // ownership enforced here
    const result = await readPlan(input);
    return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
  },
);

What riding on MCP costs

The honest tradeoffs. MCP is young and still moving, so building on it means tracking a spec that changes — the shift away from the older SSE transport is exactly that kind of churn, and there will be more. Stateless transport means no server-initiated push yet, so “real-time” is really “polling on a short interval,” and instant notifications are a roadmap item that will likely want a pub/sub layer. And a growing tool count is its own risk: 19 is comfortable, but a coordination surface can sprawl, and every tool is something an agent has to understand. Keeping the set small and purposeful is ongoing work, not a solved thing.

None of that outweighs the core win. By building on the protocol agents already speak, the Room Protocol asks for essentially nothing from the people adopting it: no SDK, no new client, no tools to abandon. Two lines of config and an agent is in the room. The lowest-friction path wins almost every time, and “change nothing” is the easiest sell there is.

— shreyas

more in this series

01Coordinating agents through shared state · the idea

02Concurrency control for a shared plan · locks and cursors

03A stateless server on a single Redis · architecture

04Typed context over prose and vector search · context model

05MCP as transport · you are here

06Multi-tenancy on one Redis · isolation and access