Skip to content

Commit 8d8cf7e

Browse files
feat(@mcp/hono): mcpHonoHandler mount helper (handleHttp-backed)
1 parent 2358111 commit 8d8cf7e

2 files changed

Lines changed: 91 additions & 2 deletions

File tree

packages/middleware/hono/src/hono.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,44 @@
1-
import type { Context } from 'hono';
1+
import type { AuthInfo, Dispatchable, HandleHttpOptions } from '@modelcontextprotocol/server';
2+
import { handleHttp } from '@modelcontextprotocol/server';
3+
import type { Context, Handler } from 'hono';
24
import { Hono } from 'hono';
35

46
import { hostHeaderValidation, localhostHostValidation } from './middleware/hostHeaderValidation.js';
57

8+
/**
9+
* Hono context variables {@linkcode mcpHonoHandler} reads. Upstream middleware
10+
* may set `parsedBody` ({@linkcode createMcpHonoApp} does this for JSON requests)
11+
* and `authInfo` (e.g. a bearer-token verifier) before this handler runs.
12+
*/
13+
export type McpHonoVariables = { parsedBody?: unknown; authInfo?: AuthInfo };
14+
15+
/**
16+
* Mounts an `McpServer` (or any `Protocol` subclass) as a Hono `Handler`.
17+
* Each request flows through `mcp.dispatch()` directly via {@linkcode handleHttp};
18+
* `mcp.connect()` and a transport instance are not used.
19+
*
20+
* Reads `c.get('parsedBody')` (set by {@linkcode createMcpHonoApp}'s JSON middleware)
21+
* and `c.get('authInfo')` (set by an upstream auth middleware) when present.
22+
*
23+
* ```ts
24+
* import { McpServer, SessionCompat } from '@modelcontextprotocol/server';
25+
* import { createMcpHonoApp, mcpHonoHandler } from '@modelcontextprotocol/hono';
26+
*
27+
* const mcp = new McpServer({ name: 's', version: '1.0.0' });
28+
* const app = createMcpHonoApp();
29+
* app.all('/mcp', mcpHonoHandler(mcp, { session: new SessionCompat() }));
30+
* ```
31+
*/
32+
export function mcpHonoHandler(mcp: Dispatchable, options?: HandleHttpOptions): Handler<{ Variables: McpHonoVariables }> {
33+
const handler = handleHttp(mcp, options);
34+
return c => {
35+
const parsedBody = c.get('parsedBody');
36+
const authInfo = c.get('authInfo');
37+
const extra = authInfo !== undefined || parsedBody !== undefined ? { authInfo, parsedBody } : undefined;
38+
return handler(c.req.raw, extra);
39+
};
40+
}
41+
642
/**
743
* Options for creating an MCP Hono application.
844
*/

packages/middleware/hono/test/hono.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@ import type { Context } from 'hono';
22
import { Hono } from 'hono';
33
import { vi } from 'vitest';
44

5-
import { createMcpHonoApp } from '../src/hono.js';
5+
import { McpServer, SessionCompat } from '@modelcontextprotocol/server';
6+
7+
import { createMcpHonoApp, mcpHonoHandler } from '../src/hono.js';
68
import { hostHeaderValidation } from '../src/middleware/hostHeaderValidation.js';
79

10+
const INIT_MESSAGE = {
11+
jsonrpc: '2.0',
12+
method: 'initialize',
13+
params: { clientInfo: { name: 'test-client', version: '1.0' }, protocolVersion: '2025-11-25', capabilities: {} },
14+
id: 'init-1'
15+
};
16+
817
describe('@modelcontextprotocol/hono', () => {
918
test('hostHeaderValidation blocks invalid Host and allows valid Host', async () => {
1019
const app = new Hono();
@@ -106,4 +115,48 @@ describe('@modelcontextprotocol/hono', () => {
106115
expect(res.status).toBe(200);
107116
expect(await res.json()).toEqual({ preset: true });
108117
});
118+
119+
describe('mcpHonoHandler', () => {
120+
function makeApp(options?: Parameters<typeof mcpHonoHandler>[1]) {
121+
const mcp = new McpServer({ name: 'test-server', version: '1.0.0' });
122+
const app = createMcpHonoApp({ host: '0.0.0.0', allowedHosts: ['localhost'] });
123+
app.all('/mcp', mcpHonoHandler(mcp, { enableJsonResponse: true, ...options }));
124+
return app;
125+
}
126+
127+
async function postJson(app: Hono, body: unknown, headers: Record<string, string> = {}): Promise<Response> {
128+
return app.request('http://localhost/mcp', {
129+
method: 'POST',
130+
headers: {
131+
Host: 'localhost',
132+
'Content-Type': 'application/json',
133+
Accept: 'application/json, text/event-stream',
134+
...headers
135+
},
136+
body: JSON.stringify(body)
137+
});
138+
}
139+
140+
test('serves initialize via mcp.dispatch() (stateless, no transport class)', async () => {
141+
const res = await postJson(makeApp(), INIT_MESSAGE);
142+
expect(res.status).toBe(200);
143+
const body = (await res.json()) as { result: { serverInfo: { name: string } } };
144+
expect(body.result).toMatchObject({ serverInfo: { name: 'test-server' } });
145+
expect(res.headers.get('mcp-session-id')).toBeNull();
146+
});
147+
148+
test('serves session lifecycle via SessionCompat', async () => {
149+
const app = makeApp({ session: new SessionCompat() });
150+
const initRes = await postJson(app, INIT_MESSAGE);
151+
const sid = initRes.headers.get('mcp-session-id');
152+
expect(sid).toBeTruthy();
153+
const pingRes = await postJson(
154+
app,
155+
{ jsonrpc: '2.0', method: 'ping', params: {}, id: 'p-1' },
156+
{ 'mcp-session-id': sid as string, 'mcp-protocol-version': '2025-11-25' }
157+
);
158+
expect(pingRes.status).toBe(200);
159+
expect(await pingRes.json()).toMatchObject({ jsonrpc: '2.0', id: 'p-1', result: {} });
160+
});
161+
});
109162
});

0 commit comments

Comments
 (0)