Skip to content

feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler#2390

Open
felixweinberger wants to merge 6 commits into
mainfrom
fweinberger/to-web-request
Open

feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler#2390
felixweinberger wants to merge 6 commits into
mainfrom
fweinberger/to-web-request

Conversation

@felixweinberger

@felixweinberger felixweinberger commented Jun 29, 2026

Copy link
Copy Markdown
Contributor

Routing on isLegacyRequest from a plain Node (req, res) handler currently means hand-building a
globalThis.Request out of req.headers:

const probe = new globalThis.Request(`http://localhost${req.url}`, {
    method: req.method,
    headers: req.headers as Record<string, string>
});
await ((await isLegacyRequest(probe, req.body)) ? legacy(req, res) : modern(req, res, req.body));

That's a smell. toNodeHandler already contains the correct conversion (header arrays, HTTP/2
pseudo-headers, the parsed-body re-serialization) as a private helper — users just can't reach it. It also
nudges people toward the two-argument isLegacyRequest(probe, body), which reads as if the second argument
were required. It isn't: the predicate clones internally, so isLegacyRequest(request) is the whole API.
Our own examples taught the same two-argument pattern, each behind ~16 lines of buffer-read + JSON.parse +
hand-built Request.

This exports the conversion as toWebRequest() and moves both examples onto it. The Express one
(legacy-routing) becomes:

import { toWebRequest } from '@modelcontextprotocol/node';

// Express — the body parser already consumed the stream, so pass it along.
const probe = await toWebRequest(req, req.body);
await ((await isLegacyRequest(probe)) ? handleLegacy(req, res) : modernNode(req, res, req.body));

and the plain node:http one (elicitation):

// toWebRequest reads the stream once, so the body now lives in `request`.
// The predicate runs first: it classifies an internal clone, leaving
// `request` readable for the .json() both arms need.
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));

How:

  • toWebRequest(req, parsedBody?, options?) is the existing private conversion, exported. The function body
    is unchanged, and toNodeHandler now calls it with the abort signal in the options bag instead of a
    positional argument, so the adapter's behavior is identical.
  • It returns Promise<Request> (it reads the Node stream). Pass parsedBody when a body parser already
    consumed the stream — it's re-serialized as the Request body and the entity headers are rewritten. Pass
    options.signal to tie the constructed request to client disconnect, the way toNodeHandler does.
  • isLegacyRequest's docs now lead with the single-argument form and present parsedBody as the perf
    escape hatch it is, not a required companion.

Motivation and Context

isLegacyRequest takes a web-standard Request, but the Node hosting path only has an IncomingMessage.
The conversion between the two already existed inside toNodeHandler; exporting it removes the last reason
to build a globalThis.Request by hand — including in our own examples, which are what people copy.

How Has This Been Tested?

New unit tests in packages/middleware/node/test/toWebRequest.test.ts cover the stream-read and
parsedBody body paths (the latter asserts the Node stream is never touched), the Host-derived URL, header
copying (multi-valued append, HTTP/2 pseudo-header skipping), the signal option, and the clone-readability
contract isLegacyRequest relies on. The existing toNodeHandler suite passes unchanged, which is the
no-behavior-change check for the adapter path. The full example e2e suite passes (both rewritten stories
across all their transport × era legs), along with typecheck:all, lint, and the docs build.

Breaking Changes

None. New export; toNodeHandler behavior is unchanged; the isLegacyRequest change is documentation only;
the examples route every input express.json() parses identically.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the MCP Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

One edge worth naming: for a POST express.json() doesn't parse (a non-JSON content type — not valid MCP),
body-parser 2.x leaves req.body undefined and the Node stream unread, so toWebRequest reads it and the
predicate classifies the real bytes (the old hand-built, header-only probe never saw that body at all). Not
meaningful for spec traffic, and the old code already routed it differently between Express 4 and 5 (an
unparsed body is {} on 4 but undefined on 5, and the two classify differently).

One deliberate nuance: the example's predicate now reads the body through the same TextDecoder path the
entry itself uses, where the old hand-rolled read used Buffer#toString('utf8'). The only observable
difference is a leading UTF-8 BOM, which the old read left in (so JSON.parse failed and the request fell
to the legacy arm) and is now stripped, so the body classifies on its content.

The only other Request construction in examples/ is authServer.ts's
new Request(new URL(req.originalUrl, issuer)), which is left alone on purpose: it synthesizes an OAuth
discovery URL from issuer, not a faithful conversion of the inbound request, so toWebRequest would
change it.

@modelcontextprotocol/node README and changeset included. isLegacyRequest's rewritten docs are precise
about the one case parsedBody is needed rather than just faster: a Request whose own body was already
read (the internal clone would throw). Behind a body parser the right fix is toWebRequest(req, req.body)
— the predicate still takes one argument.

…sion inside toNodeHandler

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.
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.
…hand-built globalThis.Request

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<string, string>`) 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.
@felixweinberger felixweinberger requested a review from a team as a code owner June 29, 2026 18:28
@changeset-bot

changeset-bot Bot commented Jun 29, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 48b9c90

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/node Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new

pkg-pr-new Bot commented Jun 29, 2026

Copy link
Copy Markdown

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@2390

@modelcontextprotocol/codemod

npm i https://pkg.pr.new/@modelcontextprotocol/codemod@2390

@modelcontextprotocol/core

npm i https://pkg.pr.new/@modelcontextprotocol/core@2390

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@2390

@modelcontextprotocol/server-legacy

npm i https://pkg.pr.new/@modelcontextprotocol/server-legacy@2390

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@2390

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@2390

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@2390

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@2390

commit: 48b9c90

Comment thread examples/legacy-routing/server.ts Outdated
… unparsed body

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.
…ts docs

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.
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.
Comment on lines +205 to 214
*
* 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<Request> {
const method = (req.method ?? 'GET').toUpperCase();
const host = singleHeaderValue(req.headers['host']) ?? 'localhost';
const url = `http://${host}${req.url ?? '/'}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 toWebRequest derives the constructed URL's host only from req.headers['host'] (falling back to 'localhost') and the header loop below skips ':'-prefixed pseudo-headers, so a Node http2-compat request — which carries its authority only as ':authority', with no 'host' entry — converts to 'http://localhost/' with no Host header. Since this PR promotes the conversion to a documented public API (and the new 'skips HTTP/2 pseudo-headers' test only passes because it supplies a synthetic 'host' alongside ':authority', a combination real HTTP/2 clients don't send), consider adding a one-line fallback to ':authority' — ?? singleHeaderValue(req.headers[':authority']) — mirroring Node's own request.authority. (Behavior is pre-existing in the private helper; not blocking.)

Extended reasoning...

What the bug is. toWebRequest builds the URL of the constructed Request from singleHeaderValue(req.headers['host']) ?? 'localhost' and then copies headers in a loop that explicitly skips any ':'-prefixed name (the HTTP/2 pseudo-headers :authority, :path, :method, :scheme). That skip — and the new test 'skips HTTP/2 pseudo-headers' — show that Node http2-compat requests are an intended input (an Http2ServerRequest satisfies the duck-typed NodeIncomingMessageLike). But over HTTP/2 the request authority normally arrives only as :authority: RFC 9113 §8.3.1 directs clients converting HTTP/1.1 requests to use :authority and omit Host, and Node's http2 compat layer does not synthesize a host entry in request.headers — only the separate request.authority getter falls back across the two. One verifier confirmed this empirically against a local node:http2 server: req.headers contains {':method', ':path', ':authority', ':scheme', ...} and no host.\n\nConcrete walk-through. A client opens an HTTP/2 connection to api.example.test and sends POST /mcp. Node's compat layer hands the handler a request whose headers are {':method': 'POST', ':path': '/mcp', ':authority': 'api.example.test', ':scheme': 'https', 'content-type': 'application/json'}. toWebRequest(req) then: (1) reads req.headers['host']undefined → falls back to 'localhost', so url = 'http://localhost/mcp'; (2) the header-copy loop skips every ':'-prefixed entry, so :authority is dropped and the resulting Request has no Host header at all (the fetch Request constructor does not synthesize one from the URL); (3) the real authority api.example.test is gone from the converted request entirely.\n\nWhy nothing else catches it. The pseudo-header skip is correct on its own (Headers rejects ':'-prefixed names), but nothing re-injects the authority before the URL is built. The new unit test for this exact scenario supplies both host: 'h2.example.test' and ':authority': 'h2.example.test', so it asserts the URL host is right while exercising a header combination genuine HTTP/2 clients do not send — it documents the pseudo-header skip but masks the missing-host case.\n\nImpact. Anything downstream that reads the converted request's URL or Host sees the wrong authority: ctx.requestInfo handed to the consumer's factory, logging, absolute-URL construction, and any user-side origin/host logic run against the converted Request. One impact claim from the original finding should be tempered rather than amplified: the SDK's own hostHeaderValidationResponse reads request.headers.get('host'), which is null here, so it fails closed with a 403 'missing Host header' — i.e. host validation is not bypassed, but HTTP/2 requests converted this way also cannot pass it. So the practical effect is wrong URL/host fidelity plus web-standard host validation becoming impossible for direct node:http2 serving, not a security bypass.\n\nWhy this belongs on this PR even though the conversion body is unchanged. The same behavior existed in the private nodeRequestToFetchRequest, so toNodeHandler users were already exposed; what this PR changes is the audience. It exports the function as toWebRequest, documents it in the README/JSDoc/changeset as the way to feed isLegacyRequest() and handler.fetch() from hand-wired handlers ('an Express req works'), and adds the HTTP/2-specific test. Fixing it now — while the API is being published — is much cheaper than after users start relying on it.\n\nFix. A one-liner that mirrors Node's own request.authority getter:\n\nts\nconst host = singleHeaderValue(req.headers['host']) ?? singleHeaderValue(req.headers[':authority']) ?? 'localhost';\n\n\nOptionally also headers.set('host', host) when it came from :authority, so the converted request carries a Host header like an HTTP/1.1 request would. And the 'skips HTTP/2 pseudo-headers' test would be more honest split in two: one case with only pseudo-headers (asserting the URL host comes from :authority), one asserting the pseudo-header names never reach the Headers object.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant