[v2] Decouple server from express and hono - http framework-agnostic MCP server (#1326)

This commit is contained in:
Konstantin Konstantinov 2026-01-16 12:13:25 +02:00 • committed by GitHub
commit f495077e08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 8193 additions and 15757 deletions

View file

@ -0,0 +1,48 @@
# `@modelcontextprotocol/hono`
Hono adapters for the MCP TypeScript server SDK.
This package is a thin Hono integration layer for [`@modelcontextprotocol/server`](../../server/).
It does **not** implement MCP itself. Instead, it helps you:
- create a Hono app with sensible defaults for MCP servers
- parse JSON request bodies and expose them as `c.get('parsedBody')` for Streamable HTTP transports
- add DNS rebinding protection via Host header validation (recommended for localhost servers)
## Install
```bash
npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono
```
## Exports
- `createMcpHonoApp(options?)`
- `hostHeaderValidation(allowedHostnames)`
- `localhostHostValidation()`
## Usage
### Streamable HTTP endpoint (Hono)
```ts
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
const app = createMcpHonoApp();
app.all('/mcp', c => transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') }));
```
### Host header validation (DNS rebinding protection)
```ts
import { localhostHostValidation } from '@modelcontextprotocol/hono';
const app = createMcpHonoApp();
app.use('*', localhostHostValidation());
```

View file

@ -0,0 +1,12 @@
// @ts-check
import baseConfig from '@modelcontextprotocol/eslint-config';
export default [
...baseConfig,
{
settings: {
'import/internal-regex': '^@modelcontextprotocol/(server|core)'
}
}
];

View file

@ -0,0 +1,67 @@
{
"name": "@modelcontextprotocol/hono",
"private": false,
"version": "2.0.0-alpha.0",
"description": "Hono adapters for the Model Context Protocol TypeScript server SDK - Hono middleware",
"license": "MIT",
"author": "Anthropic, PBC (https://anthropic.com)",
"homepage": "https://modelcontextprotocol.io",
"bugs": "https://github.com/modelcontextprotocol/typescript-sdk/issues",
"type": "module",
"repository": {
"type": "git",
"url": "git+https://github.com/modelcontextprotocol/typescript-sdk.git"
},
"engines": {
"node": ">=20",
"pnpm": ">=10.24.0"
},
"packageManager": "pnpm@10.24.0",
"keywords": [
"modelcontextprotocol",
"mcp",
"hono",
"middleware"
],
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs"
}
},
"files": [
"dist"
],
"scripts": {
"typecheck": "tsgo -p tsconfig.json --noEmit",
"build": "tsdown",
"build:watch": "tsdown --watch",
"prepack": "npm run build",
"lint": "eslint src/ && prettier --ignore-path ../../.prettierignore --check .",
"lint:fix": "eslint src/ --fix && prettier --ignore-path ../../.prettierignore --write .",
"check": "npm run typecheck && npm run lint",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {},
"peerDependencies": {
"@modelcontextprotocol/server": "workspace:^",
"hono": "catalog:runtimeServerOnly"
},
"devDependencies": {
"@modelcontextprotocol/server": "workspace:^",
"@modelcontextprotocol/tsconfig": "workspace:^",
"@modelcontextprotocol/vitest-config": "workspace:^",
"@modelcontextprotocol/eslint-config": "workspace:^",
"@eslint/js": "catalog:devTools",
"@typescript/native-preview": "catalog:devTools",
"eslint": "catalog:devTools",
"eslint-config-prettier": "catalog:devTools",
"eslint-plugin-n": "catalog:devTools",
"prettier": "catalog:devTools",
"tsdown": "catalog:devTools",
"typescript": "catalog:devTools",
"typescript-eslint": "catalog:devTools",
"vitest": "catalog:devTools"
}
}

View file

@ -0,0 +1,90 @@
import type { Context } from 'hono';
import { Hono } from 'hono';
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';
/**
* Options for creating an MCP Hono application.
*/
export interface CreateMcpHonoAppOptions {
/**
* The hostname to bind to. Defaults to '127.0.0.1'.
* When set to '127.0.0.1', 'localhost', or '::1', DNS rebinding protection is automatically enabled.
*/
host?: string;
/**
* List of allowed hostnames for DNS rebinding protection.
* If provided, host header validation will be applied using this list.
* For IPv6, provide addresses with brackets (e.g., '[::1]').
*
* This is useful when binding to '0.0.0.0' or '::' but still wanting
* to restrict which hostnames are allowed.
*/
allowedHosts?: string[];
}
/**
* Creates a Hono application pre-configured for MCP servers.
*
* When the host is '127.0.0.1', 'localhost', or '::1' (the default is '127.0.0.1'),
* DNS rebinding protection middleware is automatically applied to protect against
* DNS rebinding attacks on localhost servers.
*
* This also installs a small JSON body parsing middleware (similar to `express.json()`)
* that stashes the parsed body into `c.set('parsedBody', ...)` when `Content-Type` includes
* `application/json`.
*
* @param options - Configuration options
* @returns A configured Hono application
*/
export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
const { host = '127.0.0.1', allowedHosts } = options;
const app = new Hono();
// Similar to `express.json()`: parse JSON bodies and make them available to MCP adapters via `parsedBody`.
app.use('*', async (c: Context, next) => {
// If an upstream middleware already set parsedBody, keep it.
if (c.get('parsedBody') !== undefined) {
return await next();
}
const ct = c.req.header('content-type') ?? '';
if (!ct.includes('application/json')) {
return await next();
}
try {
// Parse from a clone so we don't consume the original request stream.
const parsed = await c.req.raw.clone().json();
c.set('parsedBody', parsed);
} catch {
// Mirror express.json() behavior loosely: reject invalid JSON.
return c.text('Invalid JSON', 400);
}
return await next();
});
// If allowedHosts is explicitly provided, use that for validation.
if (allowedHosts) {
app.use('*', hostHeaderValidation(allowedHosts));
} else {
// Apply DNS rebinding protection automatically for localhost hosts.
const localhostHosts = ['127.0.0.1', 'localhost', '::1'];
if (localhostHosts.includes(host)) {
app.use('*', localhostHostValidation());
} else if (host === '0.0.0.0' || host === '::') {
// Warn when binding to all interfaces without DNS rebinding protection.
// eslint-disable-next-line no-console
console.warn(
`Warning: Server is binding to ${host} without DNS rebinding protection. ` +
'Consider using the allowedHosts option to restrict allowed hosts, ' +
'or use authentication to protect your server.'
);
}
}
return app;
}

View file

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

View file

@ -0,0 +1,33 @@
import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server';
import type { MiddlewareHandler } from 'hono';
/**
* Hono middleware for DNS rebinding protection.
* Validates Host header hostname (port-agnostic) against an allowed list.
*/
export function hostHeaderValidation(allowedHostnames: string[]): MiddlewareHandler {
return async (c, next) => {
const result = validateHostHeader(c.req.header('host'), allowedHostnames);
if (!result.ok) {
return c.json(
{
jsonrpc: '2.0',
error: {
code: -32000,
message: result.message
},
id: null
},
403
);
}
return await next();
};
}
/**
* Convenience middleware for localhost DNS rebinding protection.
*/
export function localhostHostValidation(): MiddlewareHandler {
return hostHeaderValidation(localhostAllowedHostnames());
}

View file

@ -0,0 +1,109 @@
import type { Context } from 'hono';
import { Hono } from 'hono';
import { vi } from 'vitest';
import { createMcpHonoApp } from '../src/hono.js';
import { hostHeaderValidation } from '../src/middleware/hostHeaderValidation.js';
describe('@modelcontextprotocol/hono', () => {
test('hostHeaderValidation blocks invalid Host and allows valid Host', async () => {
const app = new Hono();
app.use('*', hostHeaderValidation(['localhost']));
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(bad.status).toBe(403);
expect(await bad.json()).toEqual(
expect.objectContaining({
jsonrpc: '2.0',
error: expect.objectContaining({
code: -32000
}),
id: null
})
);
const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } });
expect(good.status).toBe(200);
expect(await good.text()).toBe('ok');
});
test('createMcpHonoApp enables localhost DNS rebinding protection by default', async () => {
const app = createMcpHonoApp();
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(bad.status).toBe(403);
const good = await app.request('http://localhost/health', { headers: { Host: 'localhost:3000' } });
expect(good.status).toBe(200);
});
test('createMcpHonoApp uses allowedHosts when provided (even when binding to 0.0.0.0)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
warn.mockRestore();
app.get('/health', c => c.text('ok'));
const bad = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(bad.status).toBe(403);
const good = await app.request('http://localhost/health', { headers: { Host: 'myapp.local:3000' } });
expect(good.status).toBe(200);
});
test('createMcpHonoApp does not apply host validation for 0.0.0.0 without allowedHosts', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const app = createMcpHonoApp({ host: '0.0.0.0' });
warn.mockRestore();
app.get('/health', c => c.text('ok'));
const res = await app.request('http://localhost/health', { headers: { Host: 'evil.com:3000' } });
expect(res.status).toBe(200);
});
test('createMcpHonoApp parses JSON bodies into parsedBody (express.json()-like)', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.json(c.get('parsedBody')));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json' },
body: JSON.stringify({ a: 1 })
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ a: 1 });
});
test('createMcpHonoApp returns 400 on invalid JSON', async () => {
const app = createMcpHonoApp();
app.post('/echo', (c: Context) => c.text('ok'));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json' },
body: '{"a":'
});
expect(res.status).toBe(400);
expect(await res.text()).toBe('Invalid JSON');
});
test('createMcpHonoApp does not override parsedBody if upstream middleware set it', async () => {
const app = createMcpHonoApp();
app.use('/echo', async (c: Context, next) => {
c.set('parsedBody', { preset: true });
return await next();
});
app.post('/echo', (c: Context) => c.json(c.get('parsedBody')));
const res = await app.request('http://localhost/echo', {
method: 'POST',
headers: { Host: 'localhost:3000', 'content-type': 'application/json' },
body: JSON.stringify({ a: 1 })
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ preset: true });
});
});

View file

@ -0,0 +1,14 @@
{
"extends": "@modelcontextprotocol/tsconfig",
"include": ["./"],
"exclude": ["node_modules", "dist"],
"compilerOptions": {
"paths": {
"*": ["./*"],
"@modelcontextprotocol/server": ["./node_modules/@modelcontextprotocol/server/src/index.ts"],
"@modelcontextprotocol/core": [
"./node_modules/@modelcontextprotocol/server/node_modules/@modelcontextprotocol/core/src/index.ts"
]
}
}
}

View file

@ -0,0 +1,21 @@
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['src/index.ts'],
format: ['esm'],
outDir: 'dist',
clean: true,
sourcemap: true,
target: 'esnext',
platform: 'node',
shims: true,
dts: {
resolver: 'tsc',
compilerOptions: {
baseUrl: '.',
paths: {
'@modelcontextprotocol/server': ['../server/src/index.ts']
}
}
}
});

View file

@ -0,0 +1,3 @@
import baseConfig from '@modelcontextprotocol/vitest-config';
export default baseConfig;