|
| 1 | +# Walkthrough: why the SDK fights stateless, and how to fix it |
| 2 | + |
| 3 | +This is a code walk, not a spec. I'm going to start in the current SDK, show where it hurts, and then show what the same thing looks like after the proposed split. The RFC has the formal proposal; this is the "let me show you" version. |
| 4 | + |
| 5 | +--- |
| 6 | + |
| 7 | +## Part 1: The current code |
| 8 | + |
| 9 | +### Start at the only entrance |
| 10 | + |
| 11 | +There is exactly one way to make an MCP server handle requests: |
| 12 | + |
| 13 | +```ts |
| 14 | +// packages/core/src/shared/protocol.ts:437 |
| 15 | +async connect(transport: Transport): Promise<void> { |
| 16 | + this._transport = transport; |
| 17 | + transport.onmessage = (message, extra) => { |
| 18 | + // route to _onrequest / _onresponse / _onnotification |
| 19 | + }; |
| 20 | + await transport.start(); |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +You hand it a long-lived `Transport`, it takes over the `onmessage` callback, and from then on requests arrive asynchronously. There is no `handle(request) → response`. If you want to call a handler, you go through a transport. |
| 25 | + |
| 26 | +`Transport` is shaped like a pipe: |
| 27 | + |
| 28 | +```ts |
| 29 | +// packages/core/src/shared/transport.ts:8 |
| 30 | +interface Transport { |
| 31 | + start(): Promise<void>; |
| 32 | + send(message: JSONRPCMessage): Promise<void>; |
| 33 | + onmessage?: (message, extra) => void; |
| 34 | + close(): Promise<void>; |
| 35 | + sessionId?: string; |
| 36 | + setProtocolVersion?(v: string): void; |
| 37 | +} |
| 38 | +``` |
| 39 | + |
| 40 | +`start`/`close` for lifecycle, fire-and-forget `send`, async `onmessage` callback. That's stdio's shape. It's also the shape every transport must implement, including HTTP. |
| 41 | + |
| 42 | +### Follow an HTTP request through |
| 43 | + |
| 44 | +The Streamable HTTP server transport is `packages/server/src/server/streamableHttp.ts` — 1038 lines. Let's follow a `tools/list` POST: |
| 45 | + |
| 46 | +1. User's Express handler calls `transport.handleRequest(req, res, body)` (line 176) |
| 47 | +2. `handlePostRequest` validates headers (217-268), parses body (282) |
| 48 | +3. Now it has a JSON-RPC request and needs to get it to the dispatcher. But the only path is `onmessage`. So it... calls `this.onmessage?.(msg, extra)` (370). Fire and forget. |
| 49 | +4. `Protocol._onrequest` runs the handler, gets a result, builds a response, calls `this._transport.send(response)` (634) |
| 50 | +5. Back in the transport, `send(response)` needs to find *which* HTTP response stream to write to. It looks up `_streamMapping[streamId]` (756) using a `relatedRequestId` that was threaded through. |
| 51 | + |
| 52 | +So the transport keeps a table mapping in-flight request IDs to open `Response` writers (`_streamMapping`, `_requestToStreamMapping`, ~80 LOC of bookkeeping), because `send()` is fire-and-forget and the response has to find its way back to the right HTTP response somehow. |
| 53 | + |
| 54 | +This is the core impedance mismatch: **HTTP is request→response, but the only interface is pipe-shaped, so the transport reconstructs request→response correlation on top of a pipe abstraction that sits on top of HTTP's native request→response.** |
| 55 | + |
| 56 | +### The session sniffing |
| 57 | + |
| 58 | +The transport also has to know about `initialize`: |
| 59 | + |
| 60 | +```ts |
| 61 | +// streamableHttp.ts:323 |
| 62 | +if (isInitializeRequest(body)) { |
| 63 | + if (this._sessionIdGenerator) { |
| 64 | + this.sessionId = this._sessionIdGenerator(); |
| 65 | + // ... onsessioninitialized callback |
| 66 | + } |
| 67 | + this._initialized = true; |
| 68 | +} |
| 69 | +``` |
| 70 | + |
| 71 | +A transport — whose job should be "bytes in, bytes out" — is parsing message bodies to detect a specific MCP method so it knows when to mint a session ID. There are 18 references to `initialize` in this file. The transport knows about the protocol's handshake. |
| 72 | + |
| 73 | +### What "stateless" looks like today |
| 74 | + |
| 75 | +The protocol direction (SEP-2575/2567) is: no `initialize`, no sessions, each request is independent. The SDK has a stateless mode. Here's the example: |
| 76 | + |
| 77 | +```ts |
| 78 | +// examples/server/src/simpleStatelessStreamableHttp.ts (paraphrased) |
| 79 | +app.post('/mcp', async (req, res) => { |
| 80 | + const server = new McpServer(...); // build a fresh server |
| 81 | + server.registerTool('greet', ..., ...); // register everything |
| 82 | + const transport = new StreamableHTTPServerTransport({ |
| 83 | + sessionIdGenerator: undefined // <-- magic flag |
| 84 | + }); |
| 85 | + await server.connect(transport); // connect to it |
| 86 | + await transport.handleRequest(req, res, req.body); |
| 87 | + // server and transport are GC'd after the request |
| 88 | +}); |
| 89 | +``` |
| 90 | + |
| 91 | +Stateless = **build and tear down a stateful server per request**, signaled by `sessionIdGenerator: undefined` taking a different code path through the same 1038-line transport. This works, but it's stateless-by-accident, not stateless-by-design. |
| 92 | + |
| 93 | +### Why is Protocol 1100 lines? |
| 94 | + |
| 95 | +`protocol.ts` is the abstract base for both `Server` and `Client`. It does: |
| 96 | + |
| 97 | +- handler registry (`_requestHandlers`, `setRequestHandler`) |
| 98 | +- outbound request/response correlation (`_responseHandlers`, `_requestMessageId`) |
| 99 | +- timeouts (`_timeoutInfo`, `_setupTimeout`, `_resetTimeout`) |
| 100 | +- progress callbacks (`_progressHandlers`) |
| 101 | +- debounced notifications (`_pendingDebouncedNotifications`) |
| 102 | +- cancellation (`_requestHandlerAbortControllers`) |
| 103 | +- TaskManager binding (`_bindTaskManager`) |
| 104 | +- 4 abstract `assert*Capability` methods subclasses must implement |
| 105 | +- `connect()` — wiring all of the above to a transport |
| 106 | + |
| 107 | +Some of those are per-connection state (correlation, timeouts, debounce). Some are pure routing (handler registry). Some are protocol semantics (capabilities). They're fused, so you can't get at the routing without the connection state. |
| 108 | + |
| 109 | +When you trace a request through, you bounce between `Protocol._onrequest`, `Server.buildContext`, `McpServer`'s registry handlers, back to `Protocol`'s send path. Three classes, two levels of inheritance. (Python folks will recognize this — "is BaseSession or ServerSession handling this line?") |
| 110 | + |
| 111 | +--- |
| 112 | + |
| 113 | +## Part 2: The proposed split |
| 114 | + |
| 115 | +### The primitive |
| 116 | + |
| 117 | +```ts |
| 118 | +class Dispatcher { |
| 119 | + setRequestHandler(method, handler): void; |
| 120 | + dispatch(req: JSONRPCRequest, env?: RequestEnv): AsyncIterable<DispatchOutput>; |
| 121 | +} |
| 122 | +``` |
| 123 | + |
| 124 | +A `Map<method, handler>` and a function that looks up + calls. `dispatch` yields zero-or-more notifications then exactly one response (matching SEP-2260's wire constraint). `RequestEnv` is per-request context the caller provides — `{sessionId?, authInfo?, signal?, send?}`. No transport. No connection state. ~270 LOC. |
| 125 | + |
| 126 | +That's it. You can call `dispatch` from anywhere — a test, a Lambda, a loop reading stdin. |
| 127 | + |
| 128 | +### The channel adapter |
| 129 | + |
| 130 | +For stdio/WebSocket/InMemory — things that *are* persistent pipes — `StreamDriver` wraps a `ChannelTransport` and a `Dispatcher`: |
| 131 | + |
| 132 | +```ts |
| 133 | +class StreamDriver { |
| 134 | + constructor(dispatcher, channel) { ... } |
| 135 | + start() { |
| 136 | + channel.onmessage = msg => { |
| 137 | + for await (const out of dispatcher.dispatch(msg, env)) channel.send(out); |
| 138 | + }; |
| 139 | + } |
| 140 | + request(req): Promise<Result>; // outbound, with correlation/timeout |
| 141 | +} |
| 142 | +``` |
| 143 | + |
| 144 | +This is where Protocol's per-connection half goes: `_responseHandlers`, `_timeoutInfo`, `_progressHandlers`, debounce. One driver per pipe; the dispatcher it wraps can be shared. ~450 LOC. |
| 145 | + |
| 146 | +`connect(channelTransport)` builds one of these. So `connect` still works exactly as before for stdio. |
| 147 | + |
| 148 | +### The request adapter |
| 149 | + |
| 150 | +For HTTP — things that are *not* persistent pipes — `shttpHandler`: |
| 151 | + |
| 152 | +```ts |
| 153 | +function shttpHandler(dispatcher, opts?): (req: Request) => Promise<Response> { |
| 154 | + return async (req) => { |
| 155 | + const body = await req.json(); |
| 156 | + const stream = sseStreamFrom(dispatcher.dispatch(body, env)); |
| 157 | + return new Response(stream, {headers: {'content-type': 'text/event-stream'}}); |
| 158 | + }; |
| 159 | +} |
| 160 | +``` |
| 161 | + |
| 162 | +Parse → `dispatch` → stream the AsyncIterable as SSE. ~400 LOC including header validation, batch handling, EventStore replay. No `_streamMapping` — the response stream is just in lexical scope. |
| 163 | + |
| 164 | +`mcp.handleHttp(req)` is McpServer's convenience wrapper around this. |
| 165 | + |
| 166 | +### The deletable parts |
| 167 | + |
| 168 | +`SessionCompat` — bounded LRU `{sessionId → negotiatedVersion}`. If you pass it to `shttpHandler`, the handler validates `mcp-session-id` headers and mints IDs on `initialize`. If you don't, it doesn't. ~200 LOC. |
| 169 | + |
| 170 | +`BackchannelCompat` — per-session `{requestId → resolver}` so a tool handler can `await ctx.elicitInput()` and the response comes back via a separate POST. The 2025-11 server→client-over-SSE behavior. ~140 LOC. |
| 171 | + |
| 172 | +These two are the *only* places 2025-11 stateful behavior lives. When that protocol version sunsets and MRTR (SEP-2322) is the floor, delete both files; `shttpHandler` is fully stateless. |
| 173 | + |
| 174 | +### Same examples, after |
| 175 | + |
| 176 | +```ts |
| 177 | +// stateless — one server, no transport instance |
| 178 | +const mcp = new McpServer({name: 'hello', version: '1'}); |
| 179 | +mcp.registerTool('greet', ..., ...); |
| 180 | +app.post('/mcp', c => mcp.handleHttp(c.req.raw)); |
| 181 | +``` |
| 182 | + |
| 183 | +```ts |
| 184 | +// 2025-11 stateful — same server, opt-in session |
| 185 | +const session = new SessionCompat({sessionIdGenerator: () => randomUUID()}); |
| 186 | +app.all('/mcp', toNodeHttpHandler(shttpHandler(mcp, {session}))); |
| 187 | +``` |
| 188 | + |
| 189 | +```ts |
| 190 | +// stdio — unchanged from today |
| 191 | +const t = new StdioServerTransport(); |
| 192 | +await mcp.connect(t); |
| 193 | +``` |
| 194 | + |
| 195 | +```ts |
| 196 | +// the existing v1 pattern — also unchanged |
| 197 | +const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()}); |
| 198 | +await mcp.connect(t); |
| 199 | +app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body)); |
| 200 | +// (internally, t.handleRequest now calls shttpHandler — same wire behavior) |
| 201 | +``` |
| 202 | + |
| 203 | +--- |
| 204 | + |
| 205 | +## Part 3: What you get |
| 206 | + |
| 207 | +**The stateless server is one line.** One `McpServer` at module scope, `handleHttp` per request. The per-request build-and-tear-down workaround is gone. |
| 208 | + |
| 209 | +**Handlers are testable without a transport.** `await mcp.dispatchToResponse({...})` — no `InMemoryTransport` pair, no `connect`. |
| 210 | + |
| 211 | +**The SHTTP transport drops from 1038 to ~290 LOC.** No `_streamMapping` (the response stream is in lexical scope), no body-sniffing for `initialize` (SessionCompat handles it), no fake `start()`. |
| 212 | + |
| 213 | +**2025-11 protocol state lives in two named files.** When that version sunsets, delete `SessionCompat` and `BackchannelCompat`; `shttpHandler` is fully stateless. Today the same logic is `if (sessionIdGenerator)` branches scattered through one transport. |
| 214 | + |
| 215 | +**Existing code doesn't change.** `new NodeStreamableHTTPServerTransport({...})` + `connect(t)` + `t.handleRequest(...)` works exactly as before — the class builds the compat pieces internally from the options you already pass. |
| 216 | + |
| 217 | +--- |
| 218 | + |
| 219 | +*Reference implementation on [`fweinberger/ts-sdk-rebuild`](https://github.com/modelcontextprotocol/typescript-sdk/tree/fweinberger/ts-sdk-rebuild). See the [RFC](./rfc-stateless-architecture.md) for the formal proposal.* |
0 commit comments