Skip to content

Commit 8fb9b8e

Browse files
rename: Backchannel2511 -> BackchannelCompat; add RFC + WALKTHROUGH docs
1 parent 32669c3 commit 8fb9b8e

5 files changed

Lines changed: 348 additions & 8 deletions

File tree

docs/WALKTHROUGH.md

Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
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.*

docs/rfc-stateless-architecture.md

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
# RFC: Request-first SDK architecture
2+
3+
**Status:** Draft, seeking direction feedback
4+
**Reference impl:** [`fweinberger/ts-sdk-rebuild`](https://github.com/modelcontextprotocol/typescript-sdk/tree/fweinberger/ts-sdk-rebuild) (proof-of-concept, not for direct merge)
5+
6+
---
7+
8+
## TL;DR
9+
10+
The only way into the SDK today is `server.connect(transport)`, which assumes a persistent channel. The protocol is moving to per-request stateless (SEP-2575/2567/2322). This RFC proposes adding `dispatch(request, env) → response` as the core primitive and building the connection model as one adapter on top of it. Existing code keeps working unchanged.
11+
12+
---
13+
14+
## Problem
15+
16+
```
17+
┌────────────────────────────────────────────┐
18+
│ Protocol (~1100 LOC, abstract) │
19+
│ ├ handler registry │
20+
│ ├ request/response correlation │
21+
│ ├ timeouts, debounce, progress │
22+
│ ├ capability assertions (abstract) │
23+
│ ├ TaskManager binding │
24+
│ └ connect(transport) — wires onmessage │
25+
└────────────────────────────────────────────┘
26+
▲ ▲
27+
extends │ │ extends
28+
┌─────────┴──┐ ┌───────┴──────┐
29+
│ Server │ │ Client │
30+
└─────┬──────┘ └──────────────┘
31+
wraps │
32+
┌─────┴──────┐
33+
│ McpServer │
34+
└────────────┘
35+
```
36+
37+
Everything goes through `connect(transport)`. `Transport` is pipe-shaped (`{start, send, onmessage, close}`). The Streamable HTTP transport (1038 LOC) implements that pipe shape on top of HTTP — keeping a `_streamMapping` table to route fire-and-forget `send()` calls back to the right HTTP response, sniffing message bodies to detect `initialize` so it knows when to mint a session ID.
38+
39+
The recommended stateless server pattern is to construct a `McpServer`, register all tools, build a transport with `sessionIdGenerator: undefined`, `connect()`, handle one request, then let it all GC — per request.
40+
41+
---
42+
43+
## Proposal
44+
45+
```
46+
┌───────────────────────────────────────┐
47+
│ Dispatcher (~270 LOC) │
48+
│ ├ handler registry │
49+
│ └ dispatch(req, env) → AsyncIterable │
50+
│ No transport. No connection state. │
51+
└───────────────────────────────────────┘
52+
53+
extends │
54+
┌─────────────────┴─────────────────┐
55+
│ McpServer / Client │
56+
│ (MCP handlers, registries) │
57+
└─────────────────┬─────────────────┘
58+
│ dispatch() called by:
59+
┌───────────────┴───────────────┐
60+
▼ ▼
61+
┌───────────────────┐ ┌──────────────────────────┐
62+
│ StreamDriver │ │ shttpHandler │
63+
│ (channel adapter) │ │ (request adapter) │
64+
│ correlation, │ │ ├ SessionCompat (opt) │
65+
│ timeouts, debounce│ │ └ BackchannelCompat(opt) │
66+
│ stdio, WS, InMem │ │ SHTTP │
67+
└───────────────────┘ └──────────────────────────┘
68+
```
69+
70+
| Piece | What it is | Replaces |
71+
|---|---|---|
72+
| **Dispatcher** | `Map<method, handler>` + `dispatch(req, env)` | Protocol's handler-registry half |
73+
| **StreamDriver** | Wraps a pipe; `onmessage → dispatch → send` loop | Protocol's correlation/timeout half |
74+
| **shttpHandler** | `(Request) → Promise<Response>` calling `dispatch()` | The 1038-LOC SHTTP transport's core |
75+
| **SessionCompat** | Bounded LRU `{sessionId → version}` | `Transport.sessionId` + SHTTP `_initialized` |
76+
| **BackchannelCompat** | Per-session `{requestId → resolver}` for server→client over SSE | `_streamMapping` + `relatedRequestId` |
77+
78+
SessionCompat and BackchannelCompat hold all 2025-11 stateful behavior. When that protocol version sunsets and MRTR is the floor, they're deleted and `shttpHandler` is fully stateless.
79+
80+
---
81+
82+
## Compatibility
83+
84+
**Existing SHTTP code does not change.** This:
85+
86+
```ts
87+
const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()});
88+
await mcp.connect(t);
89+
app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body));
90+
```
91+
92+
works exactly as before. The transport class constructs `SessionCompat`/`BackchannelCompat` internally from the options you already pass; `handleRequest` calls `shttpHandler` under the hood. Same wire behavior, same options, no code change.
93+
94+
`Protocol` and `Server` stay as back-compat shims for direct subclassers. Stdio/WS unchanged.
95+
96+
---
97+
98+
## Wins
99+
100+
**Stateless becomes one line.**
101+
```ts
102+
const mcp = new McpServer({name: 'hello', version: '1'});
103+
mcp.registerTool('greet', ..., ...);
104+
app.post('/mcp', c => mcp.handleHttp(c.req.raw));
105+
```
106+
One server at module scope, called per request. No transport instance, no `connect`, no per-request construction.
107+
108+
**Handlers are testable without a transport.**
109+
```ts
110+
const result = await mcp.dispatchToResponse({jsonrpc:'2.0', id:1, method:'tools/list'});
111+
```
112+
113+
**2025-11 state is deletable.** Two named files (`SessionCompat`, `BackchannelCompat`) instead of branches through one transport. When 2025-11 sunsets, delete them.
114+
115+
**HTTP-shaped transports stop pretending to be pipes.** No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`. SHTTP transport drops from 1038 to ~290 LOC.
116+
117+
**Custom transports get a request-shaped option.** gRPC/Lambda/CF Workers can call `dispatch()` directly instead of implementing a fake pipe.
118+
119+
---
120+
121+
The reference implementation passes all SDK tests, conformance (40/40 server, 317/317 client), and 14/14 consumer typecheck after the existing v2 back-compat PRs. See the [WALKTHROUGH](./WALKTHROUGH.md) for a code-level walk through the current pain and the fix.

packages/server/src/server/backchannel2511.ts renamed to packages/server/src/server/backchannelCompat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { DEFAULT_REQUEST_TIMEOUT_MSEC, isJSONRPCErrorResponse, ProtocolError, Sd
2323
* (SEP-2322) is the protocol floor and `env.send` becomes a hard error in
2424
* stateless paths.
2525
*/
26-
export class Backchannel2511 {
26+
export class BackchannelCompat {
2727
private _pending = new Map<string, Map<number, { resolve: (r: Result) => void; reject: (e: Error) => void }>>();
2828
private _standaloneWriters = new Map<string, (msg: JSONRPCMessage) => void>();
2929
private _nextId = 0;

packages/server/src/server/shttpHandler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
SUPPORTED_PROTOCOL_VERSIONS
2020
} from '@modelcontextprotocol/core';
2121

22-
import type { Backchannel2511 } from './backchannel2511.js';
22+
import type { BackchannelCompat } from './backchannelCompat.js';
2323
import type { SessionCompat } from './sessionCompat.js';
2424

2525
export type StreamId = string;
@@ -98,7 +98,7 @@ export interface ShttpHandlerOptions {
9898
* waiting `env.send` promise. Version-gated: only active for sessions whose negotiated
9999
* protocol version is below `2026-06-30`.
100100
*/
101-
backchannel?: Backchannel2511;
101+
backchannel?: BackchannelCompat;
102102

103103
/**
104104
* Event store for SSE resumability via `Last-Event-ID`. When configured, every

0 commit comments

Comments
 (0)