feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler (#2390)
This commit is contained in:
parent
199a28e9e2
commit
6cc7b1cb66
9 changed files with 245 additions and 41 deletions
5
.changeset/is-legacy-request-doc-lead.md
Normal file
5
.changeset/is-legacy-request-doc-lead.md
Normal 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.
|
||||
5
.changeset/node-export-to-web-request.md
Normal file
5
.changeset/node-export-to-web-request.md
Normal 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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 —
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ export type {
|
|||
NodeIncomingMessageLike,
|
||||
NodeMcpRequestHandler,
|
||||
NodeServerResponseLike,
|
||||
ToNodeHandlerOptions
|
||||
ToNodeHandlerOptions,
|
||||
ToWebRequestOptions
|
||||
} from './toNodeHandler';
|
||||
export { toNodeHandler } from './toNodeHandler';
|
||||
export { toNodeHandler, toWebRequest } from './toNodeHandler';
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
});
|
||||
}
|
||||
|
|
|
|||
167
packages/middleware/node/test/toWebRequest.test.ts
Normal file
167
packages/middleware/node/test/toWebRequest.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue