Skip to content

Commit f7f843f

Browse files
feat(server): export classifyEntryRequest for hand-wired hybrid routing
The entry's classification step takes a web-standard Request, extracts the HTTP method and the MCP-Protocol-Version / Mcp-Method / Mcp-Name headers, performs the single body read (distinguishing an unreadable stream from a non-JSON body), and classifies with classifyInboundRequest. It was internal, so hybrid deployments had to hand-assemble the classifier's fields — or settle for the boolean isLegacyRequest and lose the routing reason. Export it, together with its EntryClassification outcome type, as the Request-shaped sibling of classifyInboundRequest. Hybrid compositions can now route on the full outcome (for example: legacy initialize to a sessionful host, everything else to the stateless handler) while reusing the exact code path behind createMcpHandler and isLegacyRequest, including the body-preserving forwardRequest for whichever handler wins. Internal call sites and behavior are unchanged.
1 parent 5ed270c commit f7f843f

7 files changed

Lines changed: 204 additions & 25 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': minor
3+
---
4+
5+
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`.

docs/serving/legacy-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async function serve(request: Request): Promise<Response> {
7070
}
7171
```
7272

73-
`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.
73+
`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.
7474

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

examples/elicitation/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Server requests user input. One factory, both protocol eras: elicitation works o
1111
`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
1212
`enumNames` field (display labels for the `plan` enum). For the secure `requestState` round-trip pattern see [`../mrtr/`](../mrtr/README.md).
1313

14-
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.
14+
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.
1515

1616
```bash
1717
pnpm --filter @mcp-examples/elicitation client # 2026-07-28 (inputRequired)

examples/elicitation/server.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,10 @@ import type {
3333
} from '@modelcontextprotocol/server';
3434
import {
3535
acceptedContent,
36+
classifyEntryRequest,
3637
createMcpHandler,
3738
inputRequired,
3839
isInitializeRequest,
39-
isLegacyRequest,
4040
McpServer,
4141
UrlElicitationRequiredError
4242
} from '@modelcontextprotocol/server';
@@ -279,14 +279,18 @@ if (transport === 'stdio') {
279279

280280
createServer((req, res) => {
281281
void (async () => {
282-
// `toWebRequest` reads the Node body into a web-standard `Request`,
283-
// so the body now lives in `request`, not `req`. Ask the predicate
284-
// first — it classifies an internal clone, leaving `request`
285-
// readable for the `.json()` both arms need (reading `.json()`
286-
// first would make the predicate's internal clone throw).
282+
// `toWebRequest` reads the Node body into a web-standard `Request`.
283+
// One classification step reads it exactly once and returns the
284+
// routing outcome together with the parsed body both arms need —
285+
// no second `.json()` read, no clone-ordering pitfall.
287286
const request = await toWebRequest(req);
288-
const legacy = await isLegacyRequest(request);
289-
const body: unknown = req.method === 'POST' ? await request.json().catch(() => {}) : undefined;
287+
const classified = await classifyEntryRequest(request);
288+
if (classified.step === 'unreadable-body') {
289+
res.writeHead(400).end();
290+
return;
291+
}
292+
const legacy = classified.step === 'no-json-body' || classified.outcome.kind === 'legacy';
293+
const body: unknown = classified.step === 'classified' ? classified.parsedBody : undefined;
290294
await (legacy ? handleLegacy(req, res, body) : modern(req, res, body));
291295
})().catch(error => {
292296
console.error('[server] request error:', error instanceof Error ? error.message : error);

packages/server/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ export type { CompletableSchema, CompleteCallback } from './server/completable';
1010
export { completable, isCompletable } from './server/completable';
1111
export type {
1212
CreateMcpHandlerOptions,
13+
EntryClassification,
1314
LegacyHttpHandler,
1415
McpHandlerRequestOptions,
1516
McpHttpHandler,
1617
McpRequestContext,
1718
McpServerFactory
1819
} from './server/createMcpHandler';
19-
export { createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler';
20+
export { classifyEntryRequest, createMcpHandler, isLegacyRequest, legacyStatelessFallback } from './server/createMcpHandler';
2021
export type {
2122
AnyToolHandler,
2223
BaseToolCallback,
@@ -69,6 +70,7 @@ export { fromJsonSchema } from './fromJsonSchema';
6970

7071
// Inbound HTTP request classification (dual-era serving): the body-primary era
7172
// predicate used by createMcpHandler, exported for hand-wired compositions.
73+
// Its Request-shaped sibling, classifyEntryRequest, is exported above.
7274
export type {
7375
InboundClassificationOutcome,
7476
InboundHttpRequest,

packages/server/src/server/createMcpHandler.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -389,11 +389,12 @@ export function legacyStatelessFallback(factory: McpServerFactory, onerror?: (er
389389
}
390390

391391
/* ------------------------------------------------------------------------ *
392-
* The entry's classification step (shared with isLegacyRequest)
392+
* The entry's classification step (shared with isLegacyRequest, exported for
393+
* hand-wired hybrid routing)
393394
* ------------------------------------------------------------------------ */
394395

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

404405
/**
405-
* The entry's classification step: read the request body exactly once (unless
406-
* a pre-parsed body is supplied) and classify the request with
407-
* {@linkcode classifyInboundRequest}. This is the single code path behind both
408-
* {@linkcode createMcpHandler}'s routing and the exported
409-
* {@linkcode isLegacyRequest} predicate, so the two can never disagree.
406+
* The entry's classification step, taking the web-standard `Request` itself —
407+
* the Request-shaped sibling of {@linkcode classifyInboundRequest}. It
408+
* extracts the HTTP method and the `MCP-Protocol-Version` / `Mcp-Method` /
409+
* `Mcp-Name` headers, reads the request body exactly once (unless a
410+
* pre-parsed body is supplied) with the unreadable-stream vs. no-JSON-body
411+
* distinction, and classifies with {@linkcode classifyInboundRequest} — so a
412+
* hand-wired composition never hand-assembles those fields. This is the
413+
* single code path behind both {@linkcode createMcpHandler}'s routing and the
414+
* exported {@linkcode isLegacyRequest} predicate, so the three can never
415+
* disagree.
410416
*
411-
* Pass `needsForward: false` when the caller never reads `forwardRequest` —
412-
* the body-preserving clone is then skipped and `forwardRequest` is the
413-
* (consumed) input request.
417+
* Where {@linkcode isLegacyRequest} collapses the decision to a boolean, this
418+
* returns the full routing outcome — for hybrid deployments that need the
419+
* routing reason. The canonical pattern routes the legacy `initialize`
420+
* handshake to a sessionful host and everything else (modern traffic, other
421+
* legacy traffic, rejections) to a stateless or strict handler:
422+
*
423+
* ```ts
424+
* import { classifyEntryRequest, createMcpHandler } from '@modelcontextprotocol/server';
425+
*
426+
* const stateless = createMcpHandler(factory);
427+
*
428+
* async function serve(request: Request): Promise<Response> {
429+
* const classified = await classifyEntryRequest(request);
430+
* if (classified.step === 'unreadable-body') {
431+
* return new Response(null, { status: 400 });
432+
* }
433+
* if (classified.step === 'classified' && classified.outcome.kind === 'legacy' && classified.outcome.reason === 'initialize') {
434+
* // The 2025 handshake: open a session on the sessionful legacy host.
435+
* return sessionHost(classified.forwardRequest);
436+
* }
437+
* // Hand the already-parsed body along so the entry classifies from the
438+
* // value instead of reading the forwarded clone a second time.
439+
* return stateless.fetch(classified.forwardRequest, classified.step === 'classified' ? { parsedBody: classified.parsedBody } : undefined);
440+
* }
441+
* ```
442+
*
443+
* For a `POST` the body is read from the request you pass; `forwardRequest`
444+
* is a body-preserving clone (or the input itself for body-less methods and
445+
* pre-parsed bodies) that stays fully readable for whichever handler the
446+
* outcome routes to. Pass `needsForward: false` when the caller never reads
447+
* `forwardRequest` — the body-preserving clone is then skipped and
448+
* `forwardRequest` is the (consumed) input request.
414449
*/
415-
async function classifyEntryRequest(request: Request, providedParsedBody?: unknown, needsForward = true): Promise<EntryClassification> {
450+
export async function classifyEntryRequest(
451+
request: Request,
452+
providedParsedBody?: unknown,
453+
needsForward = true
454+
): Promise<EntryClassification> {
416455
const httpMethod = request.method.toUpperCase();
417456

418457
let body: unknown;

packages/server/test/server/createMcpHandler.test.ts

Lines changed: 131 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@
77
* faces, the per-request era write + client-identity backfill, notification
88
* routing, the response-mode knob, and close() teardown of the modern leg.
99
*/
10-
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
10+
import {
11+
CLIENT_CAPABILITIES_META_KEY,
12+
CLIENT_INFO_META_KEY,
13+
classifyInboundRequest,
14+
PROTOCOL_VERSION_META_KEY
15+
} from '@modelcontextprotocol/core-internal';
1116
import { describe, expect, it, vi } from 'vitest';
1217
import * as z from 'zod/v4';
1318

1419
import type { McpRequestContext } from '../../src/server/createMcpHandler';
15-
import { createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler';
20+
import { classifyEntryRequest, createMcpHandler, isLegacyRequest } from '../../src/server/createMcpHandler';
1621
import { McpServer } from '../../src/server/mcp';
1722
import { PerRequestHTTPServerTransport } from '../../src/server/perRequestTransport';
1823

@@ -736,6 +741,130 @@ describe('createMcpHandler — user-land routing with isLegacyRequest (replaces
736741
});
737742
});
738743

744+
describe('classifyEntryRequest — the Request-shaped sibling of classifyInboundRequest', () => {
745+
const legacyInitialize = {
746+
jsonrpc: '2.0',
747+
id: 'init-1',
748+
method: 'initialize',
749+
params: { protocolVersion: '2025-11-25', clientInfo: { name: 'legacy', version: '1.0' }, capabilities: {} }
750+
};
751+
752+
/** The fields a hand-wired composition would extract itself to call {@linkcode classifyInboundRequest} directly. */
753+
function handExtractedFields(request: Request, body?: unknown): Parameters<typeof classifyInboundRequest>[0] {
754+
return {
755+
httpMethod: request.method.toUpperCase(),
756+
protocolVersionHeader: request.headers.get('mcp-protocol-version') ?? undefined,
757+
mcpMethodHeader: request.headers.get('mcp-method') ?? undefined,
758+
mcpNameHeader: request.headers.get('mcp-name') ?? undefined,
759+
...(body !== undefined && { body })
760+
};
761+
}
762+
763+
it('classifies representative requests exactly as classifyInboundRequest classifies the hand-extracted fields', async () => {
764+
const cases: Array<{ name: string; body?: unknown; request: () => Request }> = [
765+
{
766+
name: 'modern envelope POST',
767+
body: modernToolsCall('echo', { text: 'x' }),
768+
request: () => postRequest(modernToolsCall('echo', { text: 'x' }))
769+
},
770+
{ name: 'legacy initialize POST', body: legacyInitialize, request: () => postRequest(legacyInitialize) },
771+
{ name: 'GET session operation', request: () => new Request('http://localhost/mcp', { method: 'GET' }) },
772+
{
773+
name: 'header-only modern (modern header, claim-less body)',
774+
body: { jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} },
775+
request: () =>
776+
postRequest(
777+
{ jsonrpc: '2.0', id: 11, method: 'tools/list', params: {} },
778+
{ 'mcp-protocol-version': MODERN_REVISION, 'mcp-method': 'tools/list' }
779+
)
780+
}
781+
];
782+
783+
for (const { name, body, request } of cases) {
784+
const classified = await classifyEntryRequest(request());
785+
expect(classified.step, name).toBe('classified');
786+
if (classified.step !== 'classified') continue;
787+
expect(classified.outcome, name).toEqual(classifyInboundRequest(handExtractedFields(request(), body)));
788+
expect(classified.body, name).toEqual(body);
789+
}
790+
});
791+
792+
it('reports a POST with a non-JSON body as no-json-body (nothing to hand-extract), with the bytes preserved for routing', async () => {
793+
const classified = await classifyEntryRequest(postRequest('{not json'));
794+
expect(classified.step).toBe('no-json-body');
795+
if (classified.step !== 'no-json-body') return;
796+
expect(await classified.forwardRequest.text()).toBe('{not json');
797+
});
798+
799+
it('reads the input body exactly once and hands routing a still-readable forwardRequest (the entry contract)', async () => {
800+
const original = { jsonrpc: '2.0', id: 3, method: 'tools/list', params: {} };
801+
const request = postRequest(original);
802+
const classified = await classifyEntryRequest(request);
803+
804+
expect(classified.step).toBe('classified');
805+
if (classified.step !== 'classified') return;
806+
// The single read consumed the input request...
807+
expect(request.bodyUsed).toBe(true);
808+
// ...and forwardRequest carries the original bytes, unread — exactly
809+
// what createMcpHandler hands its legacy leg.
810+
expect(classified.forwardRequest.bodyUsed).toBe(false);
811+
expect(await classified.forwardRequest.text()).toBe(JSON.stringify(original));
812+
expect(classified.parsedBody).toEqual(original);
813+
814+
// With a pre-parsed body the stream is never touched and no clone is made.
815+
const preParsed = postRequest(original);
816+
const classifiedPreParsed = await classifyEntryRequest(preParsed, original);
817+
expect(classifiedPreParsed.step).toBe('classified');
818+
if (classifiedPreParsed.step !== 'classified') return;
819+
expect(preParsed.bodyUsed).toBe(false);
820+
expect(classifiedPreParsed.forwardRequest).toBe(preParsed);
821+
822+
// needsForward: false skips the body-preserving clone — forwardRequest
823+
// is then the (consumed) input request, the isLegacyRequest fast path.
824+
const noForward = postRequest(original);
825+
const classifiedNoForward = await classifyEntryRequest(noForward, undefined, false);
826+
expect(classifiedNoForward.step).toBe('classified');
827+
if (classifiedNoForward.step !== 'classified') return;
828+
expect(classifiedNoForward.forwardRequest).toBe(noForward);
829+
expect(noForward.bodyUsed).toBe(true);
830+
});
831+
832+
it('routes on the outcome reason — legacy initialize to a session host, everything else to the entry (the hybrid pattern)', async () => {
833+
const { factory } = testFactory();
834+
const handler = createMcpHandler(factory, { legacy: 'reject' });
835+
const sessionHost = vi.fn(async (request: Request) => new Response(await request.text(), { status: 299 }));
836+
837+
const route = async (request: Request): Promise<Response> => {
838+
const classified = await classifyEntryRequest(request);
839+
if (classified.step === 'unreadable-body') {
840+
return new Response(null, { status: 400 });
841+
}
842+
if (classified.step === 'classified' && classified.outcome.kind === 'legacy' && classified.outcome.reason === 'initialize') {
843+
return sessionHost(classified.forwardRequest);
844+
}
845+
// Hand the already-parsed body along so the entry classifies from
846+
// the value instead of reading the forwarded clone a second time.
847+
return handler.fetch(
848+
classified.forwardRequest,
849+
classified.step === 'classified' ? { parsedBody: classified.parsedBody } : undefined
850+
);
851+
};
852+
853+
// The 2025 handshake reaches the session host with its bytes intact.
854+
const initResponse = await route(postRequest(legacyInitialize));
855+
expect(initResponse.status).toBe(299);
856+
expect(await initResponse.text()).toBe(JSON.stringify(legacyInitialize));
857+
858+
// Modern traffic is served by the entry from the forwarded clone —
859+
// the entry's own single body read still works downstream.
860+
const modernResponse = await route(postRequest(modernToolsCall('echo', { text: 'routed' })));
861+
expect(modernResponse.status).toBe(200);
862+
const body = (await modernResponse.json()) as { result: { content: Array<{ text: string }> } };
863+
expect(body.result.content[0]?.text).toBe('routed');
864+
expect(sessionHost).toHaveBeenCalledTimes(1);
865+
});
866+
});
867+
739868
describe('createMcpHandler — responseMode', () => {
740869
it('defaults to the lazy upgrade: a handler emitting a related notification streams the exchange over SSE', async () => {
741870
const { factory } = testFactory();

0 commit comments

Comments
 (0)