feat(node): export toWebRequest(), the IncomingMessage→Request conversion inside toNodeHandler (#2390)
This commit is contained in:
parent
199a28e9e2
commit
6cc7b1cb66
4 changed files with 245 additions and 41 deletions
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue