From ecc4aca76a6d0c645eb88236ed822c3ffdffe929 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:47:28 +0000 Subject: [PATCH 1/6] =?UTF-8?q?feat(node):=20export=20toWebRequest(),=20th?= =?UTF-8?q?e=20IncomingMessage=E2=86=92Request=20conversion=20inside=20toN?= =?UTF-8?q?odeHandler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routing on isLegacyRequest from a plain Node `(req, res)` handler required hand-assembling a `globalThis.Request` from `req.headers` (header-array and HTTP/2 pseudo-header pitfalls included), even though toNodeHandler already contains the correct conversion as a private helper. Export that helper as `toWebRequest(req, parsedBody?, options?)` from `@modelcontextprotocol/node`. The function body is unchanged; toNodeHandler now calls it with `{ signal }` in an options bag instead of a positional AbortSignal, so the adapter's behavior is unchanged. Pass `parsedBody` when a body parser already consumed the Node stream; `options.signal` ties the constructed request to client disconnect the way toNodeHandler does. Adds unit tests for both body paths (the parsedBody case asserts the Node stream is never touched), header copying, the signal option, and the clone-readability contract isLegacyRequest relies on. README + changeset. --- .changeset/node-export-to-web-request.md | 5 + packages/middleware/node/README.md | 2 + packages/middleware/node/src/index.ts | 5 +- packages/middleware/node/src/toNodeHandler.ts | 59 ++++++- .../middleware/node/test/toWebRequest.test.ts | 158 ++++++++++++++++++ 5 files changed, 223 insertions(+), 6 deletions(-) create mode 100644 .changeset/node-export-to-web-request.md create mode 100644 packages/middleware/node/test/toWebRequest.test.ts diff --git a/.changeset/node-export-to-web-request.md b/.changeset/node-export-to-web-request.md new file mode 100644 index 0000000000..c061b40446 --- /dev/null +++ b/.changeset/node-export-to-web-request.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/node': minor +--- + +Export `toWebRequest(req, parsedBody?, options?)` — the Node `IncomingMessage` → web-standard `Request` conversion `toNodeHandler` already performs internally. Use it to feed `isLegacyRequest()` (or `handler.fetch()`) from a hand-wired Node/Express `(req, res)` handler instead of assembling a `globalThis.Request` from `req.headers` by hand. When a body parser already consumed the Node stream (`express.json()`), pass the parsed value as `parsedBody`; pass `options.signal` to tie the constructed request to client disconnect, the way `toNodeHandler` does. diff --git a/packages/middleware/node/README.md b/packages/middleware/node/README.md index 15a8e8b9c1..ceedbcc144 100644 --- a/packages/middleware/node/README.md +++ b/packages/middleware/node/README.md @@ -18,6 +18,8 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/node - `StreamableHTTPServerTransportOptions` (type alias for `WebStandardStreamableHTTPServerTransportOptions`) - `toNodeHandler(handler, opts?)` — adapt a web-standard `{ fetch }` MCP handler to a Node `(req, res, parsedBody?)` handler - `ToNodeHandlerOptions`, `FetchLikeMcpHandler`, `NodeMcpRequestHandler` (types for `toNodeHandler`) +- `toWebRequest(req, parsedBody?, opts?)` — the Node `IncomingMessage` → web-standard `Request` conversion `toNodeHandler` performs internally, exported on its own (for example to feed `isLegacyRequest()` from a hand-wired `(req, res)` handler) +- `ToWebRequestOptions` (options type for `toWebRequest`) - `NodeIncomingMessageLike`, `NodeServerResponseLike` (structural Node request/response shapes) ## Usage diff --git a/packages/middleware/node/src/index.ts b/packages/middleware/node/src/index.ts index 31cbc30c9e..94f556e3d8 100644 --- a/packages/middleware/node/src/index.ts +++ b/packages/middleware/node/src/index.ts @@ -6,6 +6,7 @@ export type { NodeIncomingMessageLike, NodeMcpRequestHandler, NodeServerResponseLike, - ToNodeHandlerOptions + ToNodeHandlerOptions, + ToWebRequestOptions } from './toNodeHandler'; -export { toNodeHandler } from './toNodeHandler'; +export { toNodeHandler, toWebRequest } from './toNodeHandler'; diff --git a/packages/middleware/node/src/toNodeHandler.ts b/packages/middleware/node/src/toNodeHandler.ts index ed345492ec..fa72f91b31 100644 --- a/packages/middleware/node/src/toNodeHandler.ts +++ b/packages/middleware/node/src/toNodeHandler.ts @@ -17,6 +17,11 @@ * app.all('/mcp', (req, res) => void node(req, res, req.body)); * ``` * + * The Node→web `Request` conversion the adapter performs is also exported on + * its own as {@linkcode toWebRequest}, for hand-wired compositions (for + * example, routing on `isLegacyRequest`) that need the web-standard `Request` + * without the rest of the adapter. + * * The Node request/response shapes are duck-typed (kept structural so this * module stays free of `node:` imports); the conversion reads `req.auth` * (validated authentication info attached by upstream middleware) and forwards @@ -111,7 +116,7 @@ export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandler let response: Response; try { - const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal); + const request = await toWebRequest(req, parsedBody, { signal: abort.signal }); response = await handler.fetch(request, { ...(req.auth !== undefined && { authInfo: req.auth }), ...(parsedBody !== undefined && { parsedBody }) @@ -175,14 +180,60 @@ export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandler } /* ------------------------------------------------------------------------ * - * Node request conversion (duck-typed; no node: imports) + * Node request conversion — `toWebRequest` (duck-typed; no node: imports) * ------------------------------------------------------------------------ */ function singleHeaderValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } -async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBody: unknown, signal: AbortSignal): Promise { +/** Options for {@linkcode toWebRequest}. */ +export interface ToWebRequestOptions { + /** + * An `AbortSignal` to attach to the constructed `Request` as + * `request.signal`. {@linkcode toNodeHandler} threads the controller it + * wires to `res.on('close')` through here, so an in-flight `handler.fetch` + * (and any SSE response it streams) observes the client disconnecting; + * hand-wired callers that forward the converted request to a handler + * themselves want the same wiring. + */ + signal?: AbortSignal; +} + +/** + * Convert a Node.js `IncomingMessage` (duck-typed — an Express `req` is one) + * to the web-standard `Request` the `@modelcontextprotocol/server` + * surface takes (`handler.fetch(request)`, `isLegacyRequest(request)`). This + * is the exact conversion {@linkcode toNodeHandler} performs internally, + * exported on its own so a hand-wired Node `(req, res)` handler never has to + * assemble a `globalThis.Request` from `req.headers` by hand: + * + * ```ts + * import { toWebRequest } from '@modelcontextprotocol/node'; + * import { isLegacyRequest } from '@modelcontextprotocol/server'; + * + * // Express: `express.json()` already consumed the stream — pass `req.body`. + * app.post('/mcp', async (req, res) => { + * const probe = await toWebRequest(req, req.body); + * await ((await isLegacyRequest(probe)) ? legacy(req, res) : modern(req, res, req.body)); + * }); + * ``` + * + * The URL is derived from the `Host` header (`localhost` when absent) and + * `req.url`; HTTP/2 pseudo-headers (`:authority`, …) are dropped, multi-valued + * headers are appended in order, and the method is uppercased (defaulting to + * `GET`). + * + * The body, for a non-`GET`/`HEAD` request: with no `parsedBody`, **the Node + * stream is read to completion** here — read anything you still need from the + * returned `Request` (`request.json()`, a clone), not from `req`. When a body + * parser already consumed the stream (`express.json()`), pass the parsed value + * as `parsedBody`: nothing is read from `req`, the value is re-serialized as + * the `Request` body, and the entity headers (`content-length`, + * `content-encoding`, `transfer-encoding`) are rewritten to describe the + * re-serialized bytes rather than the original ones. + */ +export async function toWebRequest(req: NodeIncomingMessageLike, parsedBody?: unknown, options?: ToWebRequestOptions): Promise { const method = (req.method ?? 'GET').toUpperCase(); const host = singleHeaderValue(req.headers['host']) ?? 'localhost'; const url = `http://${host}${req.url ?? '/'}`; @@ -241,7 +292,7 @@ async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBod return new Request(url, { method, headers, - signal, + ...(options?.signal !== undefined && { signal: options.signal }), ...(body !== undefined && { body }) }); } diff --git a/packages/middleware/node/test/toWebRequest.test.ts b/packages/middleware/node/test/toWebRequest.test.ts new file mode 100644 index 0000000000..e7ae40bb5e --- /dev/null +++ b/packages/middleware/node/test/toWebRequest.test.ts @@ -0,0 +1,158 @@ +/** + * `toWebRequest(req, parsedBody?, options?)` — the exported Node + * `IncomingMessage` → web-standard `Request` conversion. Covers the two body + * paths (the Node stream read vs. a supplied `parsedBody` re-serialized, with + * the entity headers rewritten and the stream untouched), Host-header URL + * derivation, header copying (multi-valued append, HTTP/2 pseudo-header + * skipping), the GET/HEAD no-body rule, the `signal` option, and the + * clone-readability contract `isLegacyRequest(request)` relies on. The full + * adapter exercises the same conversion end-to-end in `toNodeHandler.test.ts`. + */ +import { Readable } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; + +import type { NodeIncomingMessageLike } from '../src/toNodeHandler'; +import { toWebRequest } from '../src/toNodeHandler'; + +function nodeRequest(init: { + method?: string; + url?: string; + headers?: Record; + body?: string; +}): NodeIncomingMessageLike { + return Object.assign(Readable.from(init.body === undefined ? [] : [init.body]), { + method: init.method, + url: init.url, + headers: init.headers ?? {} + }); +} + +/** A request whose Node stream rejects if anything iterates it. */ +function unreadableNodeRequest(init: { + method?: string; + url?: string; + headers?: Record; +}): NodeIncomingMessageLike { + return { + method: init.method, + url: init.url, + headers: init.headers ?? {}, + [Symbol.asyncIterator](): AsyncIterator { + return { next: () => Promise.reject(new Error('the Node stream must not be read when parsedBody is supplied')) }; + } + }; +} + +describe('toWebRequest', () => { + it('reads the Node stream as the body when no parsedBody is supplied', async () => { + const raw = JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'ping' }); + const request = await toWebRequest( + nodeRequest({ + method: 'post', + url: '/mcp', + headers: { host: 'localhost:3000', 'content-type': 'application/json' }, + body: raw + }) + ); + + expect(request.method).toBe('POST'); + expect(request.url).toBe('http://localhost:3000/mcp'); + expect(request.headers.get('content-type')).toBe('application/json'); + expect(await request.text()).toBe(raw); + }); + + it('re-serializes a supplied parsedBody, rewrites the entity headers, and never touches the Node stream', async () => { + // A non-ASCII character keeps the byte length and the string length + // apart, so the rewritten content-length is provably the byte count. + const parsed = { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'écho' } }; + const request = await toWebRequest( + unreadableNodeRequest({ + method: 'POST', + url: '/mcp', + headers: { + host: 'example.test:4321', + 'content-type': 'application/json', + 'content-length': '999', + 'content-encoding': 'gzip', + 'transfer-encoding': 'chunked', + accept: ['application/json', 'text/event-stream'] + } + }), + parsed + ); + + expect(request.method).toBe('POST'); + expect(request.url).toBe('http://example.test:4321/mcp'); + expect(request.headers.get('content-type')).toBe('application/json'); + // Multi-valued Node headers are appended, not collapsed to the first value. + expect(request.headers.get('accept')).toBe('application/json, text/event-stream'); + // The entity headers described the original raw bytes; they are gone or rewritten. + expect(request.headers.get('content-encoding')).toBeNull(); + expect(request.headers.get('transfer-encoding')).toBeNull(); + const text = await request.text(); + expect(text).toBe(JSON.stringify(parsed)); + expect(request.headers.get('content-length')).toBe(String(text.length + 1)); + }); + + it('produces a body-less Request when the supplied parsedBody is not JSON-serializable', async () => { + const request = await toWebRequest( + unreadableNodeRequest({ method: 'POST', url: '/mcp', headers: { host: 'localhost', 'content-length': '42' } }), + // JSON.stringify(() => {}) is undefined: there are no bytes to describe. + () => {} + ); + expect(request.body).toBeNull(); + expect(request.headers.get('content-length')).toBeNull(); + }); + + it('derives the URL host from the Host header (falling back to localhost)', async () => { + const withHost = await toWebRequest(nodeRequest({ method: 'GET', url: '/a?b=1', headers: { host: 'api.example.test' } })); + expect(new URL(withHost.url).host).toBe('api.example.test'); + expect(new URL(withHost.url).pathname).toBe('/a'); + expect(new URL(withHost.url).search).toBe('?b=1'); + + const withoutHost = await toWebRequest(nodeRequest({ method: 'GET', url: '/a' })); + expect(new URL(withoutHost.url).host).toBe('localhost'); + }); + + it('skips HTTP/2 pseudo-headers, whose names Headers rejects', async () => { + const request = await toWebRequest( + nodeRequest({ + method: 'GET', + url: '/mcp', + headers: { host: 'h2.example.test', ':authority': 'h2.example.test', ':path': '/mcp', 'mcp-protocol-version': '2026-07-28' } + }) + ); + expect(new URL(request.url).host).toBe('h2.example.test'); + expect(request.headers.get('mcp-protocol-version')).toBe('2026-07-28'); + }); + + it('produces a body-less Request for GET/HEAD even when parsedBody is supplied', async () => { + const request = await toWebRequest(nodeRequest({ method: 'GET', url: '/mcp', headers: { host: 'localhost' } }), { + ignored: true + }); + expect(request.method).toBe('GET'); + expect(request.body).toBeNull(); + }); + + it('attaches options.signal to the constructed Request', async () => { + const controller = new AbortController(); + const request = await toWebRequest(nodeRequest({ method: 'GET', url: '/mcp', headers: { host: 'localhost' } }), undefined, { + signal: controller.signal + }); + expect(request.signal.aborted).toBe(false); + controller.abort(); + expect(request.signal.aborted).toBe(true); + }); + + it('returns a Request whose body a clone-reader leaves readable (the isLegacyRequest contract)', async () => { + const raw = JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'initialize', params: {} }); + const request = await toWebRequest( + nodeRequest({ method: 'POST', url: '/mcp', headers: { host: 'localhost', 'content-type': 'application/json' }, body: raw }) + ); + // `isLegacyRequest(request)` classifies a clone; the caller's request + // must stay readable for whichever handler it routes to. + expect(await request.clone().text()).toBe(raw); + expect(await request.text()).toBe(raw); + }); +}); From eb2d1394490d64631a8a7b52b1a84a9824df2eca Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:47:42 +0000 Subject: [PATCH 2/6] docs(server): lead isLegacyRequest's JSDoc with the single-argument form MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isLegacyRequest(request) is the whole API: the body is read from an internal clone, so the caller's request stays readable for whichever handler it is routed to. That fact was the final paragraph of a long doc comment, after the routing semantics, which made the optional `parsedBody` read like a required companion. Move it into a lead paragraph: the single-argument form first, `parsedBody` as a perf escape hatch for a body the caller already holds parsed, and the one case it is genuinely needed (a Request whose own body was already read, where the internal clone would throw). Also point Node `(req, res)` callers at `toWebRequest(req)` — and `toWebRequest(req, req.body)` behind a body parser, since the Node stream is already drained there — from the companion `@modelcontextprotocol/node` change. Documentation only; no behavior change. --- .changeset/is-legacy-request-doc-lead.md | 5 +++++ .../server/src/server/createMcpHandler.ts | 22 +++++++++++++------ 2 files changed, 20 insertions(+), 7 deletions(-) create mode 100644 .changeset/is-legacy-request-doc-lead.md diff --git a/.changeset/is-legacy-request-doc-lead.md b/.changeset/is-legacy-request-doc-lead.md new file mode 100644 index 0000000000..a3e2c7983e --- /dev/null +++ b/.changeset/is-legacy-request-doc-lead.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +`isLegacyRequest` docs: lead with the single-argument form. `isLegacyRequest(request)` is the whole API — the body is read from an internal clone, so the request you pass stays readable for whichever handler you route it to. `parsedBody` is an optional perf escape for a body you already hold parsed (and the way in for an already-consumed stream, e.g. behind `express.json()`), not a required companion. Documentation only; no behavior change. diff --git a/packages/server/src/server/createMcpHandler.ts b/packages/server/src/server/createMcpHandler.ts index b2e4828a2a..609772bde5 100644 --- a/packages/server/src/server/createMcpHandler.ts +++ b/packages/server/src/server/createMcpHandler.ts @@ -465,6 +465,21 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno * Whether {@linkcode createMcpHandler} would route this request to its legacy * (2025-era) serving rather than the modern (2026-07-28) path. * + * Call it with just the request: `await isLegacyRequest(request)`. For a + * `POST` the body is read from an internal clone, so the request you pass + * stays fully readable for whichever handler you route it to — no second + * argument is needed. (In a Node `(req, res)` handler, build that `Request` + * with `toWebRequest(req)` from `@modelcontextprotocol/node`; behind a body + * parser, which has already drained the Node stream, build it as + * `toWebRequest(req, req.body)` so the bytes come from the parsed body — + * either way the predicate still takes just the request.) The optional + * `parsedBody` is a perf escape hatch for a body you already hold parsed: + * pass it and the predicate classifies from the value directly, reading and + * cloning nothing. It is needed, not just faster, when the request's own + * body was already read — the internal clone is then impossible (cloning a + * used body throws a `TypeError`), so such a single-argument call rejects + * instead of guessing. + * * This is the entry's own classification step exported as a predicate — it * runs exactly the code `createMcpHandler` runs to make the routing decision, * not a re-implementation — so a hand-wired composition that branches on it @@ -509,13 +524,6 @@ async function classifyEntryRequest(request: Request, providedParsedBody?: unkno * envelope claim, so they are never legacy; a hand-built claim-less POST to * a method named `server/discover` has no claim and classifies legacy, * exactly as the entry itself routes it. - * - * The body is read from a clone, so the passed request stays readable for - * whichever handler the caller routes it to. If the body has already been - * consumed (for example behind `express.json()`), pass the parsed body as the - * second argument and no body read happens at all — without it the predicate - * cannot classify a consumed POST body (cloning a used body throws a - * `TypeError`), so the call rejects instead of guessing. */ export async function isLegacyRequest(request: Request, parsedBody?: unknown): Promise { // Classify a clone so the caller's request body stays readable; with a From 0d881cbb78e049ed7533f19320fbe200d2d3f3dd Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 14:47:42 +0000 Subject: [PATCH 3/6] refactor(examples): route on isLegacyRequest via toWebRequest, not a hand-built globalThis.Request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy-routing and elicitation servers each hand-read the Node body into Buffer chunks, JSON.parsed it, and constructed a `globalThis.Request` from `req.headers` (cast to `Record`) just to call isLegacyRequest — with a second argument the predicate does not need. Use the exported `toWebRequest(req[, parsedBody])` from `@modelcontextprotocol/node` instead, and call the predicate with just the request. In legacy-routing (Express 5) the conversion is always handed a parsed body (`req.body ?? {}` — body-parser leaves `req.body` undefined and the stream unread for a non-JSON content type), so it never consumes a stream the legacy transport still needs to read. In elicitation (plain `node:http`) `toWebRequest` reads the stream once and both routing arms take the body from the resulting `Request`; the predicate runs first, since it classifies an internal clone and leaves the request readable. No change to what either example demonstrates: for every input class the same arm fires with the same body value as before. --- examples/elicitation/server.ts | 28 ++++++++++------------------ examples/legacy-routing/server.ts | 18 +++++++++--------- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/examples/elicitation/server.ts b/examples/elicitation/server.ts index f0f1d7faa1..9b7ebac1dd 100644 --- a/examples/elicitation/server.ts +++ b/examples/elicitation/server.ts @@ -23,7 +23,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer } from 'node:http'; import { parseExampleArgs } from '@mcp-examples/shared'; -import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node'; import type { CallToolResult, ElicitRequestFormParams, @@ -275,23 +275,15 @@ if (transport === 'stdio') { createServer((req, res) => { void (async () => { - // Read the body once for the predicate and pass it forward. - let body: unknown; - if (req.method === 'POST') { - const chunks: Buffer[] = []; - for await (const chunk of req) chunks.push(chunk as Buffer); - const raw = Buffer.concat(chunks).toString('utf8'); - try { - body = raw ? JSON.parse(raw) : undefined; - } catch { - body = undefined; - } - } - const probe = new globalThis.Request(`http://localhost${req.url ?? '/'}`, { - method: req.method, - headers: req.headers as Record - }); - await ((await isLegacyRequest(probe, body)) ? handleLegacy(req, res, body) : modern(req, res, body)); + // `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). + const request = await toWebRequest(req); + const legacy = await isLegacyRequest(request); + const body: unknown = req.method === 'POST' ? await request.json().catch(() => {}) : undefined; + await (legacy ? handleLegacy(req, res, body) : modern(req, res, body)); })().catch(error => { console.error('[server] request error:', error instanceof Error ? error.message : error); if (!res.headersSent) res.writeHead(500).end(); diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index efc0f88e02..bea20c8432 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -14,7 +14,7 @@ import { randomUUID } from 'node:crypto'; import { parseExampleArgs } from '@mcp-examples/shared'; import { createMcpExpressApp } from '@modelcontextprotocol/express'; -import { NodeStreamableHTTPServerTransport, toNodeHandler } from '@modelcontextprotocol/node'; +import { NodeStreamableHTTPServerTransport, toNodeHandler, toWebRequest } from '@modelcontextprotocol/node'; import type { McpRequestContext } from '@modelcontextprotocol/server'; import { createMcpHandler, isInitializeRequest, isLegacyRequest, McpServer } from '@modelcontextprotocol/server'; import cors from 'cors'; @@ -72,14 +72,14 @@ app.use( ); app.post('/mcp', async (req: Request, res: Response) => { - // The predicate inspects the same headers + body the entry does. Express - // has parsed the JSON body; pass it as `parsedBody` so the predicate need - // not re-read the stream. - const probe = new globalThis.Request(`http://localhost${req.url}`, { - method: req.method, - headers: req.headers as Record - }); - await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modernNode(req, res, req.body)); + // `toWebRequest` converts the Node request into the web-standard `Request` + // the predicate inspects. Express owns the body here: `express.json()` + // already consumed the stream (or, for a non-JSON content type, skipped + // it and left `req.body` undefined). Always hand the conversion a parsed + // body — `?? {}` — so it never falls back to reading a stream the routed + // arm still needs; the predicate then takes just the request. + const probe = await toWebRequest(req, req.body ?? {}); + await ((await isLegacyRequest(probe)) ? handleLegacy(req, res) : modernNode(req, res, req.body)); }); // GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE // (explicit session termination per the MCP spec) are sessionful-2025-only — From 08980d6682e30e25c9ae77179a92d3864aae3524 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 18:53:04 +0000 Subject: [PATCH 4/6] docs(examples): correct what the legacy-routing comment claims for an unparsed body MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `{}` probe classifies as an invalid JSON-RPC body (a reject), not as legacy, so a POST that `express.json()` does not parse routes to the strict modern arm — the previous comment claimed the legacy routing was preserved. The code is unchanged: `?? {}` is still the right call, because the alternative (no `parsedBody`) makes `toWebRequest` read the Node stream out from under the legacy transport, which still needs it. Say what the line does instead of what it preserves. --- examples/legacy-routing/server.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index bea20c8432..d650fc62d2 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -73,11 +73,12 @@ app.use( app.post('/mcp', async (req: Request, res: Response) => { // `toWebRequest` converts the Node request into the web-standard `Request` - // the predicate inspects. Express owns the body here: `express.json()` - // already consumed the stream (or, for a non-JSON content type, skipped - // it and left `req.body` undefined). Always hand the conversion a parsed - // body — `?? {}` — so it never falls back to reading a stream the routed - // arm still needs; the predicate then takes just the request. + // the predicate inspects. Express owns the body here, so always hand the + // conversion a parsed body (`?? {}` — `express.json()` leaves `req.body` + // undefined for a body it does not parse) rather than letting it read a + // stream the legacy arm may still need. The predicate takes just the + // request; a body Express could not parse as JSON does not classify as + // legacy and falls through to the strict modern arm. const probe = await toWebRequest(req, req.body ?? {}); await ((await isLegacyRequest(probe)) ? handleLegacy(req, res) : modernNode(req, res, req.body)); }); From dc9bc958e2088153308be22b2ef813dc3f960f5c Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 19:02:36 +0000 Subject: [PATCH 5/6] refactor(examples): pass req.body to toWebRequest plainly, matching its docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the `?? {}`. It existed to keep `toWebRequest` from reading the Node stream for the one input where Express leaves `req.body` undefined AND the stream unread — a POST whose content type `express.json()` will not parse. That is not valid MCP traffic, the example's client never sends it, and the old code already routed it differently across Express majors (an unparsed body is `{}` on Express 4 but `undefined` on Express 5), so there was no stable behavior to preserve and no justification for a special case in the canonical example. `toWebRequest(req, req.body)` is the form the function's own docs and the changeset teach; the example now matches them, and for everything `express.json()` parses it routes exactly as before. --- examples/legacy-routing/server.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/examples/legacy-routing/server.ts b/examples/legacy-routing/server.ts index d650fc62d2..33547b2384 100644 --- a/examples/legacy-routing/server.ts +++ b/examples/legacy-routing/server.ts @@ -72,14 +72,9 @@ app.use( ); app.post('/mcp', async (req: Request, res: Response) => { - // `toWebRequest` converts the Node request into the web-standard `Request` - // the predicate inspects. Express owns the body here, so always hand the - // conversion a parsed body (`?? {}` — `express.json()` leaves `req.body` - // undefined for a body it does not parse) rather than letting it read a - // stream the legacy arm may still need. The predicate takes just the - // request; a body Express could not parse as JSON does not classify as - // legacy and falls through to the strict modern arm. - const probe = await toWebRequest(req, req.body ?? {}); + // `toWebRequest` builds the web-standard `Request` the predicate takes. + // Express has already parsed (and consumed) the JSON body — pass it along. + const probe = await toWebRequest(req, req.body); await ((await isLegacyRequest(probe)) ? handleLegacy(req, res) : modernNode(req, res, req.body)); }); // GET (standalone SSE stream / reconnect with Last-Event-ID) and DELETE From 48b9c902b3843f611092aa27d2ba797ab45f5817 Mon Sep 17 00:00:00 2001 From: Felix Weinberger Date: Mon, 29 Jun 2026 19:08:20 +0000 Subject: [PATCH 6/6] docs(node): tighten the toWebRequest JSDoc to its contract Keep what it is, where it came from, a two-line usage, and the one sharp edge (no `parsedBody` -> the Node stream is read to completion, so read the body from the returned `Request`, not from `req`; with `parsedBody` nothing is read). Drop the URL/header/method mechanics inventory, the entity-header rewrite detail (an implementation detail nobody programs against), the full Express route duplicated from examples/legacy-routing, and the essay on the `signal` option whose useful content is one line. --- packages/middleware/node/src/toNodeHandler.ts | 49 +++++-------------- 1 file changed, 12 insertions(+), 37 deletions(-) diff --git a/packages/middleware/node/src/toNodeHandler.ts b/packages/middleware/node/src/toNodeHandler.ts index fa72f91b31..4d283a95c1 100644 --- a/packages/middleware/node/src/toNodeHandler.ts +++ b/packages/middleware/node/src/toNodeHandler.ts @@ -19,8 +19,7 @@ * * The Node→web `Request` conversion the adapter performs is also exported on * its own as {@linkcode toWebRequest}, for hand-wired compositions (for - * example, routing on `isLegacyRequest`) that need the web-standard `Request` - * without the rest of the adapter. + * example, routing on `isLegacyRequest`). * * The Node request/response shapes are duck-typed (kept structural so this * module stays free of `node:` imports); the conversion reads `req.auth` @@ -189,49 +188,25 @@ function singleHeaderValue(value: string | string[] | undefined): string | undef /** Options for {@linkcode toWebRequest}. */ export interface ToWebRequestOptions { - /** - * An `AbortSignal` to attach to the constructed `Request` as - * `request.signal`. {@linkcode toNodeHandler} threads the controller it - * wires to `res.on('close')` through here, so an in-flight `handler.fetch` - * (and any SSE response it streams) observes the client disconnecting; - * hand-wired callers that forward the converted request to a handler - * themselves want the same wiring. - */ + /** An `AbortSignal` to attach to the constructed `Request` (`request.signal`). */ signal?: AbortSignal; } /** - * Convert a Node.js `IncomingMessage` (duck-typed — an Express `req` is one) - * to the web-standard `Request` the `@modelcontextprotocol/server` - * surface takes (`handler.fetch(request)`, `isLegacyRequest(request)`). This - * is the exact conversion {@linkcode toNodeHandler} performs internally, - * exported on its own so a hand-wired Node `(req, res)` handler never has to - * assemble a `globalThis.Request` from `req.headers` by hand: + * Convert a Node.js `IncomingMessage` (duck-typed — an Express `req` works) to + * the web-standard `Request` that `handler.fetch()` and `isLegacyRequest()` + * take. This is the conversion {@linkcode toNodeHandler} performs internally, + * exported for hand-wired compositions: * * ```ts - * import { toWebRequest } from '@modelcontextprotocol/node'; - * import { isLegacyRequest } from '@modelcontextprotocol/server'; - * - * // Express: `express.json()` already consumed the stream — pass `req.body`. - * app.post('/mcp', async (req, res) => { - * const probe = await toWebRequest(req, req.body); - * await ((await isLegacyRequest(probe)) ? legacy(req, res) : modern(req, res, req.body)); - * }); + * const probe = await toWebRequest(req, req.body); + * await ((await isLegacyRequest(probe)) ? legacy(req, res) : modern(req, res, req.body)); * ``` * - * The URL is derived from the `Host` header (`localhost` when absent) and - * `req.url`; HTTP/2 pseudo-headers (`:authority`, …) are dropped, multi-valued - * headers are appended in order, and the method is uppercased (defaulting to - * `GET`). - * - * The body, for a non-`GET`/`HEAD` request: with no `parsedBody`, **the Node - * stream is read to completion** here — read anything you still need from the - * returned `Request` (`request.json()`, a clone), not from `req`. When a body - * parser already consumed the stream (`express.json()`), pass the parsed value - * as `parsedBody`: nothing is read from `req`, the value is re-serialized as - * the `Request` body, and the entity headers (`content-length`, - * `content-encoding`, `transfer-encoding`) are rewritten to describe the - * re-serialized bytes rather than the original ones. + * With no `parsedBody` the Node stream is read to completion — read the body + * from the returned `Request` afterwards, not from `req`. When a body parser + * already consumed the stream (`express.json()`), pass the parsed value as + * `parsedBody` and nothing is read from `req`. */ export async function toWebRequest(req: NodeIncomingMessageLike, parsedBody?: unknown, options?: ToWebRequestOptions): Promise { const method = (req.method ?? 'GET').toUpperCase();