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
4 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();
});
});
});