Skip to content

Commit 2a28377

Browse files
rename: Client._ct -> _clientTransport; update RFC diagram
1 parent 8fb9b8e commit 2a28377

3 files changed

Lines changed: 138 additions & 51 deletions

File tree

docs/WALKTHROUGH.md

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -72,23 +72,17 @@ A transport — whose job should be "bytes in, bytes out" — is parsing message
7272

7373
### What "stateless" looks like today
7474

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:
7676

7777
```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-
});
78+
const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: undefined});
79+
await mcp.connect(t);
80+
app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body));
8981
```
9082

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.
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.)
9286

9387
### Why is Protocol 1100 lines?
9488

docs/rfc-stateless-architecture.md

Lines changed: 113 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,24 @@ The only way into the SDK today is `server.connect(transport)`, which assumes a
3636

3737
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.
3838

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 = new NodeStreamableHTTPServerTransport({
45+
sessionIdGenerator: undefined // opt-out flag
46+
});
47+
await server.connect(transport);
48+
await transport.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.
4057

4158
---
4259

@@ -67,54 +84,130 @@ The recommended stateless server pattern is to construct a `McpServer`, register
6784
└───────────────────┘ └──────────────────────────┘
6885
```
6986

70-
| Piece | What it is | Replaces |
87+
| Piece | What it does | Replaces |
7188
|---|---|---|
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` |
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` |
7794

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.
7996

8097
---
8198

8299
## Compatibility
83100

84-
**Existing SHTTP code does not change.** This:
101+
**Existing stateful SHTTP code does not change:**
85102

86103
```ts
87104
const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()});
88105
await mcp.connect(t);
89106
app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body));
90107
```
91108

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.
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 = new StdioServerTransport();
115+
await mcp.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`:
129+
130+
```ts
131+
interface ClientTransport {
132+
fetch(req: JSONRPCRequest, opts?): Promise<JSONRPCResponse>; // request → response
133+
notify(n: Notification): Promise<void>;
134+
close(): Promise<void>;
135+
}
136+
```
137+
138+
This is the request-shaped mirror of the server side: `fetch` is one request → one response.
139+
140+
```
141+
┌───────────────────────────────────────┐
142+
│ Client extends Dispatcher │
143+
│ inbound: dispatch() for elicit/ │
144+
│ sampling/roots │
145+
│ outbound: callTool → │
146+
│ _clientTransport.fetch(req)│
147+
└─────────────────┬─────────────────────┘
148+
│ _clientTransport is ONE of:
149+
┌───────────────┴───────────────┐
150+
▼ ▼
151+
┌───────────────────────┐ ┌────────────────────────────────┐
152+
│ pipeAsClientTransport │ │ StreamableHTTPClientTransport │
153+
│ (wraps a channel via │ │ (implements ClientTransport │
154+
│ StreamDriver) │ │ directly: POST → Response) │
155+
│ stdio, WS, InMem │ │ SHTTP │
156+
└───────────────────────┘ └────────────────────────────────┘
157+
```
93158

94-
`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.
95166

96167
---
97168

98169
## Wins
99170

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:
101172
```ts
102-
const mcp = new McpServer({name: 'hello', version: '1'});
103-
mcp.registerTool('greet', ..., ...);
173+
import { McpServer } from '@modelcontextprotocol/server';
174+
import { Hono } from 'hono';
175+
176+
const mcp = new McpServer({name: 'hello', version: '1.0.0'});
177+
mcp.registerTool('greet', {description: 'Say hello'}, async () => ({
178+
content: [{type: 'text', text: 'hello'}]
179+
}));
180+
181+
const app = new Hono();
104182
app.post('/mcp', c => mcp.handleHttp(c.req.raw));
105183
```
106-
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`.
107185

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:
109187
```ts
110-
const result = await mcp.dispatchToResponse({jsonrpc:'2.0', id:1, method:'tools/list'});
188+
const mcp = new McpServer({name: 'test', version: '1.0.0'});
189+
mcp.registerTool('greet', {description: '...'}, async () => ({
190+
content: [{type: 'text', text: 'hello'}]
191+
}));
192+
193+
const out = await mcp.dispatchToResponse({
194+
jsonrpc: '2.0', id: 1, method: 'tools/call', params: {name: 'greet', arguments: {}}
195+
});
196+
expect(out.result.content[0].text).toBe('hello');
111197
```
198+
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.
112205

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.
114207

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.
116209

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()`.
118211

119212
---
120213

0 commit comments

Comments
 (0)