Skip to content

Commit 86b4590

Browse files
feat(hono): add mcp() middleware to serve MCP in one call
Adds mcp(factory, options?) — a single Hono MiddlewareHandler that serves MCP 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(() => new McpServer({ name: '...', version: '...' }))) It builds on createMcpHandler rather than a raw transport, so the endpoint serves the modern 2026-07-28 protocol and falls back to stateless 2025-era serving, with a fresh server per 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: real-client tests over both the legacy (2025) and modern (2026-07-28) paths, plus protection tests - README + docs/serving/hono.md: feature mcp() as the preferred path, keep createMcpHonoApp + createMcpHandler as the own-your-routing form
1 parent cc4b416 commit 86b4590

8 files changed

Lines changed: 342 additions & 32 deletions

File tree

docs/serving/hono.md

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,39 +9,71 @@ 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(factory)` is the shortest path: it returns a single Hono middleware that serves MCP over Streamable HTTP, with JSON body parsing and DNS rebinding protection already applied. It builds on `createMcpHandler`, so the endpoint serves the modern 2026-07-28 protocol and falls back to stateless 2025-era serving — a fresh `McpServer` from your factory backs every request. Mount it on a route you already own.
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 app = new Hono();
21+
app.all(
22+
'/mcp',
23+
mcp(() => {
24+
const server = new McpServer({ name: 'notes', version: '1.0.0' });
25+
server.registerTool(
26+
'add-note',
27+
{ description: 'Append a note', inputSchema: z.object({ text: z.string() }) },
28+
async ({ text }) => ({
29+
content: [{ type: 'text', text: `Saved: ${text}` }]
30+
})
31+
);
32+
return server;
33+
})
34+
);
35+
36+
export default app;
37+
```
38+
39+
`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`.
40+
41+
### Wire the handler and route yourself
42+
43+
When you want to own the routing — mount multiple endpoints, add your own middleware, or pass `authInfo` per request — drop down to `createMcpHandler` + `createMcpHonoApp` (which is what `mcp()` composes for you). `createMcpHandler` turns the same 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.
1344

1445
```ts source="../../examples/guides/serving/hono.examples.ts#createMcpHonoApp_mount"
1546
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
16-
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
47+
import { createMcpHandler } from '@modelcontextprotocol/server';
1748
import type { Context } from 'hono';
18-
import * as z from 'zod/v4';
1949

2050
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;
51+
const factoryServer = new McpServer({ name: 'notes', version: '1.0.0' });
52+
factoryServer.registerTool(
53+
'add-note',
54+
{ description: 'Append a note', inputSchema: z.object({ text: z.string() }) },
55+
async ({ text }) => ({
56+
content: [{ type: 'text', text: `Saved: ${text}` }]
57+
})
58+
);
59+
return factoryServer;
2660
});
2761

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

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.
66+
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.
3567

3668
::: tip
3769
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.
3870
:::
3971

4072
## Protect against DNS rebinding
4173

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.
74+
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.
4375

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

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

83115
## Recap
84116

85-
- One install line, one file: `createMcpHonoApp()` plus one `app.all('/mcp', …)` route over `createMcpHandler(factory).fetch`.
117+
- `mcp(factory)` is one Hono middleware — `app.all('/mcp', mcp(factory))` serves the whole endpoint (modern + legacy) with body parsing and Host/Origin validation applied.
118+
- Want to own the routing? Use `createMcpHonoApp()` plus one `app.all('/mcp', …)` route over `createMcpHandler(factory).fetch` — the same pieces `mcp()` composes.
86119
- Hono hands `c.req.raw` straight to `handler.fetch` — no Node adapter.
87-
- A fresh server instance from your factory serves every request.
88120
- The default `127.0.0.1` bind validates `Host` and `Origin`; pass `allowedHosts` when binding to `0.0.0.0`.
89121
- `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: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,24 +15,50 @@
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 app = new Hono();
25+
app.all(
26+
'/mcp',
27+
mcp(() => {
28+
const server = new McpServer({ name: 'notes', version: '1.0.0' });
29+
server.registerTool(
30+
'add-note',
31+
{ description: 'Append a note', inputSchema: z.object({ text: z.string() }) },
32+
async ({ text }) => ({
33+
content: [{ type: 'text', text: `Saved: ${text}` }]
34+
})
35+
);
36+
return server;
37+
})
38+
);
39+
40+
export default app;
41+
//#endregion mcp_mount
42+
1843
//#region createMcpHonoApp_mount
1944
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
20-
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
45+
import { createMcpHandler } from '@modelcontextprotocol/server';
2146
import type { Context } from 'hono';
22-
import * as z from 'zod/v4';
2347

2448
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;
49+
const factoryServer = new McpServer({ name: 'notes', version: '1.0.0' });
50+
factoryServer.registerTool(
51+
'add-note',
52+
{ description: 'Append a note', inputSchema: z.object({ text: z.string() }) },
53+
async ({ text }) => ({
54+
content: [{ type: 'text', text: `Saved: ${text}` }]
55+
})
56+
);
57+
return factoryServer;
3058
});
3159

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

3864
//#region createMcpHonoApp_allowedHosts

packages/middleware/hono/README.md

Lines changed: 42 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 MCP as a single Hono middleware with `mcp(factory)`
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,56 @@ npm install @modelcontextprotocol/server @modelcontextprotocol/hono hono
1819

1920
## Exports
2021

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

2527
## Usage
2628

27-
### Streamable HTTP endpoint (Hono)
29+
### Serve MCP in one call (recommended)
30+
31+
`mcp(factory)` returns a single Hono middleware that serves MCP over Streamable
32+
HTTP. It builds on `createMcpHandler`, so the endpoint serves the modern
33+
2026-07-28 protocol and falls back to stateless 2025-era serving — a fresh
34+
`McpServer` from your factory backs every request. JSON body parsing and
35+
localhost DNS-rebinding / origin protection are wired for you; mount it on a
36+
route you already own:
37+
38+
```ts
39+
import { mcp } from '@modelcontextprotocol/hono';
40+
import { McpServer } from '@modelcontextprotocol/server';
41+
import { Hono } from 'hono';
42+
43+
const app = new Hono();
44+
app.all(
45+
'/mcp',
46+
mcp(() => new McpServer({ name: 'my-server', version: '1.0.0' }))
47+
);
48+
```
49+
50+
Binding to a public interface? Pass `allowedHosts` / `allowedOrigins` the same way
51+
as `createMcpHonoApp`, plus any `createMcpHandler` options under `handler`:
52+
53+
```ts
54+
app.all(
55+
'/mcp',
56+
mcp(factory, {
57+
host: '0.0.0.0',
58+
allowedHosts: ['api.example.com'],
59+
handler: { legacy: 'reject' } // modern-only strict
60+
})
61+
);
62+
```
63+
64+
### Build the app yourself (`createMcpHonoApp`)
65+
66+
When you need full control over routing or want to wire the transport by hand,
67+
`createMcpHonoApp()` returns a `Hono` app with the same defaults pre-applied:
2868

2969
```ts
30-
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
3170
import { createMcpHonoApp } from '@modelcontextprotocol/hono';
71+
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server';
3272

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

packages/middleware/hono/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@
5555
"hono": "catalog:runtimeServerOnly"
5656
},
5757
"devDependencies": {
58+
"@modelcontextprotocol/client": "workspace:^",
5859
"@modelcontextprotocol/server": "workspace:^",
5960
"@modelcontextprotocol/tsconfig": "workspace:^",
6061
"@modelcontextprotocol/vitest-config": "workspace:^",

packages/middleware/hono/src/hono.ts

Lines changed: 48 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 { CreateMcpHandlerOptions, McpServerFactory } from '@modelcontextprotocol/server';
2+
import { createMcpHandler, isJsonContentType } 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,48 @@ 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 {@link createMcpHandler}.
123+
*/
124+
export interface McpMiddlewareOptions extends CreateMcpHonoAppOptions {
125+
/**
126+
* Options forwarded to {@link createMcpHandler} — e.g. `legacy: 'reject'`
127+
* for a modern-only strict endpoint.
128+
*/
129+
handler?: CreateMcpHandlerOptions;
130+
}
131+
132+
/**
133+
* Serves MCP as a single Hono middleware — the one-call way to mount MCP on a
134+
* route you already own.
135+
*
136+
* It wires the same defaults as {@link createMcpHonoApp} (JSON body parsing and
137+
* localhost DNS-rebinding / origin protection) in front of a
138+
* {@link createMcpHandler}, so the endpoint serves the modern 2026-07-28
139+
* protocol and falls back to stateless 2025-era serving. A fresh server is
140+
* built from your factory for each request.
141+
*
142+
* @example
143+
* ```ts
144+
* const app = new Hono();
145+
* app.all('/mcp', mcp(() => new McpServer({ name: 'my-server', version: '1.0.0' })));
146+
* ```
147+
*
148+
* @param factory - Builds a fresh MCP server per request.
149+
* @param options - Host/origin protection and handler options.
150+
* @returns A Hono `MiddlewareHandler` that serves the MCP endpoint.
151+
*/
152+
export function mcp(factory: McpServerFactory, options?: McpMiddlewareOptions): MiddlewareHandler {
153+
const { handler: handlerOptions, ...appOptions } = options ?? {};
154+
const handler = createMcpHandler(factory, handlerOptions);
155+
const app = createMcpHonoApp(appOptions);
156+
app.all('*', (c: Context<{ Variables: { parsedBody: unknown } }>) => handler.fetch(c.req.raw, { parsedBody: c.get('parsedBody') }));
157+
// The inner app is self-contained (its body parser and Host/Origin guards
158+
// read only the request), so forwarding just the raw request is enough —
159+
// and it avoids `c.executionCtx`, which throws off Cloudflare Workers.
160+
return async c => app.fetch(c.req.raw);
161+
}

0 commit comments

Comments
 (0)