Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
c500d6e
feat(core): add Dispatcher and StreamDriver as new dispatch primitives
felixweinberger Apr 17, 2026
a3d1ae3
feat(server): McpServer with handle() — merged mcp.ts + server.ts
felixweinberger Apr 17, 2026
d814138
feat(server): McpServer extends Dispatcher with handle(); shttpHandle…
felixweinberger Apr 17, 2026
c904fae
fix(client): use @modelcontextprotocol/core barrel imports in clientV…
felixweinberger Apr 17, 2026
786b05e
feat(server): swap public exports to new McpServer; clear leaked wran…
felixweinberger Apr 17, 2026
875a25e
feat: wire TaskManager into StreamDriver/McpServer; restore capabilit…
felixweinberger Apr 17, 2026
c68922d
fix(server): thread closeSSE/closeStandaloneSSE through buildEnv to c…
felixweinberger Apr 17, 2026
5d7898b
feat(server): add deprecated .tool/.prompt/.resource overloads + flat…
felixweinberger Apr 17, 2026
85dd8b9
chore: fix lint in core (duplicate exports, explicit-any)
felixweinberger Apr 17, 2026
8a33622
feat(client): tasks support in clientV2 (SEP-2557 direction); lint cl…
felixweinberger Apr 17, 2026
baea41a
feat(core): add Dispatcher.dispatchRaw for envelope-agnostic drivers …
felixweinberger Apr 17, 2026
e16ee66
refactor(server): split mcpServer.ts into core + registries + legacy …
felixweinberger Apr 17, 2026
92c64a9
refactor(client): unify Client — clientV2 becomes the only Client
felixweinberger Apr 17, 2026
4548bd0
feat: add @modelcontextprotocol/sdk meta-package + missing type expor…
felixweinberger Apr 17, 2026
083ebde
feat: meta-package parity fixes — sdk collision, server-auth-legacy, …
felixweinberger Apr 17, 2026
dea6da2
fix: Node10 resolution compat — top-level types field + typesVersions…
felixweinberger Apr 17, 2026
679d547
refactor: gut old protocol.ts/mcp.ts/server.ts to thin compat wrappers
felixweinberger Apr 21, 2026
09ded7b
chore(sdk): remove dead tsconfig.build.json (tsdown emits dts now)
felixweinberger Apr 21, 2026
641a822
docs: shttpHandler 2025-11 elicitation back-channel limitation + work…
felixweinberger Apr 21, 2026
00dfea9
chore: lint:fix:all (prettier + unicorn/no-this-assignment disable)
felixweinberger Apr 21, 2026
efacdbd
feat(server): Backchannel2511 + version-gated env.send in shttpHandler
felixweinberger Apr 21, 2026
0ad2521
fix(compat): accept zod v3 schemas + handle cross-instance zod in JSO…
felixweinberger Apr 21, 2026
008a745
fix(server): expose v1-internal lazy-installer + helper hooks on McpS…
felixweinberger Apr 21, 2026
66ecb7a
refactor(server): gut WebStandardStreamableHTTPServerTransport to rou…
felixweinberger Apr 21, 2026
5490e2d
docs: remove superseded shttpHandler limitation doc (backchannel now …
felixweinberger Apr 21, 2026
90e31ff
fix(security): reject missing Host header when allowedHosts configure…
felixweinberger Apr 21, 2026
e502d63
fix(core): avoid TaskManager double-process on stdio path via dispatc…
felixweinberger Apr 21, 2026
5358634
feat(core): 3-arg setRequestHandler(method, paramsSchema, handler) fo…
felixweinberger Apr 21, 2026
8187039
fix(sdk): add require condition to all export subpaths for jest CJS r…
felixweinberger Apr 21, 2026
fa9c235
fix(packaging): add require condition to server/client/node/express/h…
felixweinberger Apr 21, 2026
39b0d57
docs(examples): add helloStateless server + client (no connect on ser…
felixweinberger Apr 21, 2026
de6e996
docs(examples): simplify helloStateless to Hono (one-line route)
felixweinberger Apr 21, 2026
53db0d8
feat(node): add toNodeHttpHandler helper; docs(examples): Express var…
felixweinberger Apr 21, 2026
ef1daf0
docs(examples): clarify client example is identical to v1/v2 pattern
felixweinberger Apr 21, 2026
2d4bafd
refactor(client): StreamableHTTPClientTransport implements ClientTran…
felixweinberger Apr 21, 2026
33c5047
fix(client): open standalone GET SSE stream after connect (request-sh…
felixweinberger Apr 21, 2026
c0e35f4
refactor(core): introduce OutboundChannel; McpServer/Protocol hold _o…
felixweinberger Apr 21, 2026
56e1506
refactor(core): remove TaskManager from StreamDriver; move inbound ta…
felixweinberger Apr 21, 2026
3521004
refactor(core): typed RequestServerTransport replaces 'bind' duck-typing
felixweinberger Apr 21, 2026
f494586
refactor(client): Client extends Dispatcher (was: owns _localDispatcher)
felixweinberger Apr 21, 2026
edff5ba
refactor: AttachableTransport.attach(d, opts) → OutboundChannel; McpS…
felixweinberger Apr 21, 2026
1a658e2
refactor(core): callback-based RequestTransport; rename Transport -> …
felixweinberger Apr 21, 2026
94c5403
refactor(core): generic dispatch/outbound middleware; Tasks as opt-in…
felixweinberger Apr 21, 2026
fc9885b
simplify: remove dispatchRaw + RawDispatchOutput + grpc-integration doc
felixweinberger Apr 21, 2026
c823def
simplify: remove OutboundMiddleware/useOutbound; StreamDriver calls T…
felixweinberger Apr 21, 2026
20fbba0
simplify: move RequestTransport/Backchannel2511 to internal-only; dro…
felixweinberger Apr 21, 2026
7068291
simplify: explicit `kind` brand on transports replaces duck-typing di…
felixweinberger Apr 21, 2026
6c439da
simplify: strip forward-looking spec references from comments
felixweinberger Apr 21, 2026
934f6e5
docs(mcpServer): document _outbound singleton multi-connect limitation
felixweinberger Apr 21, 2026
32669c3
fixup! simplify: remove OutboundMiddleware/useOutbound; StreamDriver …
felixweinberger Apr 21, 2026
8fb9b8e
rename: Backchannel2511 -> BackchannelCompat; add RFC + WALKTHROUGH docs
felixweinberger Apr 21, 2026
2a28377
rename: Client._ct -> _clientTransport; update RFC diagram
felixweinberger Apr 21, 2026
858fa34
docs(rfc): introduce middleware + ChannelTransport/RequestTransport i…
felixweinberger Apr 21, 2026
0f2a7f3
docs(rfc): link draft PR #1942
felixweinberger Apr 21, 2026
7026e73
chore: prettier formatting (streamDriver, taskManager)
felixweinberger Apr 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions docs/WALKTHROUGH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
# Walkthrough: why the SDK fights stateless, and how to fix it

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.

---

## Part 1: The current code

### Start at the only entrance

There is exactly one way to make an MCP server handle requests:

```ts
// packages/core/src/shared/protocol.ts:437
async connect(transport: Transport): Promise<void> {
this._transport = transport;
transport.onmessage = (message, extra) => {
// route to _onrequest / _onresponse / _onnotification
};
await transport.start();
}
```

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.

`Transport` is shaped like a pipe:

```ts
// packages/core/src/shared/transport.ts:8
interface Transport {
start(): Promise<void>;
send(message: JSONRPCMessage): Promise<void>;
onmessage?: (message, extra) => void;
close(): Promise<void>;
sessionId?: string;
setProtocolVersion?(v: string): void;
}
```

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

### Follow an HTTP request through

The Streamable HTTP server transport is `packages/server/src/server/streamableHttp.ts` — 1038 lines. Let's follow a `tools/list` POST:

1. User's Express handler calls `transport.handleRequest(req, res, body)` (line 176)
2. `handlePostRequest` validates headers (217-268), parses body (282)
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.
4. `Protocol._onrequest` runs the handler, gets a result, builds a response, calls `this._transport.send(response)` (634)
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.

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.

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

### The session sniffing

The transport also has to know about `initialize`:

```ts
// streamableHttp.ts:323
if (isInitializeRequest(body)) {
if (this._sessionIdGenerator) {
this.sessionId = this._sessionIdGenerator();
// ... onsessioninitialized callback
}
this._initialized = true;
}
```

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.

### What "stateless" looks like today

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:

```ts
const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: undefined});
await mcp.connect(t);
app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body));
```

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

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

### Why is Protocol 1100 lines?

`protocol.ts` is the abstract base for both `Server` and `Client`. It does:

- handler registry (`_requestHandlers`, `setRequestHandler`)
- outbound request/response correlation (`_responseHandlers`, `_requestMessageId`)
- timeouts (`_timeoutInfo`, `_setupTimeout`, `_resetTimeout`)
- progress callbacks (`_progressHandlers`)
- debounced notifications (`_pendingDebouncedNotifications`)
- cancellation (`_requestHandlerAbortControllers`)
- TaskManager binding (`_bindTaskManager`)
- 4 abstract `assert*Capability` methods subclasses must implement
- `connect()` — wiring all of the above to a transport

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.

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?")

---

## Part 2: The proposed split

### The primitive

```ts
class Dispatcher {
setRequestHandler(method, handler): void;
dispatch(req: JSONRPCRequest, env?: RequestEnv): AsyncIterable<DispatchOutput>;
}
```

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.

That's it. You can call `dispatch` from anywhere — a test, a Lambda, a loop reading stdin.

### The channel adapter

For stdio/WebSocket/InMemory — things that *are* persistent pipes — `StreamDriver` wraps a `ChannelTransport` and a `Dispatcher`:

```ts
class StreamDriver {
constructor(dispatcher, channel) { ... }
start() {
channel.onmessage = msg => {
for await (const out of dispatcher.dispatch(msg, env)) channel.send(out);
};
}
request(req): Promise<Result>; // outbound, with correlation/timeout
}
```

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.

`connect(channelTransport)` builds one of these. So `connect` still works exactly as before for stdio.

### The request adapter

For HTTP — things that are *not* persistent pipes — `shttpHandler`:

```ts
function shttpHandler(dispatcher, opts?): (req: Request) => Promise<Response> {
return async (req) => {
const body = await req.json();
const stream = sseStreamFrom(dispatcher.dispatch(body, env));
return new Response(stream, {headers: {'content-type': 'text/event-stream'}});
};
}
```

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.

`mcp.handleHttp(req)` is McpServer's convenience wrapper around this.

### The deletable parts

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

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

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.

### Same examples, after

```ts
// stateless — one server, no transport instance
const mcp = new McpServer({name: 'hello', version: '1'});
mcp.registerTool('greet', ..., ...);
app.post('/mcp', c => mcp.handleHttp(c.req.raw));
```

```ts
// 2025-11 stateful — same server, opt-in session
const session = new SessionCompat({sessionIdGenerator: () => randomUUID()});
app.all('/mcp', toNodeHttpHandler(shttpHandler(mcp, {session})));
```

```ts
// stdio — unchanged from today
const t = new StdioServerTransport();
await mcp.connect(t);
```

```ts
// the existing v1 pattern — also unchanged
const t = new NodeStreamableHTTPServerTransport({sessionIdGenerator: () => randomUUID()});
await mcp.connect(t);
app.all('/mcp', (req, res) => t.handleRequest(req, res, req.body));
// (internally, t.handleRequest now calls shttpHandler — same wire behavior)
```

---

## Part 3: What you get

**The stateless server is one line.** One `McpServer` at module scope, `handleHttp` per request. The per-request build-and-tear-down workaround is gone.

**Handlers are testable without a transport.** `await mcp.dispatchToResponse({...})` — no `InMemoryTransport` pair, no `connect`.

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

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

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

---

*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.*
7 changes: 4 additions & 3 deletions docs/migration-SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,9 +75,10 @@ Notes:

## 4. Renamed Symbols

| v1 symbol | v2 symbol | v2 package |
| ------------------------------- | ----------------------------------- | ---------------------------- |
| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` | `@modelcontextprotocol/node` |
| v1 symbol | v2 symbol | v2 package |
| ------------------------------- | ----------------------------------- | ------------------------------------- |
| `StreamableHTTPServerTransport` | `NodeStreamableHTTPServerTransport` | `@modelcontextprotocol/node` |
| `Transport` (interface) | `ChannelTransport` | `@modelcontextprotocol/{client,server}` (deprecated alias `Transport` kept) |

## 5. Removed / Renamed Type Aliases and Symbols

Expand Down
16 changes: 16 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,22 @@ npm install @modelcontextprotocol/express # Express
npm install @modelcontextprotocol/hono # Hono
```

### `Transport` interface renamed to `ChannelTransport`; `RequestTransport` added

The pipe-shaped `Transport` interface has been renamed `ChannelTransport`. A new `RequestTransport` interface (callback-based, for request/response transports like Streamable HTTP) sits alongside it. `connect()` accepts either.

`Transport` is kept as a deprecated type alias of `ChannelTransport`, so existing code compiles unchanged.

```typescript
// Before (v1)
import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js';

// After (v2)
import type { ChannelTransport, RequestTransport } from '@modelcontextprotocol/server';
```

If you implemented a custom transport by implementing `Transport`, switch to `implements ChannelTransport` (same shape) or, for HTTP-style transports, `implements RequestTransport`.

### `StreamableHTTPServerTransport` renamed

`StreamableHTTPServerTransport` has been renamed to `NodeStreamableHTTPServerTransport` and moved to `@modelcontextprotocol/node`.
Expand Down
Loading
Loading