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 { 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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,3 @@
|
|||
export * from './hono';
|
||||
export * from './middleware/hostHeaderValidation';
|
||||
export * from './middleware/originValidation';
|
||||
|
|
|
|||
38
packages/middleware/hono/src/middleware/originValidation.ts
Normal file
38
packages/middleware/hono/src/middleware/originValidation.ts
Normal 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());
|
||||
}
|
||||
91
packages/middleware/hono/test/originValidation.test.ts
Normal file
91
packages/middleware/hono/test/originValidation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue