Implement MCP 2026-07-28 (#2286)

Co-authored-by: Matt <77928207+mattzcarey@users.noreply.github.com>
Co-authored-by: Konstantin Konstantinov <KKonstantinov@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
This commit is contained in:
Felix Weinberger 2026-06-26 14:01:10 +01:00 • committed by GitHub
commit 1823aae891
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 69131 additions and 9001 deletions

View file

@ -2,6 +2,7 @@ import type { Express } from 'express';
import express from 'express';
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation';
import { localhostOriginValidation, originValidation } from './middleware/originValidation';
/**
* Options for creating an MCP Express application.
@ -23,6 +24,18 @@ export interface CreateMcpExpressAppOptions {
*/
allowedHosts?: string[];
/**
* List of allowed origin hostnames for Origin header validation.
* If provided, Origin validation will be applied using this list (port-agnostic,
* hostnames only — the same convention as `allowedHosts`).
*
* When omitted, Origin validation is automatically enabled for localhost-class
* binds (the same condition as host validation): requests without an `Origin`
* header pass, while a present `Origin` whose hostname is not localhost-class
* is rejected with `403`.
*/
allowedOrigins?: string[];
/**
* Controls the maximum request body size for the JSON body parser.
* Passed directly to Express's `express.json({ limit })` option.
@ -60,7 +73,7 @@ export interface CreateMcpExpressAppOptions {
* ```
*/
export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express {
const { host = '127.0.0.1', allowedHosts, jsonLimit } = options;
const { host = '127.0.0.1', allowedHosts, allowedOrigins, jsonLimit } = options;
const app = express();
app.use(express.json(jsonLimit ? { limit: jsonLimit } : undefined));
@ -84,5 +97,14 @@ export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): E
}
}
// Origin validation follows the same arming ladder as host validation:
// an explicit allowlist wins; otherwise localhost-class binds are protected
// by default. Requests without an Origin header always pass.
if (allowedOrigins) {
app.use(originValidation(allowedOrigins));
} else if (['127.0.0.1', 'localhost', '::1'].includes(host)) {
app.use(localhostOriginValidation());
}
return app;
}

View file

@ -1,5 +1,6 @@
export * from './express';
export * from './middleware/hostHeaderValidation';
export * from './middleware/originValidation';
// OAuth Resource-Server glue: bearer-token middleware + PRM/AS metadata router.
export type { BearerAuthMiddlewareOptions } from './auth/bearerAuth';

View file

@ -0,0 +1,52 @@
import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
/**
* Express middleware for Origin header validation.
* Validates the `Origin` header hostname (port-agnostic) against an allowed list.
*
* Browsers attach an `Origin` header to cross-origin requests; validating it —
* alongside Host header validation — protects localhost and development servers
* against DNS rebinding and cross-site request forgery. Requests without an
* `Origin` header pass (non-browser MCP clients do not send one); a present
* value that is not allowed, or that cannot be parsed, is rejected with `403`.
*
* @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port).
* For IPv6, provide the address with brackets (e.g., `[::1]`).
* @returns Express middleware function
*
* @example
* ```ts
* app.use(originValidation(['localhost', '127.0.0.1', '[::1]']));
* ```
*/
export function originValidation(allowedOriginHostnames: string[]): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const result = validateOriginHeader(req.headers.origin, allowedOriginHostnames);
if (!result.ok) {
res.status(403).json({
jsonrpc: '2.0',
error: {
code: -32_000,
message: result.message
},
id: null
});
return;
}
next();
};
}
/**
* Convenience middleware for localhost Origin validation.
* Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost).
*
* @example
* ```ts
* app.use(localhostOriginValidation());
* ```
*/
export function localhostOriginValidation(): RequestHandler {
return originValidation(localhostAllowedOrigins());
}

View file

@ -0,0 +1,171 @@
import type { NextFunction, Request, Response } from 'express';
import supertest from 'supertest';
import { vi } from 'vitest';
import { createMcpExpressApp } from '../src/express';
import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation';
// Helper to create mock Express request/response/next
function createMockReqResNext(origin?: string) {
const req = {
headers: {
origin
}
} as Request;
const res = {
status: vi.fn().mockReturnThis(),
json: vi.fn().mockReturnThis()
} as unknown as Response;
const next = vi.fn() as NextFunction;
return { req, res, next };
}
describe('@modelcontextprotocol/express origin validation', () => {
describe('originValidation', () => {
test('should block a disallowed Origin header', () => {
const middleware = originValidation(['localhost']);
const { req, res, next } = createMockReqResNext('http://evil.example.com');
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({
code: -32_000
}),
id: null
})
);
expect(next).not.toHaveBeenCalled();
});
test('should allow an allowed Origin header (port-agnostic)', () => {
const middleware = originValidation(['localhost']);
const { req, res, next } = createMockReqResNext('http://localhost:3000');
middleware(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
test('should allow requests without an Origin header (non-browser clients)', () => {
const middleware = originValidation(['localhost']);
const { req, res, next } = createMockReqResNext(undefined);
middleware(req, res, next);
expect(res.status).not.toHaveBeenCalled();
expect(next).toHaveBeenCalled();
});
test('should deny on failure: malformed and null origins are rejected, never passed through', () => {
const middleware = originValidation(['localhost']);
for (const malformed of ['null', 'not a url']) {
const { req, res, next } = createMockReqResNext(malformed);
middleware(req, res, next);
expect(res.status).toHaveBeenCalledWith(403);
expect(next).not.toHaveBeenCalled();
}
});
test('localhostOriginValidation allows the localhost family only', () => {
const middleware = localhostOriginValidation();
const allowed = createMockReqResNext('http://127.0.0.1:8080');
middleware(allowed.req, allowed.res, allowed.next);
expect(allowed.next).toHaveBeenCalled();
const blocked = createMockReqResNext('http://localhost.evil.example.com');
middleware(blocked.req, blocked.res, blocked.next);
expect(blocked.res.status).toHaveBeenCalledWith(403);
expect(blocked.next).not.toHaveBeenCalled();
});
});
describe('createMcpExpressApp origin arming', () => {
test('builds an app with default localhost origin protection', () => {
const app = createMcpExpressApp();
expect(app).toBeDefined();
expect(typeof app.use).toBe('function');
});
test('arms localhost origin validation by default (requests are actually filtered)', async () => {
const app = createMcpExpressApp();
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
const blocked = await supertest(app).get('/health').set('Origin', 'http://evil.example.com');
expect(blocked.status).toBe(403);
expect(blocked.body).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({ code: -32_000 }),
id: null
})
);
const allowed = await supertest(app).get('/health').set('Origin', 'http://localhost:5173');
expect(allowed.status).toBe(200);
const noOrigin = await supertest(app).get('/health');
expect(noOrigin.status).toBe(200);
});
test('an explicit allowedOrigins list replaces the default allowlist (validation stays armed)', async () => {
const app = createMcpExpressApp({
host: '0.0.0.0',
allowedHosts: ['127.0.0.1', 'myapp.local'],
allowedOrigins: ['myapp.local']
});
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
const good = await supertest(app).get('/health').set('Origin', 'https://myapp.local');
expect(good.status).toBe(200);
const bad = await supertest(app).get('/health').set('Origin', 'http://localhost:5173');
expect(bad.status).toBe(403);
});
test('applies no origin validation for 0.0.0.0 without allowedOrigins', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpExpressApp({ host: '0.0.0.0' });
app.get('/health', (_req, res) => {
res.json({ ok: true });
});
const res = await supertest(app).get('/health').set('Origin', 'http://evil.example.com');
expect(res.status).toBe(200);
warn.mockRestore();
});
test('accepts an allowedOrigins override without warnings', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] });
expect(app).toBeDefined();
expect(warn).not.toHaveBeenCalled();
warn.mockRestore();
});
test('keeps the existing 0.0.0.0 warning untouched when no allowlists are provided', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
createMcpExpressApp({ host: '0.0.0.0' });
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(
expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection')
);
warn.mockRestore();
});
});
});

View file

@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify';
import Fastify from 'fastify';
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation';
import { localhostOriginValidation, originValidation } from './middleware/originValidation';
/**
* Options for creating an MCP Fastify application.
@ -22,6 +23,18 @@ export interface CreateMcpFastifyAppOptions {
* to restrict which hostnames are allowed.
*/
allowedHosts?: string[];
/**
* List of allowed origin hostnames for Origin header validation.
* If provided, Origin validation will be applied using this list (port-agnostic,
* hostnames only — the same convention as `allowedHosts`).
*
* When omitted, Origin validation is automatically enabled for localhost-class
* binds (the same condition as host validation): requests without an `Origin`
* header pass, while a present `Origin` whose hostname is not localhost-class
* is rejected with `403`.
*/
allowedOrigins?: string[];
}
/**
@ -54,7 +67,7 @@ export interface CreateMcpFastifyAppOptions {
* ```
*/
export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): FastifyInstance {
const { host = '127.0.0.1', allowedHosts } = options;
const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options;
const app = Fastify();
@ -78,5 +91,14 @@ export function createMcpFastifyApp(options: CreateMcpFastifyAppOptions = {}): F
}
}
// Origin validation follows the same arming ladder as host validation:
// an explicit allowlist wins; otherwise localhost-class binds are protected
// by default. Requests without an Origin header always pass.
if (allowedOrigins) {
app.addHook('onRequest', originValidation(allowedOrigins));
} else if (['127.0.0.1', 'localhost', '::1'].includes(host)) {
app.addHook('onRequest', localhostOriginValidation());
}
return app;
}

View file

@ -1,2 +1,3 @@
export * from './fastify';
export * from './middleware/hostHeaderValidation';
export * from './middleware/originValidation';

View file

@ -0,0 +1,50 @@
import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server';
import type { FastifyReply, FastifyRequest } from 'fastify';
/**
* Fastify onRequest hook for Origin header validation.
* Validates the `Origin` header hostname (port-agnostic) against an allowed list.
*
* Browsers attach an `Origin` header to cross-origin requests; validating it —
* alongside Host header validation — protects localhost and development servers
* against DNS rebinding and cross-site request forgery. Requests without an
* `Origin` header pass (non-browser MCP clients do not send one); a present
* value that is not allowed, or that cannot be parsed, is rejected with `403`.
*
* @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port).
* For IPv6, provide the address with brackets (e.g., `[::1]`).
* @returns Fastify onRequest hook handler
*
* @example
* ```ts
* app.addHook('onRequest', originValidation(['localhost', '127.0.0.1', '[::1]']));
* ```
*/
export function originValidation(allowedOriginHostnames: string[]) {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
const result = validateOriginHeader(request.headers.origin, allowedOriginHostnames);
if (!result.ok) {
await reply.code(403).send({
jsonrpc: '2.0',
error: {
code: -32_000,
message: result.message
},
id: null
});
}
};
}
/**
* Convenience hook for localhost Origin validation.
* Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost).
*
* @example
* ```ts
* app.addHook('onRequest', localhostOriginValidation());
* ```
*/
export function localhostOriginValidation() {
return originValidation(localhostAllowedOrigins());
}

View file

@ -0,0 +1,116 @@
import Fastify from 'fastify';
import { createMcpFastifyApp } from '../src/fastify';
import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation';
describe('@modelcontextprotocol/fastify origin validation', () => {
describe('originValidation', () => {
test('should block a disallowed Origin header', async () => {
const app = Fastify();
app.addHook('onRequest', originValidation(['localhost']));
app.get('/health', async () => ({ ok: true }));
const res = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'localhost:3000', origin: 'http://evil.example.com' }
});
expect(res.statusCode).toBe(403);
expect(res.json()).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({
code: -32_000
}),
id: null
})
);
});
test('should allow an allowed Origin header and requests without an Origin header', async () => {
const app = Fastify();
app.addHook('onRequest', localhostOriginValidation());
app.get('/health', async () => 'ok');
const allowed = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'localhost:3000', origin: 'http://localhost:5173' }
});
expect(allowed.statusCode).toBe(200);
const noOrigin = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'localhost:3000' }
});
expect(noOrigin.statusCode).toBe(200);
});
test('should deny malformed Origin values (deny on failure)', async () => {
const app = Fastify();
app.addHook('onRequest', localhostOriginValidation());
app.get('/health', async () => 'ok');
const res = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'localhost:3000', origin: 'null' }
});
expect(res.statusCode).toBe(403);
});
});
describe('createMcpFastifyApp origin arming', () => {
test('arms localhost origin validation by default', async () => {
const app = createMcpFastifyApp();
app.get('/health', async () => 'ok');
const bad = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'localhost:3000', origin: 'http://evil.example.com' }
});
expect(bad.statusCode).toBe(403);
const good = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'localhost:3000', origin: 'http://localhost:5173' }
});
expect(good.statusCode).toBe(200);
});
test('uses allowedOrigins when provided', async () => {
const app = createMcpFastifyApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] });
app.get('/health', async () => 'ok');
const good = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'myapp.local:3000', origin: 'https://myapp.local' }
});
expect(good.statusCode).toBe(200);
const bad = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'myapp.local:3000', origin: 'http://evil.example.com' }
});
expect(bad.statusCode).toBe(403);
});
test('applies no origin validation for 0.0.0.0 without allowedOrigins', async () => {
const app = createMcpFastifyApp({ host: '0.0.0.0' });
app.get('/health', async () => 'ok');
const res = await app.inject({
method: 'GET',
url: '/health',
headers: { host: 'whatever.example.com', origin: 'http://evil.example.com' }
});
expect(res.statusCode).toBe(200);
});
});
});

View file

@ -2,6 +2,7 @@ import type { Context } from 'hono';
import { Hono } from 'hono';
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation';
import { localhostOriginValidation, originValidation } from './middleware/originValidation';
/**
* Options for creating an MCP Hono application.
@ -22,6 +23,18 @@ export interface CreateMcpHonoAppOptions {
* to restrict which hostnames are allowed.
*/
allowedHosts?: string[];
/**
* List of allowed origin hostnames for Origin header validation.
* If provided, Origin validation will be applied using this list (port-agnostic,
* hostnames only — the same convention as `allowedHosts`).
*
* When omitted, Origin validation is automatically enabled for localhost-class
* binds (the same condition as host validation): requests without an `Origin`
* header pass, while a present `Origin` whose hostname is not localhost-class
* is rejected with `403`.
*/
allowedOrigins?: string[];
}
/**
@ -39,7 +52,7 @@ export interface CreateMcpHonoAppOptions {
* @returns A configured Hono application
*/
export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
const { host = '127.0.0.1', allowedHosts } = options;
const { host = '127.0.0.1', allowedHosts, allowedOrigins } = options;
const app = new Hono();
@ -86,5 +99,14 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
}
}
// Origin validation follows the same arming ladder as host validation:
// an explicit allowlist wins; otherwise localhost-class binds are protected
// by default. Requests without an Origin header always pass.
if (allowedOrigins) {
app.use('*', originValidation(allowedOrigins));
} else if (['127.0.0.1', 'localhost', '::1'].includes(host)) {
app.use('*', localhostOriginValidation());
}
return app;
}

View file

@ -1,2 +1,3 @@
export * from './hono';
export * from './middleware/hostHeaderValidation';
export * from './middleware/originValidation';

View file

@ -0,0 +1,38 @@
import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server';
import type { MiddlewareHandler } from 'hono';
/**
* Hono middleware for Origin header validation.
* Validates the `Origin` header hostname (port-agnostic) against an allowed list.
*
* Requests without an `Origin` header pass (non-browser MCP clients do not send
* one); a present value that is not allowed, or that cannot be parsed, is
* rejected with `403`.
*/
export function originValidation(allowedOriginHostnames: string[]): MiddlewareHandler {
return async (c, next) => {
const result = validateOriginHeader(c.req.header('origin'), allowedOriginHostnames);
if (!result.ok) {
return c.json(
{
jsonrpc: '2.0',
error: {
code: -32_000,
message: result.message
},
id: null
},
403
);
}
return await next();
};
}
/**
* Convenience middleware for localhost Origin validation.
* Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost).
*/
export function localhostOriginValidation(): MiddlewareHandler {
return originValidation(localhostAllowedOrigins());
}

View file

@ -0,0 +1,91 @@
import { Hono } from 'hono';
import { vi } from 'vitest';
import { createMcpHonoApp } from '../src/hono';
import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation';
describe('@modelcontextprotocol/hono origin validation', () => {
test('originValidation blocks a disallowed Origin and allows an allowed Origin', async () => {
const app = new Hono();
app.use('*', originValidation(['localhost']));
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', {
headers: { Host: 'localhost:3000', Origin: 'http://evil.example.com' }
});
expect(bad.status).toBe(403);
expect(await bad.json()).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({
code: -32_000
}),
id: null
})
);
const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000', Origin: 'http://localhost:3000' } });
expect(good.status).toBe(200);
expect(await good.text()).toBe('ok');
});
test('originValidation allows requests without an Origin header and denies malformed origins', async () => {
const app = new Hono();
app.use('*', localhostOriginValidation());
app.get('/health', c => c.text('ok'));
const noOrigin = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } });
expect(noOrigin.status).toBe(200);
const malformed = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000', Origin: 'null' } });
expect(malformed.status).toBe(403);
});
test('createMcpHonoApp arms localhost origin validation by default', async () => {
const app = createMcpHonoApp();
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', {
headers: { Host: 'localhost:3000', Origin: 'http://evil.example.com' }
});
expect(bad.status).toBe(403);
const goodOrigin = await app.request('http://localhost/health', {
headers: { Host: 'localhost:3000', Origin: 'http://localhost:5173' }
});
expect(goodOrigin.status).toBe(200);
const noOrigin = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } });
expect(noOrigin.status).toBe(200);
});
test('createMcpHonoApp uses allowedOrigins when provided', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'], allowedOrigins: ['myapp.local'] });
warn.mockRestore();
app.get('/health', c => c.text('ok'));
const good = await app.request('http://localhost/health', {
headers: { Host: 'myapp.local:3000', Origin: 'https://myapp.local' }
});
expect(good.status).toBe(200);
const bad = await app.request('http://localhost/health', {
headers: { Host: 'myapp.local:3000', Origin: 'http://evil.example.com' }
});
expect(bad.status).toBe(403);
});
test('createMcpHonoApp applies no origin validation for 0.0.0.0 without allowedOrigins (existing warning preserved)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0' });
expect(warn).toHaveBeenCalledTimes(1);
warn.mockRestore();
app.get('/health', c => c.text('ok'));
const anyOrigin = await app.request('http://localhost/health', {
headers: { Host: 'whatever.example.com', Origin: 'http://evil.example.com' }
});
expect(anyOrigin.status).toBe(200);
});
});

View file

@ -16,6 +16,9 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/node
- `NodeStreamableHTTPServerTransport`
- `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`)
- `NodeIncomingMessageLike`, `NodeServerResponseLike` (structural Node request/response shapes)
## Usage

View file

@ -1 +1,11 @@
export * from './middleware/hostHeaderValidation';
export * from './middleware/originValidation';
export * from './streamableHttp';
export type {
FetchLikeMcpHandler,
NodeIncomingMessageLike,
NodeMcpRequestHandler,
NodeServerResponseLike,
ToNodeHandlerOptions
} from './toNodeHandler';
export { toNodeHandler } from './toNodeHandler';

View file

@ -0,0 +1,53 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server';
/**
* Node.js request guard for DNS rebinding protection.
* Validates the `Host` header hostname (port-agnostic) against an allowed list.
*
* Unlike the framework adapters, plain `node:http` has no middleware chain, so
* the guard returns whether the request may proceed: when it returns `false`
* it has already answered the request with a `403` JSON-RPC error and the
* caller must not handle it further.
*
* @param allowedHostnames - List of allowed hostnames (without ports).
* For IPv6, provide the address with brackets (e.g., `[::1]`).
*
* @example
* ```ts
* const validateHost = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']);
* http.createServer((req, res) => {
* if (!validateHost(req, res)) return;
* void transport.handleRequest(req, res);
* });
* ```
*/
export function hostHeaderValidation(allowedHostnames: string[]): (req: IncomingMessage, res: ServerResponse) => boolean {
return (req, res) => {
const result = validateHostHeader(req.headers.host, allowedHostnames);
if (result.ok) {
return true;
}
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32_000,
message: result.message
},
id: null
})
);
return false;
};
}
/**
* Convenience guard for localhost DNS rebinding protection.
* Allows only `localhost`, `127.0.0.1`, and `[::1]` (IPv6 localhost) hostnames.
*/
export function localhostHostValidation(): (req: IncomingMessage, res: ServerResponse) => boolean {
return hostHeaderValidation(localhostAllowedHostnames());
}

View file

@ -0,0 +1,54 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { localhostAllowedOrigins, validateOriginHeader } from '@modelcontextprotocol/server';
/**
* Node.js request guard for Origin header validation.
* Validates the `Origin` header hostname (port-agnostic) against an allowed list.
*
* Requests without an `Origin` header pass (non-browser MCP clients do not send
* one); a present value that is not allowed, or that cannot be parsed, is
* rejected with `403`. The guard returns whether the request may proceed: when
* it returns `false` it has already answered the request and the caller must
* not handle it further.
*
* @param allowedOriginHostnames - List of allowed origin hostnames (without scheme or port).
* For IPv6, provide the address with brackets (e.g., `[::1]`).
*
* @example
* ```ts
* const validateOrigin = originValidation(['localhost', '127.0.0.1', '[::1]']);
* http.createServer((req, res) => {
* if (!validateOrigin(req, res)) return;
* void transport.handleRequest(req, res);
* });
* ```
*/
export function originValidation(allowedOriginHostnames: string[]): (req: IncomingMessage, res: ServerResponse) => boolean {
return (req, res) => {
const result = validateOriginHeader(req.headers.origin, allowedOriginHostnames);
if (result.ok) {
return true;
}
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
jsonrpc: '2.0',
error: {
code: -32_000,
message: result.message
},
id: null
})
);
return false;
};
}
/**
* Convenience guard for localhost Origin validation.
* Allows only origins whose hostname is `localhost`, `127.0.0.1`, or `[::1]` (IPv6 localhost).
*/
export function localhostOriginValidation(): (req: IncomingMessage, res: ServerResponse) => boolean {
return originValidation(localhostAllowedOrigins());
}

View file

@ -158,6 +158,17 @@ export class NodeStreamableHTTPServerTransport implements Transport {
return this._webStandardTransport.send(message, options);
}
/**
* Forwards the supported protocol versions to the wrapped Web Standard
* transport for `MCP-Protocol-Version` header validation. Called by the
* protocol layer during connect; without this delegation a server's
* `supportedProtocolVersions` option never reached the Node adapter's
* header validation.
*/
setSupportedProtocolVersions(versions: string[]): void {
this._webStandardTransport.setSupportedProtocolVersions(versions);
}
/**
* Handles an incoming HTTP request, whether `GET` or `POST`.
*

View file

@ -0,0 +1,271 @@
/**
* `toNodeHandler` — adapt the web-standard {@linkcode McpHttpHandler} returned
* by `createMcpHandler` to a Node.js `(req, res, parsedBody?)` request handler.
*
* The handler itself is web-standards-only (`{ fetch, close, notify, bus }` — the
* shape Workers/Bun/Deno expect from `export default`). Node frameworks
* (Express, Fastify, plain `node:http`) wrap it once with this helper:
*
* ```ts
* import { createMcpHandler } from '@modelcontextprotocol/server';
* import { toNodeHandler } from '@modelcontextprotocol/node';
*
* const handler = createMcpHandler(factory);
* app.all('/mcp', toNodeHandler(handler));
* // or, when a body parser already consumed the stream:
* const node = toNodeHandler(handler);
* app.all('/mcp', (req, res) => void node(req, res, req.body));
* ```
*
* 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
* it as the handler's pass-through `authInfo`.
*/
import type { AuthInfo, McpHandlerRequestOptions } from '@modelcontextprotocol/server';
/**
* Minimal duck-typed shape of a Node.js `IncomingMessage` accepted by
* {@linkcode toNodeHandler}. Kept structural so the adapter stays free of
* `node:` imports.
*/
export interface NodeIncomingMessageLike extends AsyncIterable<unknown> {
method?: string;
url?: string;
headers: Record<string, string | string[] | undefined>;
/** Validated authentication info attached by upstream middleware (pass-through). */
auth?: AuthInfo;
}
/** Minimal duck-typed shape of a Node.js `ServerResponse` accepted by {@linkcode toNodeHandler}. */
export interface NodeServerResponseLike {
writeHead(statusCode: number, headers?: Record<string, string>): unknown;
write(chunk: string | Uint8Array): unknown;
end(chunk?: string | Uint8Array): unknown;
on(event: string, listener: (...args: unknown[]) => void): unknown;
destroyed?: boolean;
}
/**
* The web-standard fetch face of an `McpHttpHandler` (or any
* fetch-shaped MCP handler) — the only surface {@linkcode toNodeHandler}
* touches. Accepting the face structurally keeps the adapter usable with
* hand-wired compositions that route over `isLegacyRequest` and produce a
* `Response` directly.
*/
export interface FetchLikeMcpHandler {
fetch: (request: Request, options?: McpHandlerRequestOptions) => Promise<Response>;
}
/**
* A Node.js `(req, res, parsedBody?)` request handler produced by
* {@linkcode toNodeHandler}. The third argument is an optional pre-parsed body
* (`req.body` from `express.json()`); a function third argument (Express's
* `next` when the handler is mounted as middleware) is ignored.
*/
export type NodeMcpRequestHandler = (req: NodeIncomingMessageLike, res: NodeServerResponseLike, parsedBody?: unknown) => Promise<void>;
/** Options for {@linkcode toNodeHandler}. */
export interface ToNodeHandlerOptions {
/**
* Called when the adapter answers `500` because request conversion or
* `handler.fetch` itself threw (e.g. a closed handler). Restores the
* observability the removed `.node` face had via the entry's own
* `onerror` — entry-internal failures are still reported through
* `handler.fetch` and surface via the entry's `onerror` option as before.
*/
onerror?: (error: Error) => void;
}
/**
* Adapts a web-standard MCP handler (`handler.fetch`) to a Node.js
* `(req, res, parsedBody?)` request handler. The returned function converts the
* Node request to a web-standard `Request`, calls `handler.fetch`, then writes
* the `Response` back to `res` (honoring write backpressure for streamed SSE
* responses).
*
* `req.auth` is forwarded as the handler's pass-through `authInfo`. A function
* third argument (Express's `next`) is ignored, never treated as a body.
*
* Pass `{ onerror }` to observe the adapter-level error fallback (request
* conversion / `handler.fetch` throw) before the `500` response is written.
*/
export function toNodeHandler(handler: FetchLikeMcpHandler, opts?: ToNodeHandlerOptions): NodeMcpRequestHandler {
return async (req, res, parsedBody) => {
// Express passes (req, res, next) when the handler is mounted as a
// middleware function; a function third argument is `next`, not a body.
if (typeof parsedBody === 'function') {
parsedBody = undefined;
}
let finished = false;
const abort = new AbortController();
res.on('close', () => {
if (!finished) {
abort.abort();
}
});
if (res.destroyed === true) {
abort.abort();
}
let response: Response;
try {
const request = await nodeRequestToFetchRequest(req, parsedBody, abort.signal);
response = await handler.fetch(request, {
...(req.auth !== undefined && { authInfo: req.auth }),
...(parsedBody !== undefined && { parsedBody })
});
} catch (error) {
try {
opts?.onerror?.(error instanceof Error ? error : new Error(String(error)));
} catch {
// Reporting must never alter the response.
}
response = internalServerErrorResponse(echoableRequestId(parsedBody));
}
const headers: Record<string, string> = {};
for (const [name, value] of response.headers) {
headers[name] = value;
}
res.writeHead(response.status, headers);
if (response.body === null) {
finished = true;
res.end();
return;
}
// Honor write backpressure: when write() reports a full buffer (Node's
// `false` return), wait for the response to drain before pulling the
// next chunk. The abort signal (wired to 'close' above, and seeded
// from `res.destroyed` at entry to cover the pre-registration window
// when 'close' already fired during async middleware) is the single
// termination source — racing it against the drain wait means a
// vanished client cannot park the loop, and breaking out of the async
// iterator calls return() to cancel the upstream stream.
let drainResolve: (() => void) | undefined;
const releaseDrainWait = () => {
drainResolve?.();
drainResolve = undefined;
};
res.on('drain', releaseDrainWait);
const closed = new Promise<void>(resolve => {
abort.signal.addEventListener('abort', () => resolve(), { once: true });
});
try {
for await (const chunk of response.body) {
if (abort.signal.aborted) {
break;
}
if (res.write(chunk) === false) {
await Promise.race([
new Promise<void>(resolve => {
drainResolve = resolve;
}),
closed
]);
}
}
} catch {
// Stream aborted upstream; the abort signal already cancelled the exchange.
}
finished = true;
res.end();
};
}
/* ------------------------------------------------------------------------ *
* Node request conversion (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> {
const method = (req.method ?? 'GET').toUpperCase();
const host = singleHeaderValue(req.headers['host']) ?? 'localhost';
const url = `http://${host}${req.url ?? '/'}`;
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
// HTTP/2 pseudo-headers (`:method`, `:path`, `:authority`, …) are
// connection metadata, not header fields — `Headers` rejects their
// names, so they are skipped rather than copied.
if (value === undefined || name.startsWith(':')) {
continue;
}
if (Array.isArray(value)) {
for (const item of value) {
headers.append(name, item);
}
} else {
headers.set(name, value);
}
}
// The body is carried as text: MCP request bodies are JSON, and a string
// body keeps the constructed Request portable across runtime lib versions.
let body: string | undefined;
if (method !== 'GET' && method !== 'HEAD') {
if (parsedBody === undefined) {
const decoder = new TextDecoder();
let collected = '';
for await (const chunk of req) {
collected += typeof chunk === 'string' ? chunk : decoder.decode(chunk as Uint8Array, { stream: true });
}
collected += decoder.decode();
if (collected.length > 0) {
body = collected;
}
} else {
// The caller already consumed and parsed the Node stream (the
// documented `(req, res, req.body)` mounting behind
// `express.json()`), so the bytes cannot be re-read. Re-serialize
// the parsed value so consumers of the forwarded Request — anything
// on the legacy leg reading `request.json()`/`text()` instead of
// the pass-through parsedBody — still receive the body, and replace
// the entity headers that described the original raw bytes.
const serialized: string | undefined = JSON.stringify(parsedBody);
headers.delete('content-encoding');
headers.delete('transfer-encoding');
if (serialized === undefined) {
headers.delete('content-length');
} else {
body = serialized;
headers.set('content-length', String(new TextEncoder().encode(serialized).byteLength));
}
}
}
return new Request(url, {
method,
headers,
signal,
...(body !== undefined && { body })
});
}
/* ------------------------------------------------------------------------ *
* Adapter-level error fallback (request conversion failure / closed handler)
* ------------------------------------------------------------------------ */
/**
* The JSON-RPC id to echo on an adapter-built error response: the body's `id`
* when the body is a single JSON-RPC request whose id is a string or number,
* `null` otherwise.
*/
function echoableRequestId(body: unknown): string | number | null {
if (body === null || typeof body !== 'object' || Array.isArray(body)) {
return null;
}
const { method, id } = body as { method?: unknown; id?: unknown };
if (typeof method !== 'string') {
return null;
}
return typeof id === 'string' || typeof id === 'number' ? id : null;
}
function internalServerErrorResponse(id: string | number | null): Response {
return Response.json({ jsonrpc: '2.0', error: { code: -32_603, message: 'Internal server error' }, id }, { status: 500 });
}

View file

@ -0,0 +1,416 @@
/**
* `toNodeHandler(handler)` — the Node `(req, res, parsedBody?)` adapter over a
* web-standard `McpHttpHandler`. Covers the request-stream conversion, the
* pre-parsed-body path (the documented `express.json()` mounting), `req.auth`
* pass-through, HTTP/2 pseudo-header skipping, and write-backpressure pacing.
*
* These tests previously lived in `@modelcontextprotocol/server`'s
* `createMcpHandler.test.ts` as the `.node` face tests; the body of the
* adapter is unchanged, only its home moved.
*/
import { Readable } from 'node:stream';
import { CLIENT_CAPABILITIES_META_KEY, CLIENT_INFO_META_KEY, PROTOCOL_VERSION_META_KEY } from '@modelcontextprotocol/core-internal';
import type { McpRequestContext } from '@modelcontextprotocol/server';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
import { describe, expect, it, vi } from 'vitest';
import * as z from 'zod/v4';
import type { NodeServerResponseLike } from '../src/toNodeHandler';
import { toNodeHandler } from '../src/toNodeHandler';
const MODERN_REVISION = '2026-07-28';
const ENVELOPE = {
[PROTOCOL_VERSION_META_KEY]: MODERN_REVISION,
[CLIENT_INFO_META_KEY]: { name: 'node-adapter-test-client', version: '3.2.1' },
[CLIENT_CAPABILITIES_META_KEY]: { elicitation: { form: {} } }
};
function modernToolsCall(name: string, args: Record<string, unknown>): unknown {
return {
jsonrpc: '2.0',
id: 1,
method: 'tools/call',
params: { name, arguments: args, _meta: ENVELOPE }
};
}
/** SEP-2243 standard headers a conformant client derives from a modern body. */
function bodyDerivedStandardHeaders(body: unknown): Record<string, string> {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return {};
const b = body as { method?: unknown; params?: { name?: unknown; uri?: unknown; _meta?: Record<string, unknown> } };
if (typeof b.params?._meta?.[PROTOCOL_VERSION_META_KEY] !== 'string') return {};
const out: Record<string, string> = {};
if (typeof b.method === 'string') out['mcp-method'] = b.method;
const name = b.method === 'resources/read' ? b.params.uri : b.params.name;
if (typeof name === 'string') out['mcp-name'] = name;
return out;
}
function testFactory(): { factory: (ctx: McpRequestContext) => McpServer; contexts: McpRequestContext[] } {
const contexts: McpRequestContext[] = [];
const factory = (ctx: McpRequestContext): McpServer => {
contexts.push(ctx);
const mcpServer = new McpServer({ name: 'node-adapter-test-server', version: '1.0.0' });
mcpServer.registerTool('echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
content: [{ type: 'text', text }]
}));
mcpServer.registerTool('whoami', { inputSchema: z.object({}) }, async (_args, ctx2) => ({
content: [{ type: 'text', text: ctx2.http?.authInfo?.clientId ?? 'anonymous' }]
}));
mcpServer.registerTool('progress-then-echo', { inputSchema: z.object({ text: z.string() }) }, async ({ text }, ctx2) => {
await ctx2.mcpReq.notify({ method: 'notifications/progress', params: { progressToken: 'tok', progress: 1 } });
return { content: [{ type: 'text', text }] };
});
return mcpServer;
};
return { factory, contexts };
}
describe('toNodeHandler', () => {
it('serves through the duck-typed Node adapter, reading the request stream when no parsed body is given', async () => {
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'node face' }));
// Express mounts pass `next` as the third argument; a function is never a parsed body.
await node(req, res, () => {});
expect(res.statusCode).toBe(200);
expect(await body()).toContain('node face');
});
it('prefers a pre-parsed body over the request stream', async () => {
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const parsed = modernToolsCall('echo', { text: 'pre-parsed' });
const { req, res, body } = nodeRequestResponse(undefined);
Object.assign(req.headers, bodyDerivedStandardHeaders(parsed));
await node(req, res, parsed);
expect(res.statusCode).toBe(200);
expect(await body()).toContain('pre-parsed');
});
it('serves a pre-parsed legacy body on the default fallback (the documented express.json mounting)', async () => {
const { factory, contexts } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
// The documented Express mounting: express.json() consumed the stream
// and hands the parsed object as the third argument; the raw headers
// still describe the original (already-consumed) bytes.
const legacyMessage = { jsonrpc: '2.0', id: 7, method: 'tools/call', params: { name: 'echo', arguments: { text: 'node legacy' } } };
const { req, res, body } = nodeRequestResponse(undefined);
req.headers['content-length'] = '999';
req.headers['transfer-encoding'] = 'chunked';
await node(req, res, legacyMessage);
expect(res.statusCode).toBe(200);
expect(await body()).toContain('node legacy');
expect(contexts).toHaveLength(1);
expect(contexts[0]?.era).toBe('legacy');
});
it('forwards req.auth from upstream middleware as pass-through authInfo', async () => {
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const { req, res, body } = nodeRequestResponse(modernToolsCall('whoami', {}));
req.auth = { token: 'verified', clientId: 'node-client', scopes: [] };
await node(req, res);
expect(res.statusCode).toBe(200);
expect(await body()).toContain('node-client');
});
it('skips HTTP/2 pseudo-headers when copying node request headers', async () => {
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const { req, res, body } = nodeRequestResponse(modernToolsCall('echo', { text: 'http2 served' }));
Object.assign(req.headers, {
':method': 'POST',
':path': '/mcp',
':scheme': 'http',
':authority': 'localhost:3000'
});
await node(req, res);
expect(res.statusCode).toBe(200);
expect(await body()).toContain('http2 served');
});
it('waits for drain before writing the next chunk when res.write reports backpressure', async () => {
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const writes: string[] = [];
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const res: NodeServerResponseLike & { statusCode: number } = {
statusCode: 0,
writeHead(statusCode: number) {
this.statusCode = statusCode;
return this;
},
write(chunk: string | Uint8Array) {
writes.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
// Always report a full buffer.
return false;
},
end() {
return this;
},
on(event: string, listener: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
return this;
}
};
const emitDrain = () => {
for (const listener of listeners.get('drain') ?? []) {
listener();
}
};
// The default (auto) response mode streams this exchange over SSE, so
// the loop sees at least two chunks (the progress frame and the result).
const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'paced' }));
const served = node(req, res);
await vi.waitFor(() => expect(writes.length).toBe(1));
// With the buffer reported full and no drain yet, no further chunk is written.
await new Promise(resolve => setTimeout(resolve, 25));
expect(writes).toHaveLength(1);
// Draining releases the loop chunk by chunk until the stream completes.
const pump = setInterval(emitDrain, 5);
await served;
clearInterval(pump);
const streamed = writes.join('');
expect(writes.length).toBeGreaterThan(1);
expect(streamed).toContain('notifications/progress');
expect(streamed).toContain('paced');
});
it('does not park on backpressure when the client closes without ever draining', async () => {
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
let writeCount = 0;
const res: NodeServerResponseLike & { statusCode: number } = {
statusCode: 0,
writeHead(statusCode: number) {
this.statusCode = statusCode;
return this;
},
write() {
writeCount += 1;
// Always report a full buffer; 'drain' will never fire.
return false;
},
end() {
return this;
},
on(event: string, listener: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
existing.push(listener);
listeners.set(event, existing);
return this;
}
};
const emitClose = () => {
for (const listener of listeners.get('close') ?? []) {
listener();
}
};
const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'gone' }));
const served = node(req, res);
// The first chunk is written, then the loop waits for drain.
await vi.waitFor(() => expect(writeCount).toBe(1));
// The client vanishes mid-stream: 'close' fires, 'drain' never does.
emitClose();
// The handler promise must resolve — racing the abort signal against
// the drain wait releases the loop instead of parking forever.
await expect(
Promise.race([served, new Promise((_, reject) => setTimeout(() => reject(new Error('parked')), 500))])
).resolves.toBeUndefined();
});
it('does not park on backpressure when the response was already destroyed before the adapter listened', async () => {
// 'close' fired during async middleware BEFORE toNodeHandler registered
// its listener — `res.destroyed` is the entry-time witness. The
// adapter must seed the abort from it so the drain wait cannot park.
const { factory } = testFactory();
const node = toNodeHandler(createMcpHandler(factory));
const res: NodeServerResponseLike & { statusCode: number } = {
statusCode: 0,
destroyed: true,
writeHead(statusCode: number) {
this.statusCode = statusCode;
return this;
},
write() {
// Always report a full buffer; 'drain' will never fire and
// 'close' already happened (no listener will ever be called).
return false;
},
end() {
return this;
},
on() {
return this;
}
};
const { req } = nodeRequestResponse(modernToolsCall('progress-then-echo', { text: 'gone' }));
const served = node(req, res);
// The handler promise must resolve — the entry-time `destroyed` check
// seeds the abort signal so the drain race releases immediately.
await expect(
Promise.race([served, new Promise((_, reject) => setTimeout(() => reject(new Error('parked')), 500))])
).resolves.toBeUndefined();
});
it('answers with a 500 JSON-RPC error when handler.fetch throws (closed handler)', async () => {
const { factory } = testFactory();
const handler = createMcpHandler(factory);
await handler.close();
const node = toNodeHandler(handler);
const parsed = modernToolsCall('echo', { text: 'late' });
const { req, res, body } = nodeRequestResponse(undefined);
Object.assign(req.headers, bodyDerivedStandardHeaders(parsed));
await node(req, res, parsed);
expect(res.statusCode).toBe(500);
const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown };
expect(payload.error.code).toBe(-32_603);
expect(payload.id).toBe(1);
});
it('reports the adapter-level fallback error to onerror before answering 500', async () => {
const thrown = new Error('fetch boom');
const onerror = vi.fn();
const node = toNodeHandler(
{
fetch: () => {
throw thrown;
}
},
{ onerror }
);
const parsed = modernToolsCall('echo', { text: 'late' });
const { req, res, body } = nodeRequestResponse(undefined);
Object.assign(req.headers, bodyDerivedStandardHeaders(parsed));
await node(req, res, parsed);
expect(onerror).toHaveBeenCalledTimes(1);
expect(onerror).toHaveBeenCalledWith(thrown);
expect(res.statusCode).toBe(500);
const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown };
expect(payload.error.code).toBe(-32_603);
expect(payload.id).toBe(1);
});
it('still answers 500 when the onerror callback itself throws', async () => {
const node = toNodeHandler(
{
fetch: () => {
throw new Error('fetch boom');
}
},
{
onerror: () => {
throw new Error('reporter boom');
}
}
);
const parsed = modernToolsCall('echo', { text: 'late' });
const { req, res, body } = nodeRequestResponse(undefined);
Object.assign(req.headers, bodyDerivedStandardHeaders(parsed));
await node(req, res, parsed);
expect(res.statusCode).toBe(500);
const payload = JSON.parse(await body()) as { error: { code: number }; id: unknown };
expect(payload.error.code).toBe(-32_603);
expect(payload.id).toBe(1);
});
});
/* ------------------------------------------------------------------------ *
* Node face fixtures (duck-typed, no real sockets)
* ------------------------------------------------------------------------ */
interface FakeNodeResponse extends NodeServerResponseLike {
statusCode: number;
headers: Record<string, string> | undefined;
}
function nodeRequestResponse(body: unknown): {
req: Readable & {
method: string;
url: string;
headers: Record<string, string>;
auth?: { token: string; clientId: string; scopes: string[] };
};
res: FakeNodeResponse;
body: () => Promise<string>;
} {
const payload = body === undefined ? [] : [JSON.stringify(body)];
const req = Object.assign(Readable.from(payload), {
method: 'POST',
url: '/mcp',
headers: {
host: 'localhost:3000',
'content-type': 'application/json',
accept: 'application/json, text/event-stream',
...bodyDerivedStandardHeaders(body)
} as Record<string, string>
});
const chunks: string[] = [];
let resolveFinished: () => void;
const finished = new Promise<void>(resolve => {
resolveFinished = resolve;
});
const res: FakeNodeResponse = {
statusCode: 0,
headers: undefined,
writeHead(statusCode: number, headers?: Record<string, string>) {
this.statusCode = statusCode;
this.headers = headers;
return this;
},
write(chunk: string | Uint8Array) {
chunks.push(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk));
return true;
},
end(chunk?: string | Uint8Array) {
if (chunk !== undefined) {
this.write(chunk);
}
resolveFinished();
return this;
},
on() {
return this;
}
};
return {
req,
res,
body: async () => {
await finished;
return chunks.join('');
}
};
}

View file

@ -0,0 +1,79 @@
import type { IncomingMessage, ServerResponse } from 'node:http';
import { vi } from 'vitest';
import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation';
import { localhostOriginValidation, originValidation } from '../src/middleware/originValidation';
function fakeReqRes(headers: Record<string, string | undefined>) {
const req = { headers } as unknown as IncomingMessage;
const writeHead = vi.fn().mockReturnThis();
const end = vi.fn().mockReturnThis();
const res = { writeHead, end } as unknown as ServerResponse;
return { req, res, writeHead, end };
}
function sentBody(end: ReturnType<typeof vi.fn>): unknown {
const payload = end.mock.calls[0]?.[0] as string | undefined;
return payload === undefined ? undefined : JSON.parse(payload);
}
describe('@modelcontextprotocol/node validation guards', () => {
describe('hostHeaderValidation', () => {
test('blocks a disallowed Host header with a 403 JSON-RPC error and reports the request as handled', () => {
const guard = hostHeaderValidation(['localhost']);
const { req, res, writeHead, end } = fakeReqRes({ host: 'evil.example.com:3000' });
expect(guard(req, res)).toBe(false);
expect(writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'application/json' });
expect(sentBody(end)).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({ code: -32_000 }),
id: null
})
);
});
test('allows an allowed Host header (port-agnostic)', () => {
const guard = localhostHostValidation();
const { req, res, writeHead } = fakeReqRes({ host: '127.0.0.1:8080' });
expect(guard(req, res)).toBe(true);
expect(writeHead).not.toHaveBeenCalled();
});
});
describe('originValidation', () => {
test('blocks a disallowed Origin header with a 403 JSON-RPC error', () => {
const guard = originValidation(['localhost']);
const { req, res, writeHead, end } = fakeReqRes({ host: 'localhost:3000', origin: 'http://evil.example.com' });
expect(guard(req, res)).toBe(false);
expect(writeHead).toHaveBeenCalledWith(403, { 'Content-Type': 'application/json' });
expect(sentBody(end)).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({ code: -32_000 }),
id: null
})
);
});
test('allows an allowed Origin and requests without an Origin header', () => {
const guard = localhostOriginValidation();
const allowed = fakeReqRes({ host: 'localhost:3000', origin: 'http://localhost:5173' });
expect(guard(allowed.req, allowed.res)).toBe(true);
const absent = fakeReqRes({ host: 'localhost:3000' });
expect(guard(absent.req, absent.res)).toBe(true);
});
test('denies malformed Origin values (deny on failure)', () => {
const guard = localhostOriginValidation();
const { req, res } = fakeReqRes({ host: 'localhost:3000', origin: 'null' });
expect(guard(req, res)).toBe(false);
});
});
});

Some files were not shown because too many files have changed in this diff Show more