Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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();
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';
38 changes: 33 additions & 5 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 @@ 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 })
Expand Down Expand Up @@ -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 ?? '/'}`;
Comment thread
claude[bot] marked this conversation as resolved.

const headers = new Headers();
Expand Down Expand Up @@ -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 })
});
}
Expand Down
167 changes: 167 additions & 0 deletions packages/middleware/node/test/toWebRequest.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
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