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