Skip to content

Commit c8d3eba

Browse files
committed
feat: fetch transport
1 parent cd7a055 commit c8d3eba

File tree

7 files changed

+4137
-401
lines changed

7 files changed

+4137
-401
lines changed

package-lock.json

Lines changed: 649 additions & 401 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@
116116
},
117117
"devDependencies": {
118118
"@cfworker/json-schema": "^4.1.1",
119+
"@hono/node-server": "^1.19.6",
120+
"hono": "^4.10.7",
119121
"@eslint/js": "^9.39.1",
120122
"@types/content-type": "^1.1.8",
121123
"@types/cors": "^2.8.17",
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* Example MCP Server using Hono.js with FetchStreamableHTTPServerTransport
3+
*
4+
* This example demonstrates how to use the experimental FetchStreamableHTTPServerTransport
5+
* with Hono.js to create an MCP server that uses Web Standard APIs.
6+
*
7+
* The FetchStreamableHTTPServerTransport uses Web Standard Request/Response objects,
8+
* making it compatible with various runtimes like Cloudflare Workers, Deno, Bun, etc.
9+
* This example runs on Node.js using @hono/node-server.
10+
*
11+
* To run this example:
12+
* npx tsx src/examples/server/honoFetchStreamableHttp.ts
13+
*
14+
* Then test with curl:
15+
* # Initialize
16+
* curl -X POST http://localhost:3000/mcp \
17+
* -H "Content-Type: application/json" \
18+
* -H "Accept: application/json, text/event-stream" \
19+
* -d '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2025-03-26","clientInfo":{"name":"test","version":"1.0"},"capabilities":{}},"id":1}'
20+
*
21+
* # List tools (use session ID from init response)
22+
* curl -X POST http://localhost:3000/mcp \
23+
* -H "Content-Type: application/json" \
24+
* -H "Accept: application/json, text/event-stream" \
25+
* -H "mcp-session-id: <session-id>" \
26+
* -d '{"jsonrpc":"2.0","method":"tools/list","params":{},"id":2}'
27+
*/
28+
29+
import { Hono } from 'hono';
30+
import { cors } from 'hono/cors';
31+
import { serve } from '@hono/node-server';
32+
import { McpServer } from '../../server/mcp.js';
33+
import { FetchStreamableHTTPServerTransport, type AuthenticatedRequest } from '../../experimental/fetch-streamable-http/index.js';
34+
import { CallToolResult, GetPromptResult, ReadResourceResult } from '../../types.js';
35+
import { z } from 'zod';
36+
37+
// Create the Hono app
38+
const app = new Hono();
39+
40+
// Store active transports by session ID for session management
41+
const transports = new Map<string, FetchStreamableHTTPServerTransport>();
42+
43+
/**
44+
* Creates and configures an MCP server with example tools, resources, and prompts
45+
*/
46+
function createMcpServer(): McpServer {
47+
const server = new McpServer(
48+
{
49+
name: 'hono-fetch-streamable-http-server',
50+
version: '1.0.0'
51+
},
52+
{ capabilities: { logging: {} } }
53+
);
54+
55+
// Register a simple tool using the new registerTool API
56+
server.registerTool(
57+
'greet',
58+
{
59+
description: 'Greets someone by name',
60+
inputSchema: {
61+
name: z.string().describe('The name to greet')
62+
}
63+
},
64+
async ({ name }): Promise<CallToolResult> => {
65+
return {
66+
content: [
67+
{
68+
type: 'text',
69+
text: `Hello, ${name}! Welcome to the Hono MCP server.`
70+
}
71+
]
72+
};
73+
}
74+
);
75+
76+
// Register a tool that demonstrates async operations
77+
server.registerTool(
78+
'calculate',
79+
{
80+
description: 'Performs a simple calculation',
81+
inputSchema: {
82+
operation: z.enum(['add', 'subtract', 'multiply', 'divide']).describe('The operation to perform'),
83+
a: z.number().describe('First operand'),
84+
b: z.number().describe('Second operand')
85+
}
86+
},
87+
async ({ operation, a, b }): Promise<CallToolResult> => {
88+
let result: number;
89+
switch (operation) {
90+
case 'add':
91+
result = a + b;
92+
break;
93+
case 'subtract':
94+
result = a - b;
95+
break;
96+
case 'multiply':
97+
result = a * b;
98+
break;
99+
case 'divide':
100+
if (b === 0) {
101+
return {
102+
content: [{ type: 'text', text: 'Error: Division by zero' }],
103+
isError: true
104+
};
105+
}
106+
result = a / b;
107+
break;
108+
}
109+
return {
110+
content: [
111+
{
112+
type: 'text',
113+
text: `${a} ${operation} ${b} = ${result}`
114+
}
115+
]
116+
};
117+
}
118+
);
119+
120+
// Register a tool that sends notifications (demonstrates SSE streaming)
121+
server.registerTool(
122+
'send-notifications',
123+
{
124+
description: 'Sends a series of notifications to demonstrate SSE streaming',
125+
inputSchema: {
126+
count: z.number().min(1).max(10).default(3).describe('Number of notifications to send'),
127+
interval: z.number().min(100).max(2000).default(500).describe('Interval between notifications in ms')
128+
}
129+
},
130+
async ({ count, interval }, extra): Promise<CallToolResult> => {
131+
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
132+
133+
for (let i = 1; i <= count; i++) {
134+
await server.sendLoggingMessage(
135+
{
136+
level: 'info',
137+
data: `Notification ${i} of ${count} at ${new Date().toISOString()}`
138+
},
139+
extra.sessionId
140+
);
141+
if (i < count) {
142+
await sleep(interval);
143+
}
144+
}
145+
146+
return {
147+
content: [
148+
{
149+
type: 'text',
150+
text: `Sent ${count} notifications with ${interval}ms interval`
151+
}
152+
]
153+
};
154+
}
155+
);
156+
157+
// Register a simple prompt using the new registerPrompt API
158+
server.registerPrompt(
159+
'code-review',
160+
{
161+
description: 'A prompt template for code review',
162+
argsSchema: {
163+
language: z.string().describe('Programming language'),
164+
code: z.string().describe('Code to review')
165+
}
166+
},
167+
async ({ language, code }): Promise<GetPromptResult> => {
168+
return {
169+
messages: [
170+
{
171+
role: 'user',
172+
content: {
173+
type: 'text',
174+
text: `Please review the following ${language} code and provide feedback on:
175+
1. Code quality and best practices
176+
2. Potential bugs or issues
177+
3. Performance considerations
178+
4. Suggestions for improvement
179+
180+
Code:
181+
\`\`\`${language}
182+
${code}
183+
\`\`\``
184+
}
185+
}
186+
]
187+
};
188+
}
189+
);
190+
191+
// Register a simple resource using the new registerResource API
192+
server.registerResource(
193+
'server-info',
194+
'mcp://server/info',
195+
{
196+
description: 'Information about this MCP server',
197+
mimeType: 'application/json'
198+
},
199+
async (): Promise<ReadResourceResult> => {
200+
return {
201+
contents: [
202+
{
203+
uri: 'mcp://server/info',
204+
mimeType: 'application/json',
205+
text: JSON.stringify(
206+
{
207+
name: 'hono-fetch-streamable-http-server',
208+
version: '1.0.0',
209+
runtime: 'Node.js',
210+
framework: 'Hono',
211+
transport: 'FetchStreamableHTTPServerTransport',
212+
timestamp: new Date().toISOString()
213+
},
214+
null,
215+
2
216+
)
217+
}
218+
]
219+
};
220+
}
221+
);
222+
223+
return server;
224+
}
225+
226+
// Configure CORS middleware for all routes
227+
app.use(
228+
'*',
229+
cors({
230+
origin: '*',
231+
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
232+
allowHeaders: ['Content-Type', 'Accept', 'mcp-session-id', 'last-event-id', 'mcp-protocol-version'],
233+
exposeHeaders: ['mcp-session-id']
234+
})
235+
);
236+
237+
app.all('/mcp', async c => {
238+
const request = c.req.raw as AuthenticatedRequest;
239+
240+
// Check for existing session
241+
const sessionId = request.headers.get('mcp-session-id');
242+
243+
if (sessionId && transports.has(sessionId)) {
244+
// Reuse existing transport for this session
245+
const transport = transports.get(sessionId)!;
246+
return transport.handleRequest(request);
247+
}
248+
249+
// For new sessions or initialization, create new transport and server
250+
const server = createMcpServer();
251+
const transport = new FetchStreamableHTTPServerTransport({
252+
sessionIdGenerator: () => crypto.randomUUID(),
253+
onsessioninitialized: sessionId => {
254+
// Store the transport for session reuse
255+
transports.set(sessionId, transport);
256+
console.log(`Session initialized: ${sessionId}`);
257+
},
258+
onsessionclosed: sessionId => {
259+
// Clean up when session closes
260+
transports.delete(sessionId);
261+
console.log(`Session closed: ${sessionId}`);
262+
}
263+
});
264+
265+
await server.connect(transport);
266+
267+
return transport.handleRequest(request);
268+
});
269+
270+
// Health check endpoint
271+
app.get('/health', c => {
272+
return c.json({
273+
status: 'healthy',
274+
activeSessions: transports.size,
275+
timestamp: new Date().toISOString()
276+
});
277+
});
278+
279+
// Start the server
280+
const PORT = 3000;
281+
console.log(`MCP server running at http://localhost:${PORT}/mcp`);
282+
283+
serve({
284+
fetch: app.fetch,
285+
port: PORT
286+
});
287+
288+
// Handle graceful shutdown
289+
process.on('SIGINT', async () => {
290+
console.log('\nShutting down server...');
291+
292+
// Close all active transports
293+
for (const [sessionId, transport] of transports) {
294+
console.log(`Closing session: ${sessionId}`);
295+
await transport.close();
296+
}
297+
transports.clear();
298+
299+
console.log('Server stopped.');
300+
process.exit(0);
301+
});

0 commit comments

Comments
 (0)