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/.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/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..33547b2384 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,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 - }); - 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 — 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..4d283a95c1 100644 --- a/packages/middleware/node/src/toNodeHandler.ts +++ b/packages/middleware/node/src/toNodeHandler.ts @@ -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,14 +179,36 @@ 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` (`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 { const method = (req.method ?? 'GET').toUpperCase(); const host = singleHeaderValue(req.headers['host']) ?? 'localhost'; const url = `http://${host}${req.url ?? '/'}`; @@ -241,7 +267,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); + }); +}); 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