-
Notifications
You must be signed in to change notification settings - Fork 2k
feat(server): closeOnRefusedHandshake option for per-handshake transports #2439
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`. |
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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. */ | ||
|
|
@@ -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
|
||
|
Comment on lines
+417
to
+441
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 The 'canonical pattern' documented for the new Extended reasoning...The bug. The JSDoc Why. What happens on the stateless leg. Step-by-step proof. (1) A 2025-era client POSTs Why nothing in the PR catches it. The new hybrid-routing test only sends a legacy How to fix. Correct the |
||
| * | ||
| * 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; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.