Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 5 additions & 0 deletions .changeset/export-classify-entry-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

Export `classifyEntryRequest` (with its `EntryClassification` outcome type) from `@modelcontextprotocol/server`: the Request-shaped sibling of `classifyInboundRequest`, and the single classification step already behind `createMcpHandler` and `isLegacyRequest`. It takes the web-standard `Request`, extracts the HTTP method and the `MCP-Protocol-Version` / `Mcp-Method` / `Mcp-Name` headers, performs the entry's single body read (distinguishing an unreadable stream from a non-JSON body), and returns the full routing outcome plus a body-preserving `forwardRequest` — so hybrid deployments that need the routing reason (for example: route the legacy `initialize` handshake to a sessionful host, everything else to the stateless handler) no longer hand-assemble the classifier's fields or lose the reason to the boolean `isLegacyRequest`.
5 changes: 5 additions & 0 deletions .changeset/refused-handshake-fires-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': minor
---

Add a `closeOnRefusedHandshake` option (default `false`) to `WebStandardStreamableHTTPServerTransport` for transports created per handshake attempt — the session-map recipe, where a fresh transport and connected server pair serves one expected `initialize`. When enabled, a first completed request that fails to establish the session — a refused or throwing handshake, a pre-session `GET`/`DELETE`, an unsupported method — schedules the transport's close chain behind its response, so `onclose` fires and the paired server tears down instead of leaking. The close defers while another request is still in flight and re-checks before running, so a refusal settling next to an in-flight `initialize` never tears it down. The default stays off: long-lived sessionful endpoints must answer pre-session refusals (for example the SDK client's version-negotiation probe) without ending the transport, and stateless transports never close on refusals.
2 changes: 1 addition & 1 deletion docs/serving/legacy-clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async function serve(request: Request): Promise<Response> {
}
```

`legacyStatelessFallback(factory)` is the entry's default legacy serving as a standalone handler — it holds the legacy leg's place here. Put your existing wiring there instead and it keeps its sessions, its event store, and its clients: [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) runs a sessionful `StreamableHTTPServerTransport` deployment behind this exact branch. Route every `false` to the strict handler — the modern path owns the error answers for malformed modern requests.
`legacyStatelessFallback(factory)` is the entry's default legacy serving as a standalone handler — it holds the legacy leg's place here. Put your existing wiring there instead and it keeps its sessions, its event store, and its clients: [`legacy-routing/server.ts`](https://github.com/modelcontextprotocol/typescript-sdk/blob/main/examples/legacy-routing/server.ts) runs a sessionful `StreamableHTTPServerTransport` deployment behind this exact branch. Route every `false` to the strict handler — the modern path owns the error answers for malformed modern requests. When the branch needs the routing reason rather than a boolean — say, sending only the legacy `initialize` handshake to a session host and everything else to the stateless handler — `classifyEntryRequest` is the same classification step returning the full outcome (`classified.outcome.reason === 'initialize'`) plus a body-preserving `forwardRequest` to hand to whichever handler wins.

The `initialize` the strict handler rejected above now completes the 2025 handshake on the legacy leg:

Expand Down
6 changes: 5 additions & 1 deletion docs/serving/sessions-state-scaling.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ const route = async (req: Request, res: Response) => {
if (!sessionId && isInitializeRequest(req.body)) {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
// This pair exists for one expected handshake: if the
// initialize is refused, close the transport (onclose below
// runs) so the just-connected server never leaks.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
sessions.set(id, transport);
}
Expand All @@ -59,7 +63,7 @@ app.get('/mcp', route);
app.delete('/mcp', route);
```

The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing.
The map cleans itself up: `transport.onclose` fires when the session ends, whether the client sent `DELETE` or you called `transport.close()`. `closeOnRefusedHandshake` extends that to the handshake itself — each transport here exists for one expected `initialize`, so if that first request is refused instead (a malformed body, a missing `Accept` header), the transport closes and `onclose` runs, and the server connected just above never leaks. Leave the option off for a sessionful transport that serves an endpoint long-term, where pre-session refusals (a client's version-negotiation probe, say) must not end it. A request with an unknown `Mcp-Session-Id` gets the `404` above, which tells the client to start a new session; a request with no session header at all gets the `400`, which tells it to re-send the id it already has instead of re-initializing.

::: tip
On shutdown, close every stored transport — `for (const [, transport] of sessions) await transport.close()` — before exiting; `close()` ends the session's SSE streams and rejects its pending requests.
Expand Down
2 changes: 1 addition & 1 deletion examples/elicitation/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Server requests user input. One factory, both protocol eras: elicitation works o
`plan_trip` chains **two** form elicitations inside one tool call (destination → dates for that destination): two sequential `ctx.mcpReq.elicitInput` pushes on 2025, two `inputRequired` rounds with `requestState` carry-over on 2026. The `register_user` form schema includes an
`enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md).

Runs all four transport/era legs: `server.ts` inlines a sessionful `NodeStreamableHTTPServerTransport` arm for 2025 traffic (the same `isLegacyRequest` composition `../legacy-routing/` shows by hand), so push server→client requests reach the client over either transport.
Runs all four transport/era legs: `server.ts` inlines a sessionful `NodeStreamableHTTPServerTransport` arm for 2025 traffic (the `classifyEntryRequest` flavor of the routing composition `../legacy-routing/` shows with `isLegacyRequest` — one classification step also yields the parsed body both arms need), so push server→client requests reach the client over either transport.

```bash
pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired)
Expand Down
23 changes: 15 additions & 8 deletions examples/elicitation/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,10 @@ import type {
} from '@modelcontextprotocol/server';
import {
acceptedContent,
classifyEntryRequest,
createMcpHandler,
inputRequired,
isInitializeRequest,
isLegacyRequest,
McpServer,
UrlElicitationRequiredError
} from '@modelcontextprotocol/server';
Expand Down Expand Up @@ -254,6 +254,9 @@ if (transport === 'stdio') {
} else if (!sid && isInitializeRequest(body)) {
const t = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
// This pair serves one expected handshake: a refused initialize
// closes the transport (onclose below runs) instead of leaking it.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
sessions.set(id, t);
}
Expand All @@ -276,14 +279,18 @@ if (transport === 'stdio') {

createServer((req, res) => {
void (async () => {
// `toWebRequest` reads the Node body into a web-standard `Request`,
// so the body now lives in `request`, not `req`. Ask the predicate
// first — it classifies an internal clone, leaving `request`
// readable for the `.json()` both arms need (reading `.json()`
// first would make the predicate's internal clone throw).
// `toWebRequest` reads the Node body into a web-standard `Request`.
// One classification step reads it exactly once and returns the
// routing outcome together with the parsed body both arms need —
// no second `.json()` read, no clone-ordering pitfall.
const request = await toWebRequest(req);
const legacy = await isLegacyRequest(request);
const body: unknown = req.method === 'POST' ? await request.json().catch(() => {}) : undefined;
const classified = await classifyEntryRequest(request);
if (classified.step === 'unreadable-body') {
res.writeHead(400).end();
return;
}
const legacy = classified.step === 'no-json-body' || classified.outcome.kind === 'legacy';
const body: unknown = classified.step === 'classified' ? classified.parsedBody : undefined;
await (legacy ? handleLegacy(req, res, body) : modern(req, res, body));
})().catch(error => {
console.error('[server] request error:', error instanceof Error ? error.message : error);
Expand Down
4 changes: 4 additions & 0 deletions examples/guides/serving/sessions-state-scaling.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ function sessions_routing(app: Express, buildServer: () => McpServer) {
if (!sessionId && isInitializeRequest(req.body)) {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
// This pair exists for one expected handshake: if the
// initialize is refused, close the transport (onclose below
// runs) so the just-connected server never leaks.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
sessions.set(id, transport);
}
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down
3 changes: 3 additions & 0 deletions examples/legacy-routing/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,9 @@ const handleLegacy = async (req: Request, res: Response) => {
} else if (!sid && isInitializeRequest(req.body)) {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
// This pair serves one expected handshake: a refused initialize
// closes the transport (onclose below runs) instead of leaking it.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
sessions.set(id, transport);
}
Expand Down
3 changes: 3 additions & 0 deletions examples/repl/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,9 @@ app.all('/mcp', async (req: Request, res: Response) => {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
eventStore, // resumability — events are persisted for replay on GET reconnect
// This pair serves one expected handshake: a refused initialize
// closes the transport (onclose below runs) instead of leaking it.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
sessions.set(id, transport);
}
Expand Down
3 changes: 3 additions & 0 deletions examples/sse-polling/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ app.all('/mcp', async (req: Request, res: Response) => {
sessionIdGenerator: () => randomUUID(),
eventStore,
retryInterval: 300, // Default retry interval for priming events
// This pair serves one expected handshake: a refused initialize
// closes the transport (onclose below runs) instead of leaking it.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
console.error(`[${id}] Session initialized`);
transports.set(id, transport);
Expand Down
3 changes: 3 additions & 0 deletions examples/standalone-get/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,9 @@ app.post('/mcp', async (req: Request, res: Response) => {
} else if (!sid && isInitializeRequest(req.body)) {
const transport = new NodeStreamableHTTPServerTransport({
sessionIdGenerator: () => randomUUID(),
// This pair serves one expected handshake: a refused initialize
// closes the transport (onclose below runs) instead of leaking it.
closeOnRefusedHandshake: true,
onsessioninitialized: id => {
sessions.set(id, transport);
}
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ export type { CompletableSchema, CompleteCallback } from './server/completable';
export { completable, isCompletable } from './server/completable';
export type {
CreateMcpHandlerOptions,
EntryClassification,
LegacyHttpHandler,
McpHandlerRequestOptions,
McpHttpHandler,
McpRequestContext,
McpServerFactory
} from './server/createMcpHandler';
export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler';
export { classifyEntryRequest, createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler';
Comment thread
claude[bot] marked this conversation as resolved.
export type {
AnyToolHandler,
BaseToolCallback,
Expand Down Expand Up @@ -69,6 +70,7 @@ export { fromJsonSchema } from './fromJsonSchema';

// Inbound HTTP request classification (dual-era serving): the body-primary era
// predicate used by createMcpHandler, exported for hand-wired compositions.
// Its Request-shaped sibling, classifyEntryRequest, is exported above.
export type {
InboundClassificationOutcome,
InboundHttpRequest,
Expand Down
63 changes: 51 additions & 12 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,11 +389,12 @@
}

/* ------------------------------------------------------------------------ *
* The entry's classification step (shared with isLegacyRequest)
* The entry's classification step (shared with isLegacyRequest, exported for
* hand-wired hybrid routing)
* ------------------------------------------------------------------------ */

/** The outcome of the entry's classification step for one inbound HTTP request. */
type EntryClassification =
/** The outcome of the entry's classification step ({@linkcode classifyEntryRequest}) for one inbound HTTP request. */
export type EntryClassification =
/** The body bytes could not be read at all (a failing stream, not malformed JSON). */
| { step: 'unreadable-body' }
/** A POST with an empty or non-JSON body: nothing to classify, so there is no envelope claim. */
Expand All @@ -402,17 +403,55 @@
| { step: 'classified'; outcome: InboundClassificationOutcome; body: unknown; parsedBody: unknown; forwardRequest: Request };

/**
* The entry's classification step: read the request body exactly once (unless
* a pre-parsed body is supplied) and classify the request with
* {@linkcode classifyInboundRequest}. This is the single code path behind both
* {@linkcode createMcpHandler}'s routing and the exported
* {@linkcode isLegacyRequest} predicate, so the two can never disagree.
* The entry's classification step, taking the web-standard `Request` itself —
* the Request-shaped sibling of {@linkcode classifyInboundRequest}. It
* extracts the HTTP method and the `MCP-Protocol-Version` / `Mcp-Method` /
* `Mcp-Name` headers, reads the request body exactly once (unless a
* pre-parsed body is supplied) with the unreadable-stream vs. no-JSON-body
* distinction, and classifies with {@linkcode classifyInboundRequest} — so a
* hand-wired composition never hand-assembles those fields. This is the
* single code path behind both {@linkcode createMcpHandler}'s routing and the
* exported {@linkcode isLegacyRequest} predicate, so the three can never
* disagree.
*
* Pass `needsForward: false` when the caller never reads `forwardRequest` —
* the body-preserving clone is then skipped and `forwardRequest` is the
* (consumed) input request.
* Where {@linkcode isLegacyRequest} collapses the decision to a boolean, this
* returns the full routing outcome — for hybrid deployments that need the
* routing reason. The canonical pattern routes the legacy `initialize`
* handshake to a sessionful host and everything else (modern traffic, other
* legacy traffic, rejections) to a stateless or strict handler:
*
* ```ts
* import { classifyEntryRequest, createMcpHandler } from '@modelcontextprotocol/server';
*
* const stateless = createMcpHandler(factory);
*
* async function serve(request: Request): Promise<Response> {
* const classified = await classifyEntryRequest(request);
* if (classified.step === 'unreadable-body') {
* return new Response(null, { status: 400 });
* }
* if (classified.step === 'classified' && classified.outcome.kind === 'legacy' && classified.outcome.reason === 'initialize') {
* // The 2025 handshake: open a session on the sessionful legacy host.
* return sessionHost(classified.forwardRequest);
* }
* // Hand the already-parsed body along so the entry classifies from the
* // value instead of reading the forwarded clone a second time.
* return stateless.fetch(classified.forwardRequest, classified.step === 'classified' ? { parsedBody: classified.parsedBody } : undefined);
* }
* ```

Check failure on line 441 in packages/server/src/server/createMcpHandler.ts

View check run for this annotation

Claude / Claude Code Review

Documented classifyEntryRequest hybrid pattern strands the session it opens

The 'canonical pattern' documented for the new `classifyEntryRequest` export (this JSDoc `@example`, the changeset, the new sentence in `docs/serving/legacy-clients.md`, and the new hybrid-routing test) routes only `outcome.reason === 'initialize'` to the sessionful host — but the classifier never inspects `Mcp-Session-Id`, so every follow-up from the session that host just opened (claim-less `tools/call` POSTs, `notifications/initialized`, the standalone GET SSE stream, DELETE termination, post
Comment on lines +417 to +441

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The 'canonical pattern' documented for the new classifyEntryRequest export (this JSDoc @example, the changeset, the new sentence in docs/serving/legacy-clients.md, and the new hybrid-routing test) routes only outcome.reason === 'initialize' to the sessionful host — but the classifier never inspects Mcp-Session-Id, so every follow-up from the session that host just opened (claim-less tools/call POSTs, notifications/initialized, the standalone GET SSE stream, DELETE termination, posted responses to server-initiated requests) classifies legacy with a different reason and lands on the stateless/strict leg: session traffic is served statelessly, GET/DELETE get 405, in-flight elicitInput hangs, and the session host's transport/server pair leaks. Route all legacy/session-bound traffic to the session host in the example, changeset, docs sentence, and test (as the elicitation example already does with kind === 'legacy'), or reword the pattern to a scenario where initialize-only routing is sound.

Extended reasoning...

The bug. The JSDoc @example on the newly exported classifyEntryRequest (createMcpHandler.ts:417-441) presents a 'canonical pattern': route classified.outcome.kind === 'legacy' && classified.outcome.reason === 'initialize' to a sessionful legacy host, and 'everything else (modern traffic, other legacy traffic, rejections)' to a stateless or strict createMcpHandler. The same pattern is repeated in .changeset/export-classify-entry-request.md, in the sentence added to docs/serving/legacy-clients.md ("sending only the legacy initialize handshake to a session host and everything else to the stateless handler"), and pinned by the new test "routes on the outcome reason — legacy initialize to a session host, everything else to the entry (the hybrid pattern)". As documented, the pattern strands the very session it opens.

Why. classifyInboundRequest (packages/core-internal/src/shared/inboundClassification.ts) never inspects the Mcp-Session-Id header. Its legacy reasons are 'batch' | 'initialize' | 'no-claim' | 'notification' | 'http-method' | 'response'. Every request a legacy client sends after the handshake — the requests the session exists for — classifies legacy with a reason other than 'initialize': session-bound tools/call POSTs are 'no-claim', notifications/initialized is 'notification', the standalone GET SSE stream and DELETE session termination are 'http-method', and posted responses to server→client requests (elicitation/sampling answers) are 'response'. Under the documented split all of them go to stateless.fetch / the strict handler.

What happens on the stateless leg. legacyStatelessFallback serves each POST from a fresh instance that knows nothing about the session, drops notifications with a 202, and answers GET/DELETE with 405 ("Method not allowed." — behavior pinned by the existing 'answers GET and DELETE like the canonical stateless example (405)' test). So the standalone GET stream — the entire reason to run a sessionful legacy arm (server-initiated elicitation, sampling, notifications) — is refused; DELETE is refused, so the session at the host can never be terminated and its transport/server pair leaks (ironically the exact leak class the first commit's closeOnRefusedHandshake targets, and that option cannot help here because the session was established); and responses the client posts to server-initiated requests never reach the session host, so any in-flight ctx.mcpReq.elicitInput(...) hangs forever.

Step-by-step proof. (1) A 2025-era client POSTs initialize with no _meta envelope → reason 'initialize' → routed to sessionHost, which mints an Mcp-Session-Id and connects a fresh Server. (2) The client sends notifications/initialized with that header → reason 'notification' (the classifier ignores the header) → stateless.fetch → 202, dropped; the session host's Server never learns initialization completed. (3) The client opens the standalone GET SSE stream with the header → 'http-method' → stateless leg → 405; no server→client channel exists. (4) The client calls a tool whose handler awaits elicitInput → the POST is 'no-claim' → served statelessly by an instance that has no session and no client capabilities; even if a call did reach the host, the client's posted elicitation response is 'response' → stateless leg → never delivered → the handler hangs. (5) DELETE → 'http-method' → 405; the host's transport/server pair lives until process exit.

Why nothing in the PR catches it. The new hybrid-routing test only sends a legacy initialize and modern traffic — it never sends a follow-up session-bound request, so the hole is invisible. And the one real in-repo consumer this PR adds (examples/elicitation/server.ts) does not use the reason-based split: it routes classified.step === 'no-json-body' || classified.outcome.kind === 'legacy' — i.e. all legacy traffic — to the sessionful arm and never reads outcome.reason, which is exactly the boolean isLegacyRequest already provides. So the reason-based routing that is the export's stated justification (changeset, docs sentence, this example) has no sound consumer in the tree, while anyone who copies the 'canonical pattern' ships silently broken legacy sessions plus a per-handshake resource leak.

How to fix. Correct the @example, the changeset text, the legacy-clients.md sentence, and the pinning test so all session-bound legacy traffic reaches the session host — e.g. reason === 'initialize' || request.headers.has('mcp-session-id') || the GET/DELETE/notification/response reasons, which collapses to kind === 'legacy' (what the elicitation example and the isLegacyRequest recipe in the same docs paragraph already do). If that collapse weakens the case for exporting the reason, restate the export's justification (e.g. access to parsedBody/forwardRequest from the single body read, which is what the elicitation example actually uses it for), or reword the pattern to a scenario where initialize-only routing is genuinely sound (e.g. session-affinity infrastructure owning all follow-ups). The exported function itself is fine; it's the advertised wiring around it that misleads.

*
* For a `POST` the body is read from the request you pass; `forwardRequest`
* is a body-preserving clone (or the input itself for body-less methods and
* pre-parsed bodies) that stays fully readable for whichever handler the
* outcome routes to. Pass `needsForward: false` when the caller never reads
* `forwardRequest` — the body-preserving clone is then skipped and
* `forwardRequest` is the (consumed) input request.
*/
async function classifyEntryRequest(request: Request, providedParsedBody?: unknown, needsForward = true): Promise<EntryClassification> {
export async function classifyEntryRequest(
request: Request,
providedParsedBody?: unknown,
needsForward = true
): Promise<EntryClassification> {
const httpMethod = request.method.toUpperCase();

let body: unknown;
Expand Down
Loading
Loading