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:
parent
e4227d135c
commit
1823aae891
21 changed files with 69131 additions and 9001 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
171
packages/middleware/express/test/originValidation.test.ts
Normal file
171
packages/middleware/express/test/originValidation.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './fastify';
|
||||
export * from './middleware/hostHeaderValidation';
|
||||
export * from './middleware/originValidation';
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
116
packages/middleware/fastify/test/originValidation.test.ts
Normal file
116
packages/middleware/fastify/test/originValidation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './hono';
|
||||
export * from './middleware/hostHeaderValidation';
|
||||
export * from './middleware/originValidation';
|
||||
|
|
|
|||
38
packages/middleware/hono/src/middleware/originValidation.ts
Normal file
38
packages/middleware/hono/src/middleware/originValidation.ts
Normal 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());
|
||||
}
|
||||
91
packages/middleware/hono/test/originValidation.test.ts
Normal file
91
packages/middleware/hono/test/originValidation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
54
packages/middleware/node/src/middleware/originValidation.ts
Normal file
54
packages/middleware/node/src/middleware/originValidation.ts
Normal 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());
|
||||
}
|
||||
|
|
@ -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`.
|
||||
*
|
||||
|
|
|
|||
271
packages/middleware/node/src/toNodeHandler.ts
Normal file
271
packages/middleware/node/src/toNodeHandler.ts
Normal 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 });
|
||||
}
|
||||
416
packages/middleware/node/test/toNodeHandler.test.ts
Normal file
416
packages/middleware/node/test/toNodeHandler.test.ts
Normal 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('');
|
||||
}
|
||||
};
|
||||
}
|
||||
79
packages/middleware/node/test/validation.test.ts
Normal file
79
packages/middleware/node/test/validation.test.ts
Normal 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
Loading…
Reference in a new issue