You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Copy file name to clipboardExpand all lines: docs/WALKTHROUGH.md
+7-13Lines changed: 7 additions & 13 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -72,23 +72,17 @@ A transport — whose job should be "bytes in, bytes out" — is parsing message
72
72
73
73
### What "stateless" looks like today
74
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:
75
+
The protocol direction (SEP-2575/2567) is: no `initialize`, no sessions, each request is independent. You can do this today with a module-scope transport:
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.
83
+
`sessionIdGenerator: undefined` is the opt-out — it makes `handleRequest` skip the session-ID minting/validation branches in the transport. The request still goes through the pipe-shaped path (`onmessage → _onrequest → handler → send → _streamMapping` lookup), but without sessions the mapping is just per-in-flight-request.
84
+
85
+
It works. It's not obvious — you have to know that `undefined` is the flag, that `connect()` is still needed, and that the transport class is doing pipe-correlation under a request/response API. (The shipped example actually constructs the transport per-request, which is unnecessary but suggests the authors weren't confident in the module-scope version either.)
@@ -36,7 +36,24 @@ The only way into the SDK today is `server.connect(transport)`, which assumes a
36
36
37
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
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.
39
+
The shipped stateless example constructs a fresh server and transport per request ([`examples/server/src/simpleStatelessStreamableHttp.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/7bb79ebbbba88a503851617d053b13d8fd9228bb/examples/server/src/simpleStatelessStreamableHttp.ts#L99-L111)):
40
+
41
+
```ts
42
+
app.post('/mcp', async (req, res) => {
43
+
const server =getServer(); // McpServer + all registrations
44
+
const transport =newNodeStreamableHTTPServerTransport({
45
+
sessionIdGenerator: undefined// opt-out flag
46
+
});
47
+
awaitserver.connect(transport);
48
+
awaittransport.handleRequest(req, res, req.body);
49
+
res.on('close', () => {
50
+
transport.close();
51
+
server.close();
52
+
});
53
+
});
54
+
```
55
+
56
+
A module-scope version (one server, one transport, `sessionIdGenerator: undefined`) does work, but the example doesn't use it — and the request still goes through the pipe-shaped path either way.
40
57
41
58
---
42
59
@@ -67,54 +84,130 @@ The recommended stateless server pattern is to construct a `McpServer`, register
|**BackchannelCompat**|Per-session `{requestId → resolver}`for server→client over SSE|`_streamMapping` + `relatedRequestId`|
89
+
|**Dispatcher**|Knows which handler to call for which method. You register handlers (`setRequestHandler('tools/list', fn)`); `dispatch(request)` looks one up, runs it, returns the output. Doesn't know how the request arrived or where the response goes.| Protocol's handler-registry half |
90
+
|**StreamDriver**|Runs a Dispatcher over a persistent connection (stdio, WebSocket). Reads from the pipe → `dispatch()` → writes back. Owns the per-connection state: response correlation, timeouts, debounce. One per pipe; the Dispatcher it wraps can be shared.| Protocol's correlation/timeout half |
91
+
|**shttpHandler**|Runs a Dispatcher over HTTP. Takes a web `Request`, parses the body, calls `dispatch()`, streams the result as a `Response`. A function you mount on a router, not a class you connect.| The 1038-LOC SHTTP transport's core |
92
+
|**SessionCompat**|Remembers session IDs across HTTP requests. 2025-11 servers mint an ID on `initialize` and validate it on every later request — this is the bounded LRU that does that. Pass it to `shttpHandler` for 2025-11 clients; omit it for stateless.|`Transport.sessionId` + SHTTP `_initialized`|
93
+
|**BackchannelCompat**|Lets a tool handler ask the client a question mid-call (`ctx.elicitInput()`) over HTTP. 2025-11 does this by writing the question into the still-open SSE response and waiting for the client to POST the answer back; this holds the "waiting for answer N" table. Under MRTR the same thing is a return value, so this gets deleted.|`_streamMapping` + `relatedRequestId`|
77
94
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.
95
+
The last two are the only places 2025-11 stateful behavior lives. They're passed to`shttpHandler`as options; without them it's pure request→response.
79
96
80
97
---
81
98
82
99
## Compatibility
83
100
84
-
**Existing SHTTP code does not change.** This:
101
+
**Existing stateful SHTTP code does not change:**
85
102
86
103
```ts
87
104
const t =newNodeStreamableHTTPServerTransport({sessionIdGenerator: () =>randomUUID()});
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.
109
+
Same options, same wire behavior — sessions are minted on `initialize`, validated on every later request, `transport.sessionId` is populated, `onsessioninitialized`/`onsessionclosed` fire, `ctx.elicitInput()` works mid-tool-call. Under the hood the transport class constructs a `SessionCompat` and `BackchannelCompat` from those options and routes `handleRequest` through `shttpHandler`. The session-ful behavior is identical; the implementation is the new path.
110
+
111
+
**Existing stdio code does not change:**
112
+
113
+
```ts
114
+
const t =newStdioServerTransport();
115
+
awaitmcp.connect(t);
116
+
```
117
+
118
+
`connect()` sees a channel-shaped transport and builds a `StreamDriver(mcp, t)` internally — which reads stdin, calls `dispatch()`, writes stdout. The stdio transport class itself is unchanged (it was always just a pipe wrapper); what's different is that the read-dispatch-write loop now lives in `StreamDriver` instead of `Protocol`.
119
+
120
+
`Protocol` and `Server` stay as back-compat shims for direct subclassers (ext-apps).
121
+
122
+
---
123
+
124
+
## Client side
125
+
126
+
The same split applies. `Client extends Dispatcher` — its registry holds the handlers for requests the *server* sends (`elicitation/create`, `sampling/createMessage`, `roots/list`). When one arrives, `dispatch()` routes it.
127
+
128
+
For outbound (`callTool`, `listTools`, etc.), Client uses a `ClientTransport`:
`Protocol` and `Server` stay as back-compat shims for direct subclassers. Stdio/WS unchanged.
159
+
**Over HTTP:**`StreamableHTTPClientTransport.fetch` POSTs the request and reads the response (SSE or JSON). If the server writes a JSON-RPC *request* into that SSE stream (2025-11 elicitation), the transport calls `opts.onrequest(r)` — which Client wires to `this.dispatch(r)` — and POSTs the answer back. Same flow as today, request-shaped underneath.
160
+
161
+
**Over stdio:**`pipeAsClientTransport(stdioTransport)` wraps the channel in a StreamDriver and exposes `{fetch, notify, close}`. `fetch` becomes "send over the pipe, await the correlated response."
162
+
163
+
**MRTR (SEP-2322):** the stateless server→client path. Instead of the held-stream backchannel, the server *returns*`{input_required, requests: [...]}` as the `tools/call` result. Client sees that, services each request via its own `dispatch()`, and re-sends `tools/call` with the answers attached. No held stream, works over any transport. Client's `_request` runs this loop transparently — `await client.callTool(...)` looks the same to the caller whether the server used the backchannel or MRTR.
164
+
165
+
**Compat:**`client.connect(transport)` keeps working with both `ChannelTransport` and `ClientTransport`. Existing code (`new StreamableHTTPClientTransport(url)` + `connect`) is unchanged.
95
166
96
167
---
97
168
98
169
## Wins
99
170
100
-
**Stateless becomes one line.**
171
+
**Stateless without the opt-out.** Today's stateless is `sessionIdGenerator: undefined` — a flag that opts you out of session handling but leaves the request going through the pipe-shaped path (`onmessage → dispatch → send → _streamMapping` lookup). It's stateless at the wire but not in the code: concurrent requests still share a `_streamMapping` table on the transport instance, the transport still parses bodies looking for `initialize`, and the shipped example constructs everything per-request because the module-scope version isn't obviously safe. After:
One server at module scope, called per request. No transport instance, no `connect`, no per-request construction.
184
+
No transport class, no `connect`, no flag. The path is `parse → dispatch → respond`.
107
185
108
-
**Handlers are testable without a transport.**
186
+
**Handlers are testable without a transport.** Today, unit-testing a tool handler means an `InMemoryTransport` pair, two `connect()` calls, and a client to drive it. After:
109
187
```ts
110
-
const result =awaitmcp.dispatchToResponse({jsonrpc:'2.0', id:1, method:'tools/list'});
The HTTP layer is testable the same way — `await shttpHandler(mcp)(new Request('http://test/mcp', {method: 'POST', body: ...}))` returns a `Response` you can assert on, no server to spin up.
199
+
200
+
**Middleware that covers everything.**`Dispatcher.use(mw)` wraps every dispatch — including `initialize`, which today has no hook. FastMCP currently subclasses the SDK's session class and overrides a `_`-private method to intercept `initialize` for auth; after, it's `mcp.use(authMiddleware)`. Same story for logging, rate limiting, tracing.
201
+
202
+
**Pluggable transports stop paying the pipe tax.** A gRPC/WebTransport/Lambda integration today has to implement `{start, send, onmessage, close}` and reconstruct request→response on top. After, request-shaped transports call `dispatch()` directly; only genuinely persistent channels (stdio, WebSocket) implement `ChannelTransport`.
203
+
204
+
**Extensions plug in cleanly.** Tasks (and later sampling/roots when they move to `ext-*` packages) attach via `mcp.use(tasksMiddleware(store))` instead of being wired into Protocol. The core SDK doesn't import them.
112
205
113
-
**2025-11 state is deletable.** Two named files (`SessionCompat`, `BackchannelCompat`) instead of branches through one transport. When 2025-11 sunsets, delete them.
206
+
**2025-11 state is deletable.** Two named files instead of `if (sessionIdGenerator)`branches through one transport. The sunset is `git rm sessionCompat.ts backchannelCompat.ts`, not a hunt.
114
207
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.
208
+
**Protocol stops being a god class.**Today `Protocol` (~1100 LOC) is registry + correlation + timeouts + capabilities + tasks + connect, abstract, with both Server and Client extending it. Tracing a request means bouncing between Protocol, Server, and McpServer. After: Dispatcher does routing, StreamDriver does per-connection state, McpServer does MCP semantics. Each file has one job; you can read one without the others.
116
209
117
-
**Custom transports get a request-shaped option.**gRPC/Lambda/CF Workers can call `dispatch()`directly instead of implementing a fake pipe.
210
+
**The SHTTP transport class drops from 1038 to ~290 LOC.**New code doesn't need the class at all (`handleHttp` is the entry). The class still exists for back-compat — existing code that does `new NodeStreamableHTTPServerTransport(...)`keeps working — but it's now a thin shim that constructs `shttpHandler` internally. No `_streamMapping`, no body-sniffing for `initialize`, no fake `start()`.
0 commit comments