Skip to content
5 changes: 5 additions & 0 deletions .changeset/is-legacy-request-doc-lead.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions .changeset/node-export-to-web-request.md
Original file line number Diff line number Diff line change
@@ -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.
28 changes: 10 additions & 18 deletions examples/elicitation/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string>
});
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();
Expand Down
14 changes: 5 additions & 9 deletions examples/legacy-routing/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,14 +72,10 @@ 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<string, string>
});
await ((await isLegacyRequest(probe, req.body)) ? handleLegacy(req, res) : modernNode(req, res, 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
// (explicit session termination per the MCP spec) are sessionful-2025-only —
Expand Down
2 changes: 2 additions & 0 deletions packages/middleware/node/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions packages/middleware/node/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export type {
NodeIncomingMessageLike,
NodeMcpRequestHandler,
NodeServerResponseLike,
ToNodeHandlerOptions
ToNodeHandlerOptions,
ToWebRequestOptions
} from './toNodeHandler';
export { toNodeHandler } from './toNodeHandler';
export { toNodeHandler, toWebRequest } from './toNodeHandler';
34 changes: 30 additions & 4 deletions packages/middleware/node/src/toNodeHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
* 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`).
*
* 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
Expand Down Expand Up @@ -111,7 +115,7 @@

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 })
Expand Down Expand Up @@ -175,17 +179,39 @@
}

/* ------------------------------------------------------------------------ *
* 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<Request> {
/** Options for {@linkcode toWebRequest}. */
export interface ToWebRequestOptions {
/** An `AbortSignal` to attach to the constructed `Request` (`request.signal`). */
signal?: AbortSignal;
}

/**
* 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
* const probe = await toWebRequest(req, req.body);
* await ((await isLegacyRequest(probe)) ? legacy(req, res) : modern(req, res, req.body));
* ```
*
* 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 ?? '/'}`;

Check warning on line 214 in packages/middleware/node/src/toNodeHandler.ts

View check run for this annotation

Claude / Claude Code Review

toWebRequest drops HTTP/2 :authority — URL host falls back to localhost

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/<path>' 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' al
Comment on lines +205 to 214

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.


const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
Expand Down Expand Up @@ -241,7 +267,7 @@
return new Request(url, {
method,
headers,
signal,
...(options?.signal !== undefined && { signal: options.signal }),
...(body !== undefined && { body })
});
}
Expand Down
158 changes: 158 additions & 0 deletions packages/middleware/node/test/toWebRequest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | string[]>;
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<string, string | string[]>;
}): NodeIncomingMessageLike {
return {
method: init.method,
url: init.url,
headers: init.headers ?? {},
[Symbol.asyncIterator](): AsyncIterator<unknown> {
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);
});
});
22 changes: 15 additions & 7 deletions packages/server/src/server/createMcpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<boolean> {
// Classify a clone so the caller's request body stays readable; with a
Expand Down
Loading