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 { 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);
});
});
});