[v2] Decouple server from express and hono - http framework-agnostic MCP server (#1326)
This commit is contained in:
parent
5ce4b5ef08
commit
f495077e08
30 changed files with 8193 additions and 15757 deletions
24
packages/middleware/README.md
Normal file
24
packages/middleware/README.md
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# Middleware packages
|
||||
|
||||
The packages in `packages/middleware/*` are **thin integration layers** that help you expose an MCP server in a specific runtime, platform, or web framework.
|
||||
|
||||
They intentionally **do not** add new MCP features or “business logic”. MCP functionality (tools, resources, prompts, transports, auth primitives, etc.) lives in `@modelcontextprotocol/server` (and other core packages). Middleware packages should primarily:
|
||||
|
||||
- adapt request/response types to the SDK (e.g. Node.js `IncomingMessage`/`ServerResponse`)
|
||||
- provide small framework helpers (e.g. wiring, body parsing hooks)
|
||||
- supply safe defaults for common deployment pitfalls (e.g. localhost DNS rebinding protection)
|
||||
|
||||
## Packages
|
||||
|
||||
- `@modelcontextprotocol/express` — Express helpers (app defaults + Host header validation for DNS rebinding protection).
|
||||
- `@modelcontextprotocol/hono` — Hono helpers (app defaults + JSON body parsing hook + Host header validation).
|
||||
- `@modelcontextprotocol/node` — Node.js Streamable HTTP transport wrapper for `IncomingMessage`/`ServerResponse`.
|
||||
|
||||
## Typical usage
|
||||
|
||||
Most servers use:
|
||||
|
||||
- `@modelcontextprotocol/server` for the MCP server implementation
|
||||
- one middleware package for framework/runtime integration (this folder)
|
||||
- (optionally) additional platform/framework dependencies (Express, Hono, etc.)
|
||||
|
||||
62
packages/middleware/express/README.md
Normal file
62
packages/middleware/express/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# `@modelcontextprotocol/express`
|
||||
|
||||
Express adapters for the MCP TypeScript server SDK.
|
||||
|
||||
This package is a thin Express integration layer for [`@modelcontextprotocol/server`](../../server/).
|
||||
|
||||
It does **not** implement MCP itself. Instead, it helps you:
|
||||
|
||||
- create an Express app with sensible defaults for MCP servers
|
||||
- add DNS rebinding protection via Host header validation (recommended for localhost servers)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @modelcontextprotocol/server @modelcontextprotocol/express express
|
||||
|
||||
# For MCP Streamable HTTP over Node.js (IncomingMessage/ServerResponse):
|
||||
npm install @modelcontextprotocol/node
|
||||
```
|
||||
|
||||
## Exports
|
||||
|
||||
- `createMcpExpressApp(options?)`
|
||||
- `hostHeaderValidation(allowedHostnames)`
|
||||
- `localhostHostValidation()`
|
||||
|
||||
## Usage
|
||||
|
||||
### Create an Express app (localhost DNS rebinding protection by default)
|
||||
|
||||
```ts
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
|
||||
const app = createMcpExpressApp(); // default host is 127.0.0.1; protection enabled
|
||||
```
|
||||
|
||||
### Streamable HTTP endpoint (Express)
|
||||
|
||||
```ts
|
||||
import { createMcpExpressApp } from '@modelcontextprotocol/express';
|
||||
import { NodeStreamableHTTPServerTransport } from '@modelcontextprotocol/node';
|
||||
import { McpServer } from '@modelcontextprotocol/server';
|
||||
|
||||
const app = createMcpExpressApp();
|
||||
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
|
||||
|
||||
app.post('/mcp', async (req, res) => {
|
||||
// Stateless example: create a transport per request.
|
||||
// For stateful mode (sessions), keep a transport instance around and reuse it.
|
||||
const transport = new NodeStreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
||||
await server.connect(transport);
|
||||
await transport.handleRequest(req, res, req.body);
|
||||
});
|
||||
```
|
||||
|
||||
### Host header validation (DNS rebinding protection)
|
||||
|
||||
```ts
|
||||
import { hostHeaderValidation } from '@modelcontextprotocol/express';
|
||||
|
||||
app.use(hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']));
|
||||
```
|
||||
12
packages/middleware/express/eslint.config.mjs
Normal file
12
packages/middleware/express/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// @ts-check
|
||||
|
||||
import baseConfig from '@modelcontextprotocol/eslint-config';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@modelcontextprotocol/(server|core)'
|
||||
}
|
||||
}
|
||||
];
|
||||
69
packages/middleware/express/package.json
Normal file
69
packages/middleware/express/package.json
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
{
|
||||
"name": "@modelcontextprotocol/express",
|
||||
"private": false,
|
||||
"version": "2.0.0-alpha.0",
|
||||
"description": "Express adapters for the Model Context Protocol TypeScript server SDK - Express 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",
|
||||
"express",
|
||||
"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:^",
|
||||
"express": "catalog:runtimeServerOnly"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@modelcontextprotocol/server": "workspace:^",
|
||||
"@modelcontextprotocol/tsconfig": "workspace:^",
|
||||
"@modelcontextprotocol/vitest-config": "workspace:^",
|
||||
"@modelcontextprotocol/eslint-config": "workspace:^",
|
||||
"@eslint/js": "catalog:devTools",
|
||||
"@types/express": "catalog:devTools",
|
||||
"@types/express-serve-static-core": "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"
|
||||
}
|
||||
}
|
||||
76
packages/middleware/express/src/express.ts
Normal file
76
packages/middleware/express/src/express.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import type { Express } from 'express';
|
||||
import express from 'express';
|
||||
|
||||
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';
|
||||
|
||||
/**
|
||||
* Options for creating an MCP Express application.
|
||||
*/
|
||||
export interface CreateMcpExpressAppOptions {
|
||||
/**
|
||||
* 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 an Express 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.
|
||||
*
|
||||
* @param options - Configuration options
|
||||
* @returns A configured Express application
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* // Basic usage - defaults to 127.0.0.1 with DNS rebinding protection
|
||||
* const app = createMcpExpressApp();
|
||||
*
|
||||
* // Custom host - DNS rebinding protection only applied for localhost hosts
|
||||
* const app = createMcpExpressApp({ host: '0.0.0.0' }); // No automatic DNS rebinding protection
|
||||
* const app = createMcpExpressApp({ host: 'localhost' }); // DNS rebinding protection enabled
|
||||
*
|
||||
* // Custom allowed hosts for non-localhost binding
|
||||
* const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local', 'localhost'] });
|
||||
* ```
|
||||
*/
|
||||
export function createMcpExpressApp(options: CreateMcpExpressAppOptions = {}): Express {
|
||||
const { host = '127.0.0.1', allowedHosts } = options;
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// 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;
|
||||
}
|
||||
2
packages/middleware/express/src/index.ts
Normal file
2
packages/middleware/express/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './express.js';
|
||||
export * from './middleware/hostHeaderValidation.js';
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import { localhostAllowedHostnames, validateHostHeader } from '@modelcontextprotocol/server';
|
||||
import type { NextFunction, Request, RequestHandler, Response } from 'express';
|
||||
|
||||
/**
|
||||
* Express middleware for DNS rebinding protection.
|
||||
* Validates Host header hostname (port-agnostic) against an allowed list.
|
||||
*
|
||||
* This is particularly important for servers without authorization or HTTPS,
|
||||
* such as localhost servers or development servers. DNS rebinding attacks can
|
||||
* bypass same-origin policy by manipulating DNS to point a domain to a
|
||||
* localhost address, allowing malicious websites to access your local server.
|
||||
*
|
||||
* @param allowedHostnames - List of allowed hostnames (without ports).
|
||||
* For IPv6, provide the address with brackets (e.g., '[::1]').
|
||||
* @returns Express middleware function
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const middleware = hostHeaderValidation(['localhost', '127.0.0.1', '[::1]']);
|
||||
* app.use(middleware);
|
||||
* ```
|
||||
*/
|
||||
export function hostHeaderValidation(allowedHostnames: string[]): RequestHandler {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
const result = validateHostHeader(req.headers.host, allowedHostnames);
|
||||
if (!result.ok) {
|
||||
res.status(403).json({
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: result.message
|
||||
},
|
||||
id: null
|
||||
});
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience middleware for localhost DNS rebinding protection.
|
||||
* Allows only localhost, 127.0.0.1, and [::1] (IPv6 localhost) hostnames.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* app.use(localhostHostValidation());
|
||||
* ```
|
||||
*/
|
||||
export function localhostHostValidation(): RequestHandler {
|
||||
return hostHeaderValidation(localhostAllowedHostnames());
|
||||
}
|
||||
182
packages/middleware/express/test/server-express.test.ts
Normal file
182
packages/middleware/express/test/server-express.test.ts
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import type { NextFunction, Request, Response } from 'express';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { createMcpExpressApp } from '../src/express.js';
|
||||
import { hostHeaderValidation, localhostHostValidation } from '../src/middleware/hostHeaderValidation.js';
|
||||
|
||||
// Helper to create mock Express request/response/next
|
||||
function createMockReqResNext(host?: string) {
|
||||
const req = {
|
||||
headers: {
|
||||
host
|
||||
}
|
||||
} as Request;
|
||||
|
||||
const res = {
|
||||
status: vi.fn().mockReturnThis(),
|
||||
json: vi.fn().mockReturnThis()
|
||||
} as unknown as Response;
|
||||
|
||||
const next = vi.fn() as NextFunction;
|
||||
|
||||
return { req, res, next };
|
||||
}
|
||||
|
||||
describe('@modelcontextprotocol/express', () => {
|
||||
describe('hostHeaderValidation', () => {
|
||||
test('should block invalid Host header', () => {
|
||||
const middleware = hostHeaderValidation(['localhost']);
|
||||
const { req, res, next } = createMockReqResNext('evil.com:3000');
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
jsonrpc: '2.0',
|
||||
error: expect.objectContaining({
|
||||
code: -32000
|
||||
}),
|
||||
id: null
|
||||
})
|
||||
);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow valid Host header', () => {
|
||||
const middleware = hostHeaderValidation(['localhost']);
|
||||
const { req, res, next } = createMockReqResNext('localhost:3000');
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
expect(res.json).not.toHaveBeenCalled();
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle multiple allowed hostnames', () => {
|
||||
const middleware = hostHeaderValidation(['localhost', '127.0.0.1', 'myapp.local']);
|
||||
const { req: req1, res: res1, next: next1 } = createMockReqResNext('127.0.0.1:8080');
|
||||
const { req: req2, res: res2, next: next2 } = createMockReqResNext('myapp.local');
|
||||
|
||||
middleware(req1, res1, next1);
|
||||
middleware(req2, res2, next2);
|
||||
|
||||
expect(next1).toHaveBeenCalled();
|
||||
expect(next2).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('localhostHostValidation', () => {
|
||||
test('should allow localhost', () => {
|
||||
const middleware = localhostHostValidation();
|
||||
const { req, res, next } = createMockReqResNext('localhost:3000');
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow 127.0.0.1', () => {
|
||||
const middleware = localhostHostValidation();
|
||||
const { req, res, next } = createMockReqResNext('127.0.0.1:3000');
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should allow [::1] (IPv6 localhost)', () => {
|
||||
const middleware = localhostHostValidation();
|
||||
const { req, res, next } = createMockReqResNext('[::1]:3000');
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should block non-localhost hosts', () => {
|
||||
const middleware = localhostHostValidation();
|
||||
const { req, res, next } = createMockReqResNext('evil.com:3000');
|
||||
|
||||
middleware(req, res, next);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMcpExpressApp', () => {
|
||||
test('should enable localhost DNS rebinding protection by default', () => {
|
||||
const app = createMcpExpressApp();
|
||||
|
||||
// The app should be a valid Express application
|
||||
expect(app).toBeDefined();
|
||||
expect(typeof app.use).toBe('function');
|
||||
expect(typeof app.get).toBe('function');
|
||||
expect(typeof app.post).toBe('function');
|
||||
});
|
||||
|
||||
test('should apply DNS rebinding protection for localhost host', () => {
|
||||
const app = createMcpExpressApp({ host: 'localhost' });
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
test('should apply DNS rebinding protection for ::1 host', () => {
|
||||
const app = createMcpExpressApp({ host: '::1' });
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
test('should use allowedHosts when provided', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
const app = createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
|
||||
warn.mockRestore();
|
||||
|
||||
expect(app).toBeDefined();
|
||||
});
|
||||
|
||||
test('should warn when binding to 0.0.0.0 without allowedHosts', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
createMcpExpressApp({ host: '0.0.0.0' });
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Warning: Server is binding to 0.0.0.0 without DNS rebinding protection')
|
||||
);
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('should warn when binding to :: without allowedHosts', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
createMcpExpressApp({ host: '::' });
|
||||
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Warning: Server is binding to :: without DNS rebinding protection'));
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('should not warn for 0.0.0.0 when allowedHosts is provided', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
createMcpExpressApp({ host: '0.0.0.0', allowedHosts: ['myapp.local'] });
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
test('should not apply host validation for non-localhost hosts without allowedHosts', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
// For arbitrary hosts (not 0.0.0.0 or ::), no validation is applied and no warning
|
||||
const app = createMcpExpressApp({ host: '192.168.1.1' });
|
||||
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
expect(app).toBeDefined();
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
14
packages/middleware/express/tsconfig.json
Normal file
14
packages/middleware/express/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/middleware/express/tsdown.config.ts
Normal file
21
packages/middleware/express/tsdown.config.ts
Normal 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']
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
3
packages/middleware/express/vitest.config.js
Normal file
3
packages/middleware/express/vitest.config.js
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import baseConfig from '@modelcontextprotocol/vitest-config';
|
||||
|
||||
export default baseConfig;
|
||||
48
packages/middleware/hono/README.md
Normal file
48
packages/middleware/hono/README.md
Normal 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());
|
||||
```
|
||||
12
packages/middleware/hono/eslint.config.mjs
Normal file
12
packages/middleware/hono/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// @ts-check
|
||||
|
||||
import baseConfig from '@modelcontextprotocol/eslint-config';
|
||||
|
||||
export default [
|
||||
...baseConfig,
|
||||
{
|
||||
settings: {
|
||||
'import/internal-regex': '^@modelcontextprotocol/(server|core)'
|
||||
}
|
||||
}
|
||||
];
|
||||
67
packages/middleware/hono/package.json
Normal file
67
packages/middleware/hono/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
90
packages/middleware/hono/src/hono.ts
Normal file
90
packages/middleware/hono/src/hono.ts
Normal 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;
|
||||
}
|
||||
2
packages/middleware/hono/src/index.ts
Normal file
2
packages/middleware/hono/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './hono.js';
|
||||
export * from './middleware/hostHeaderValidation.js';
|
||||
|
|
@ -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());
|
||||
}
|
||||
109
packages/middleware/hono/test/server-hono.test.ts
Normal file
109
packages/middleware/hono/test/server-hono.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
14
packages/middleware/hono/tsconfig.json
Normal file
14
packages/middleware/hono/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
21
packages/middleware/hono/tsdown.config.ts
Normal file
21
packages/middleware/hono/tsdown.config.ts
Normal 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']
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue