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

View file

@ -1,2 +1,3 @@
export * from './hono';
export * from './middleware/hostHeaderValidation';
export * from './middleware/originValidation';

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

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