Skip to content

Commit aaa3985

Browse files
feat(hono): add mcp() middleware to serve an McpServer in one call
Adds mcp(server, options?) — a single Hono MiddlewareHandler that serves an McpServer over Streamable HTTP with JSON body parsing and localhost DNS-rebinding / origin protection applied (the same defaults as createMcpHonoApp), so it can be mounted on a route you already own: app.all('/mcp', mcp(server)) The server is connected lazily on the first request. The inner app is self-contained, so only the raw request is forwarded to it — this also avoids c.executionCtx, which throws off Cloudflare Workers. Feature and API shape inspired by yusukebe's mcp-server-hono-middleware. - packages/middleware/hono/src/hono.ts: mcp() + McpMiddlewareOptions - packages/middleware/hono/test/mcp.test.ts: end-to-end + protection tests - README + docs/serving/hono.md: feature mcp() as the preferred path, keep createMcpHonoApp as the bring-your-own-app/factory escape hatch
1 parent cc4b416 commit aaa3985

5 files changed

Lines changed: 298 additions & 32 deletions

File tree

docs/serving/hono.md

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,63 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono
99

1010
## Mount the handler
1111

12-
`createMcpHandler` turns a server factory into a web-standard HTTP handler, and `handler.fetch` takes the `Request` a Hono route already holds as `c.req.raw` — no Node adapter. `createMcpHonoApp` is `new Hono()` with JSON body parsing and DNS rebinding protection already applied.
12+
`mcp(server)` is the shortest path: it returns a single Hono middleware that serves your `McpServer` over Streamable HTTP, with JSON body parsing and DNS rebinding protection already applied. Mount it on a route you already own — it connects the server on the first request.
13+
14+
```ts source="../../examples/guides/serving/hono.examples.ts#mcp_mount"
15+
import { mcp } from '@modelcontextprotocol/hono';
16+
import { McpServer } from '@modelcontextprotocol/server';
17+
import { Hono } from 'hono';
18+
import * as z from 'zod/v4';
19+
20+
const server = new McpServer({ name: 'notes', version: '1.0.0' });
21+
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
22+
content: [{ type: 'text', text: `Saved: ${text}` }]
23+
}));
24+
25+
const app = new Hono();
26+
app.all('/mcp', mcp(server));
27+
28+
export default app;
29+
```
30+
31+
`app` is an ordinary Hono app, and `export default app` is the `{ fetch }` object Cloudflare Workers, Deno, and Bun serve directly; on Node, pass `app` to `serve` from `@hono/node-server`.
32+
33+
### Bring your own app and factory
34+
35+
When you want a fresh `McpServer` per request or full control over routing, drop down to `createMcpHandler` + `createMcpHonoApp`. `createMcpHandler` turns a server factory into a web-standard HTTP handler, and `handler.fetch` takes the `Request` a Hono route already holds as `c.req.raw` — no Node adapter. `createMcpHonoApp` is `new Hono()` with the same JSON body parsing and DNS rebinding protection applied.
1336

1437
```ts source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_mount"
1538
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
16-
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
39+
import { createMcpHandler } from '@modelcontextprotocol/server';
1740
import type { Context } from 'hono';
18-
import * as z from 'zod/v4';
1941

2042
const handler = createMcpHandler(() => {
21-
const server = new McpServer({ name: 'notes', version: '1.0.0' });
22-
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
23-
content: [{ type: 'text', text: `Saved: ${text}` }]
24-
}));
25-
return server;
43+
const factoryServer = new McpServer({ name: 'notes', version: '1.0.0' });
44+
factoryServer.registerTool(
45+
'add-note',
46+
{ description: 'Append a note', inputSchema: z.object({ text: z.string() }) },
47+
async ({ text }) => ({
48+
content: [{ type: 'text', text: `Saved: ${text}` }]
49+
})
50+
);
51+
return factoryServer;
2652
});
2753

28-
const app = createMcpHonoApp();
29-
app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') }));
30-
31-
export default app;
54+
const factoryApp = createMcpHonoApp();
55+
factoryApp.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') }));
3256
```
3357

34-
`app` is an ordinary Hono app, and `export default app` is the `{ fetch }` object Cloudflare Workers, Deno, and Bun serve directly; on Node, pass `app` to `serve` from `@hono/node-server`. The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model.
58+
The factory runs once per request, so a fresh `McpServer` serves every call: [Serve over HTTP](./http.md#understand-the-per-request-factory) covers that model.
3559

3660
::: tip
3761
Keep the explicit `c: Context` annotation: on an inferred callback context `c.get`'s key parameter narrows to `never` and `c.get('parsedBody')` does not compile.
3862
:::
3963

4064
## Protect against DNS rebinding
4165

42-
A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `createMcpHonoApp` validates the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs.
66+
A malicious page can DNS-rebind its own domain to `127.0.0.1` and reach a localhost server as if it were same-origin. `mcp()` and `createMcpHonoApp` both validate the `Host` and `Origin` headers against that: with the default `127.0.0.1` bind (and `localhost` / `::1`), a request carrying a non-localhost value gets `403` before your handler runs.
4367

44-
Binding to all interfaces drops that default — name the hosts you serve instead.
68+
Binding to all interfaces drops that default — name the hosts you serve instead. `mcp()` takes the same `host` / `allowedHosts` / `allowedOrigins` options.
4569

4670
```ts source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_allowedHosts"
4771
const publicApp = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['api.example.com'] });
@@ -82,8 +106,8 @@ data: {"result":{"tools":[{"name":"add-note","description":"Append a note","inpu
82106

83107
## Recap
84108

85-
- One install line, one file: `createMcpHonoApp()` plus one `app.all('/mcp', …)` route over `createMcpHandler(factory).fetch`.
109+
- `mcp(server)` is one Hono middleware — `app.all('/mcp', mcp(server))` serves the whole endpoint with body parsing and Host/Origin validation applied.
110+
- Need a fresh server per request or custom routing? Use `createMcpHonoApp()` plus one `app.all('/mcp', …)` route over `createMcpHandler(factory).fetch`.
86111
- Hono hands `c.req.raw` straight to `handler.fetch` — no Node adapter.
87-
- A fresh server instance from your factory serves every request.
88112
- The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`.
89113
- `authInfo` and `parsedBody` travel in `handler.fetch`'s second argument; handlers read auth as `ctx.http.authInfo`.

examples/guides/serving/hono.examples.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,42 @@
1515
/* eslint-disable no-console */
1616
import type { AuthInfo } from '@modelcontextprotocol/server';
1717

18+
//#region mcp_mount
19+
import { mcp } from '@modelcontextprotocol/hono';
20+
import { McpServer } from '@modelcontextprotocol/server';
21+
import { Hono } from 'hono';
22+
import * as z from 'zod/v4';
23+
24+
const server = new McpServer({ name: 'notes', version: '1.0.0' });
25+
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
26+
content: [{ type: 'text', text: `Saved: ${text}` }]
27+
}));
28+
29+
const app = new Hono();
30+
app.all('/mcp', mcp(server));
31+
32+
export default app;
33+
//#endregion mcp_mount
34+
1835
//#region createMcpHonoApp_mount
1936
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
20-
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
37+
import { createMcpHandler } from '@modelcontextprotocol/server';
2138
import type { Context } from 'hono';
22-
import * as z from 'zod/v4';
2339

2440
const handler = createMcpHandler(() => {
25-
const server = new McpServer({ name: 'notes', version: '1.0.0' });
26-
server.registerTool('add-note', { description: 'Append a note', inputSchema: z.object({ text: z.string() }) }, async ({ text }) => ({
27-
content: [{ type: 'text', text: `Saved: ${text}` }]
28-
}));
29-
return server;
41+
const factoryServer = new McpServer({ name: 'notes', version: '1.0.0' });
42+
factoryServer.registerTool(
43+
'add-note',
44+
{ description: 'Append a note', inputSchema: z.object({ text: z.string() }) },
45+
async ({ text }) => ({
46+
content: [{ type: 'text', text: `Saved: ${text}` }]
47+
})
48+
);
49+
return factoryServer;
3050
});
3151

32-
const app = createMcpHonoApp();
33-
app.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') }));
34-
35-
export default app;
52+
const factoryApp = createMcpHonoApp();
53+
factoryApp.all('/mcp', (c: Context) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') }));
3654
//#endregion createMcpHonoApp_mount
3755

3856
//#region createMcpHonoApp_allowedHosts

packages/middleware/hono/README.md

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ This package is a thin Hono integration layer for [`@modelcontextprotocol/server
66

77
It does **not** implement MCP itself. Instead, it helps you:
88

9+
- serve an `McpServer` as a single Hono middleware with `mcp(server)`
910
- create a Hono app with sensible defaults for MCP servers
1011
- parse JSON request bodies and expose them as `c.get('parsedBody')` for Streamable HTTP transports
1112
- add DNS rebinding protection via Host header validation (recommended for localhost servers)
@@ -18,17 +19,53 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono
1819

1920
## Exports
2021

22+
- `mcp(server, options?)` — the one-call way to serve an MCP server as a Hono middleware
2123
- `createMcpHonoApp(options?)`
2224
- `hostHeaderValidation(allowedHostnames)`
2325
- `localhostHostValidation()`
2426

2527
## Usage
2628

27-
### Streamable HTTP endpoint (Hono)
29+
### Serve an MCP server in one call (recommended)
30+
31+
`mcp(server)` returns a single Hono middleware that serves your `McpServer` over
32+
Streamable HTTP. It wires JSON body parsing and localhost DNS-rebinding / origin
33+
protection for you, and connects the server on the first request — mount it on a
34+
route you already own:
35+
36+
```ts
37+
import { mcp } from '@modelcontextprotocol/hono';
38+
import { McpServer } from '@modelcontextprotocol/server';
39+
import { Hono } from 'hono';
40+
41+
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
42+
43+
const app = new Hono();
44+
app.all('/mcp', mcp(server));
45+
```
46+
47+
Binding to a public interface? Pass `allowedHosts` / `allowedOrigins` the same way
48+
as `createMcpHonoApp`, plus any transport options:
49+
50+
```ts
51+
app.all(
52+
'/mcp',
53+
mcp(server, {
54+
host: '0.0.0.0',
55+
allowedHosts: ['api.example.com'],
56+
transport: { enableJsonResponse: true }
57+
})
58+
);
59+
```
60+
61+
### Build the app yourself (`createMcpHonoApp`)
62+
63+
When you need full control over routing or want to wire the transport by hand,
64+
`createMcpHonoApp()` returns a `Hono` app with the same defaults pre-applied:
2865

2966
```ts
30-
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
3167
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
68+
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
3269

3370
const server = new McpServer({ name: 'my-server', version: '1.0.0' });
3471
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined });

packages/middleware/hono/src/hono.ts

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
import { isJsonContentType } from '@modelcontextprotocol/server';
2-
import type { Context } from 'hono';
1+
import type { McpServer, WebStandardStreamableHTTPServerTransportOptions } from '@modelcontextprotocol/server';
2+
import { isJsonContentType, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
3+
import type { Context, MiddlewareHandler } from 'hono';
34
import { Hono } from 'hono';
45

56
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation';
@@ -113,3 +114,56 @@ export function createMcpHonoApp(options: CreateMcpHonoAppOptions = {}): Hono {
113114

114115
return app;
115116
}
117+
118+
/**
119+
* Options for the {@link mcp} middleware.
120+
*
121+
* Extends {@link CreateMcpHonoAppOptions} (host/origin protection) with the
122+
* options forwarded to the underlying `WebStandardStreamableHTTPServerTransport`.
123+
*/
124+
export interface McpMiddlewareOptions extends CreateMcpHonoAppOptions {
125+
/**
126+
* Options forwarded to the `WebStandardStreamableHTTPServerTransport` that
127+
* serves the connected server. Defaults to stateless mode
128+
* (`sessionIdGenerator: undefined`).
129+
*/
130+
transport?: WebStandardStreamableHTTPServerTransportOptions;
131+
}
132+
133+
/**
134+
* Serves an `McpServer` as a single Hono middleware — the one-call way to mount
135+
* MCP on a route you already own.
136+
*
137+
* It wires the same defaults as {@link createMcpHonoApp} (JSON body parsing and
138+
* localhost DNS-rebinding / origin protection) in front of a
139+
* `WebStandardStreamableHTTPServerTransport`, connecting the server lazily on
140+
* the first request.
141+
*
142+
* @example
143+
* ```ts
144+
* const server = new McpServer({ name: 'my-server', version: '1.0.0' });
145+
* const app = new Hono();
146+
* app.all('/mcp', mcp(server));
147+
* ```
148+
*
149+
* @param server - The MCP server to serve.
150+
* @param options - Host/origin protection and transport options.
151+
* @returns A Hono `MiddlewareHandler` that serves the MCP endpoint.
152+
*/
153+
export function mcp(server: McpServer, options?: McpMiddlewareOptions): MiddlewareHandler {
154+
const { transport: transportOptions, ...appOptions } = options ?? {};
155+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, ...transportOptions });
156+
const app = createMcpHonoApp(appOptions);
157+
app.all('*', (c: Context<{ Variables: { parsedBody: unknown } }>) =>
158+
transport.handleRequest(c.req.raw, { parsedBody: c.get('parsedBody') })
159+
);
160+
return async c => {
161+
if (!server.isConnected()) {
162+
await server.connect(transport);
163+
}
164+
// The inner app is self-contained (its body parser and Host/Origin guards
165+
// read only the request), so forwarding just the raw request is enough —
166+
// and it avoids `c.executionCtx`, which throws off Cloudflare Workers.
167+
return await app.fetch(c.req.raw);
168+
};
169+
}

0 commit comments

Comments
 (0)