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

This commit is contained in:
Felix Weinberger 2026-06-30 11:47:44 +01:00 • committed by GitHub
commit 6cc7b1cb66
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 245 additions and 41 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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,
@ -276,23 +276,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();

View file

@ -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,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 —

View file

@ -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

View file

@ -6,6 +6,7 @@ export type {
NodeIncomingMessageLike,
NodeMcpRequestHandler,
NodeServerResponseLike,
ToNodeHandlerOptions
ToNodeHandlerOptions,
ToWebRequestOptions
} from './toNodeHandler';
export { toNodeHandler } from './toNodeHandler';
export { toNodeHandler, toWebRequest } from './toNodeHandler';

View file

@ -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
@ -111,7 +115,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,16 +179,40 @@ 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<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';
// HTTP/2 requests carry their authority as the `:authority` pseudo-header,
// usually with no `host` entry at all (mirrors Node's `request.authority`).
const host = singleHeaderValue(req.headers['host']) ?? singleHeaderValue(req.headers[':authority']) ?? 'localhost';
const url = `http://${host}${req.url ?? '/'}`;
const headers = new Headers();
@ -241,7 +269,7 @@ async function nodeRequestToFetchRequest(req: NodeIncomingMessageLike, parsedBod
return new Request(url, {
method,
headers,
signal,
...(options?.signal !== undefined && { signal: options.signal }),
...(body !== undefined && { body })
});
}

View file

@ -0,0 +1,167 @@
/**
* `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('derives the URL host from :authority for an HTTP/2 request (no host header) and drops pseudo-headers', async () => {
// A real HTTP/2 client sends only the pseudo-header — no `host` entry.
const request = await toWebRequest(
nodeRequest({
method: 'GET',
url: '/mcp',
headers: { ':authority': 'h2.example.test:8443', ':path': '/mcp', ':scheme': 'http', 'mcp-protocol-version': '2026-07-28' }
})
);
expect(new URL(request.url).host).toBe('h2.example.test:8443');
// Pseudo-header names are skipped — `Headers` rejects them.
expect(request.headers.get('mcp-protocol-version')).toBe('2026-07-28');
});
it('prefers the host header over :authority when both are present', async () => {
const request = await toWebRequest(
nodeRequest({ method: 'GET', url: '/mcp', headers: { host: 'h1.example.test', ':authority': 'h2.example.test' } })
);
expect(new URL(request.url).host).toBe('h1.example.test');
});
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);
});
});

View file

@ -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<boolean> {
// Classify a clone so the caller's request body stays readable; with a