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
4 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue