modality-mcp-kit 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/FastHonoMcp.js +345 -0
- package/dist/McpSessionManager.js +46 -0
- package/dist/index.js +1 -0
- package/dist/sse-wrapper.js +206 -0
- package/dist/types/FastHonoMcp.d.ts +50 -0
- package/dist/types/McpSessionManager.d.ts +31 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/sse-wrapper.d.ts +91 -0
- package/package.json +7 -4
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Middleware for Hono Integration
|
|
3
|
+
*
|
|
4
|
+
* This middleware integrates the Model Context Protocol (MCP) into a Hono web server.
|
|
5
|
+
* It delegates all MCP protocol handling to FastMCP's built-in capabilities, avoiding
|
|
6
|
+
* any custom protocol implementation.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - Hono serves as the primary and only HTTP server (port 8800)
|
|
10
|
+
* - FastMCP handles all MCP protocol logic, schema conversion, and responses
|
|
11
|
+
* - Middleware acts as a simple proxy to FastMCP's stateless request handling
|
|
12
|
+
* - No manual JSON-RPC method handling - FastMCP does everything
|
|
13
|
+
*
|
|
14
|
+
* Key Principle:
|
|
15
|
+
* - Always reuse FastMCP's built-in functions
|
|
16
|
+
* - Never implement custom MCP protocol logic
|
|
17
|
+
* - Let FastMCP handle initialize, tools/list, tools/call, etc.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* app.use('/mcp', mcpMiddleware);
|
|
21
|
+
* app.use('/mcp/*', mcpMiddleware);
|
|
22
|
+
*
|
|
23
|
+
* https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema
|
|
24
|
+
* https://modelcontextprotocol.io/specification/2025-11-25/schema
|
|
25
|
+
*/
|
|
26
|
+
import { ModalityFastMCP } from "modality-mcp-kit";
|
|
27
|
+
import { JSONRPCManager, getLoggerInstance, } from "modality-kit";
|
|
28
|
+
import { sseNotification, sseError, SSE_HEADERS, createSSEStream, } from "./sse-wrapper.js";
|
|
29
|
+
import { McpSessionManager } from "./McpSessionManager.js";
|
|
30
|
+
// Initialize FastMCP instance for internal use (NO SERVER)
|
|
31
|
+
export class FastHonoMcp extends ModalityFastMCP {
|
|
32
|
+
logger;
|
|
33
|
+
config;
|
|
34
|
+
sessions = new McpSessionManager();
|
|
35
|
+
currentSessionId = "";
|
|
36
|
+
constructor(config) {
|
|
37
|
+
super();
|
|
38
|
+
this.config = config;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Disconnect and cleanup a session
|
|
42
|
+
*/
|
|
43
|
+
disconnect(sessionId) {
|
|
44
|
+
const targetSession = sessionId || this.currentSessionId;
|
|
45
|
+
const disconnected = this.sessions.disconnect(targetSession);
|
|
46
|
+
if (disconnected) {
|
|
47
|
+
this.logger?.info(`Session disconnected: ${targetSession}`);
|
|
48
|
+
}
|
|
49
|
+
return disconnected;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Ensure session exists, create if needed
|
|
53
|
+
*/
|
|
54
|
+
ensureSession() {
|
|
55
|
+
if (!this.sessions.has(this.currentSessionId)) {
|
|
56
|
+
const session = this.sessions.create();
|
|
57
|
+
this.currentSessionId = session.id;
|
|
58
|
+
this.logger?.info(`Session connected: ${this.currentSessionId}`);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.sessions.touch(this.currentSessionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
handler() {
|
|
65
|
+
return async (c, next) => {
|
|
66
|
+
this.logger =
|
|
67
|
+
this.logger || getLoggerInstance("HonoMcpMiddleware", "debug");
|
|
68
|
+
const url = new URL(c.req.url);
|
|
69
|
+
// Only handle MCP routes
|
|
70
|
+
if (!url.pathname.startsWith("/mcp")) {
|
|
71
|
+
return next();
|
|
72
|
+
}
|
|
73
|
+
try {
|
|
74
|
+
// Handle DELETE for session disconnect
|
|
75
|
+
if (c.req.method === "DELETE" && url.pathname === "/mcp") {
|
|
76
|
+
const requestSessionId = c.req.header("mcp-session-id");
|
|
77
|
+
// Validate session ID matches
|
|
78
|
+
if (requestSessionId && requestSessionId !== this.currentSessionId) {
|
|
79
|
+
return c.json({ error: "Session ID mismatch" }, 400);
|
|
80
|
+
}
|
|
81
|
+
const disconnected = this.disconnect(requestSessionId || this.currentSessionId);
|
|
82
|
+
if (disconnected) {
|
|
83
|
+
return c.body(null, 204);
|
|
84
|
+
}
|
|
85
|
+
return c.json({ error: "Session not found" }, 404);
|
|
86
|
+
}
|
|
87
|
+
// Handle main MCP endpoint
|
|
88
|
+
if (c.req.method === "POST" && url.pathname === "/mcp") {
|
|
89
|
+
// Ensure session exists (creates new one if disconnected)
|
|
90
|
+
this.ensureSession();
|
|
91
|
+
const headers = {
|
|
92
|
+
"mcp-session-id": this.currentSessionId,
|
|
93
|
+
};
|
|
94
|
+
const bodyText = await c.req.text();
|
|
95
|
+
this.logger.info("MCP Middleware Received Body", { bodyText });
|
|
96
|
+
// Handle notifications/initialized locally (no response needed for notifications)
|
|
97
|
+
try {
|
|
98
|
+
const requestData = JSON.parse(bodyText);
|
|
99
|
+
if (requestData?.method === "notifications/initialized") {
|
|
100
|
+
return c.text(sseNotification(), 200, {
|
|
101
|
+
...SSE_HEADERS,
|
|
102
|
+
...headers,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Not valid JSON, continue with normal processing
|
|
108
|
+
}
|
|
109
|
+
// Use streaming SSE response
|
|
110
|
+
return createSSEStream(async (writer) => {
|
|
111
|
+
const result = await createJsonRpcManager(this).validateMessage(bodyText);
|
|
112
|
+
writer.send(result);
|
|
113
|
+
}, headers);
|
|
114
|
+
}
|
|
115
|
+
return c.json({ error: "MCP endpoint not implemented" }, 501);
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
this.logger.error("MCP Middleware Error", error);
|
|
119
|
+
const message = error instanceof Error ? error.message : "Internal error";
|
|
120
|
+
return c.text(sseError(null, -32603, message), 500, SSE_HEADERS);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
initHono(app, path = "mcp") {
|
|
125
|
+
const middlewareHandler = this.handler();
|
|
126
|
+
app.use(`/${path}`, middlewareHandler);
|
|
127
|
+
app.use(`/${path}/*`, middlewareHandler);
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
class HonoJSONRPCManager extends JSONRPCManager {
|
|
132
|
+
async sendMessage(message) {
|
|
133
|
+
return message;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
function createJsonRpcManager(middleware) {
|
|
137
|
+
const mcpTools = middleware.getTools();
|
|
138
|
+
const mcpPrompts = middleware.getPrompts();
|
|
139
|
+
const jsonrpc = new HonoJSONRPCManager();
|
|
140
|
+
jsonrpc.registerMethod("initialize", {
|
|
141
|
+
handler(params) {
|
|
142
|
+
// Validate required request parameters
|
|
143
|
+
if (!params.capabilities) {
|
|
144
|
+
throw new Error("Missing required parameter: capabilities");
|
|
145
|
+
}
|
|
146
|
+
if (!params.clientInfo) {
|
|
147
|
+
throw new Error("Missing required parameter: clientInfo");
|
|
148
|
+
}
|
|
149
|
+
if (!params.protocolVersion) {
|
|
150
|
+
throw new Error("Missing required parameter: protocolVersion");
|
|
151
|
+
}
|
|
152
|
+
// Return valid InitializeResult
|
|
153
|
+
return {
|
|
154
|
+
protocolVersion: "2025-11-25",
|
|
155
|
+
capabilities: {
|
|
156
|
+
tools: { listChanged: true },
|
|
157
|
+
...(mcpPrompts.length > 0 && { prompts: { listChanged: true } }),
|
|
158
|
+
completions: {},
|
|
159
|
+
logging: {},
|
|
160
|
+
},
|
|
161
|
+
serverInfo: {
|
|
162
|
+
name: middleware.config.name,
|
|
163
|
+
version: middleware.config.version,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
jsonrpc.registerMethod("tools/list", {
|
|
169
|
+
async handler() {
|
|
170
|
+
const { toJsonSchema } = await import("xsschema");
|
|
171
|
+
const tools = await Promise.all(mcpTools.map(async (tool) => ({
|
|
172
|
+
name: tool.name,
|
|
173
|
+
description: tool.description,
|
|
174
|
+
inputSchema: await toJsonSchema(tool.parameters), // Simplified for example
|
|
175
|
+
})));
|
|
176
|
+
return {
|
|
177
|
+
tools,
|
|
178
|
+
};
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
jsonrpc.registerMethod("tools/call", {
|
|
182
|
+
async handler(params) {
|
|
183
|
+
const { ERROR_METHOD_NOT_FOUND } = await import("modality-kit");
|
|
184
|
+
const { name, arguments: args } = params;
|
|
185
|
+
const tool = mcpTools.find((t) => t.name === name);
|
|
186
|
+
if (!tool) {
|
|
187
|
+
throw new ERROR_METHOD_NOT_FOUND(`Tool not found: ${name}`);
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
content: [
|
|
191
|
+
{
|
|
192
|
+
text: await tool.execute(args),
|
|
193
|
+
type: "text",
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
});
|
|
199
|
+
jsonrpc.registerMethod("prompts/list", {
|
|
200
|
+
async handler() {
|
|
201
|
+
const prompts = mcpPrompts.map((prompt) => ({
|
|
202
|
+
name: prompt.name,
|
|
203
|
+
...(prompt.description && { description: prompt.description }),
|
|
204
|
+
...(prompt.title && { title: prompt.title }),
|
|
205
|
+
...(prompt.arguments && {
|
|
206
|
+
arguments: prompt.arguments.map((arg) => ({
|
|
207
|
+
name: arg.name,
|
|
208
|
+
...(arg.description && { description: arg.description }),
|
|
209
|
+
...(arg.required !== undefined && { required: arg.required }),
|
|
210
|
+
...(arg.title && { title: arg.title }),
|
|
211
|
+
...(arg.enum && { enum: Array.from(arg.enum) }),
|
|
212
|
+
})),
|
|
213
|
+
}),
|
|
214
|
+
}));
|
|
215
|
+
return { prompts };
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
jsonrpc.registerMethod("prompts/get", {
|
|
219
|
+
async handler(params) {
|
|
220
|
+
const { ERROR_METHOD_NOT_FOUND } = await import("modality-kit");
|
|
221
|
+
const { name, arguments: args } = params;
|
|
222
|
+
const prompt = mcpPrompts.find((p) => p.name === name);
|
|
223
|
+
if (!prompt) {
|
|
224
|
+
throw new ERROR_METHOD_NOT_FOUND(`Prompt not found: ${name}`);
|
|
225
|
+
}
|
|
226
|
+
const text = await prompt.load(args || {});
|
|
227
|
+
return {
|
|
228
|
+
...(prompt.description && { description: prompt.description }),
|
|
229
|
+
messages: [
|
|
230
|
+
{
|
|
231
|
+
role: "user",
|
|
232
|
+
content: {
|
|
233
|
+
type: "text",
|
|
234
|
+
text,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
};
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
jsonrpc.registerMethod("completion/complete", {
|
|
242
|
+
async handler(params) {
|
|
243
|
+
const { ref, argument } = params;
|
|
244
|
+
// Only handle prompt references
|
|
245
|
+
if (ref?.type !== "ref/prompt") {
|
|
246
|
+
return { completion: { values: [], total: 0 } };
|
|
247
|
+
}
|
|
248
|
+
const prompt = mcpPrompts.find((p) => p.name === ref.name);
|
|
249
|
+
if (!prompt || !prompt.arguments) {
|
|
250
|
+
return { completion: { values: [], total: 0 } };
|
|
251
|
+
}
|
|
252
|
+
const arg = prompt.arguments.find((a) => a.name === argument.name);
|
|
253
|
+
if (!arg || !arg.enum) {
|
|
254
|
+
return { completion: { values: [], total: 0 } };
|
|
255
|
+
}
|
|
256
|
+
const enumValues = Array.from(arg.enum);
|
|
257
|
+
const inputValue = (argument.value || "").trim().toLowerCase();
|
|
258
|
+
const completionLimit = prompt.completionLimit ?? 10;
|
|
259
|
+
// Empty input: return first N items
|
|
260
|
+
if (!inputValue) {
|
|
261
|
+
const limit = Math.min(completionLimit, enumValues.length);
|
|
262
|
+
return {
|
|
263
|
+
completion: {
|
|
264
|
+
values: enumValues.slice(0, limit),
|
|
265
|
+
total: enumValues.length,
|
|
266
|
+
hasMore: enumValues.length > limit,
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
// Filter enum values by the partial input
|
|
271
|
+
const matchingValues = enumValues.filter((v) => v.toLowerCase().startsWith(inputValue));
|
|
272
|
+
return {
|
|
273
|
+
completion: {
|
|
274
|
+
values: matchingValues.slice(0, 100),
|
|
275
|
+
total: matchingValues.length,
|
|
276
|
+
hasMore: matchingValues.length > 100,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
// Notification - no response needed (return empty result)
|
|
282
|
+
jsonrpc.registerMethod("notifications/initialized", {
|
|
283
|
+
handler() {
|
|
284
|
+
return {};
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
// notifications/cancelled - client requests cancellation of in-flight request
|
|
288
|
+
jsonrpc.registerMethod("notifications/cancelled", {
|
|
289
|
+
handler(params) {
|
|
290
|
+
const { requestId, reason } = params;
|
|
291
|
+
middleware.logger.info(`Request cancelled: ${requestId}`, { reason });
|
|
292
|
+
// Note: Stateless HTTP cannot cancel in-flight requests
|
|
293
|
+
// This handler acknowledges the notification for spec compliance
|
|
294
|
+
return {};
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
// ping - keep-alive check per MCP spec
|
|
298
|
+
jsonrpc.registerMethod("ping", {
|
|
299
|
+
handler() {
|
|
300
|
+
return {};
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
// logging/setLevel - validate and return empty result per MCP spec
|
|
304
|
+
jsonrpc.registerMethod("logging/setLevel", {
|
|
305
|
+
handler(params) {
|
|
306
|
+
const VALID_LOG_LEVELS = [
|
|
307
|
+
"debug",
|
|
308
|
+
"info",
|
|
309
|
+
"notice",
|
|
310
|
+
"warning",
|
|
311
|
+
"error",
|
|
312
|
+
"critical",
|
|
313
|
+
"alert",
|
|
314
|
+
"emergency",
|
|
315
|
+
];
|
|
316
|
+
// Map MCP log levels to modality logger levels
|
|
317
|
+
const LOG_LEVEL_MAP = {
|
|
318
|
+
debug: "debug",
|
|
319
|
+
info: "info",
|
|
320
|
+
notice: "info",
|
|
321
|
+
warning: "warn",
|
|
322
|
+
error: "error",
|
|
323
|
+
critical: "error",
|
|
324
|
+
alert: "error",
|
|
325
|
+
emergency: "error",
|
|
326
|
+
};
|
|
327
|
+
const { level } = params;
|
|
328
|
+
// Validate level parameter exists
|
|
329
|
+
if (!level) {
|
|
330
|
+
throw new Error("Missing required parameter: level");
|
|
331
|
+
}
|
|
332
|
+
// Validate level is a valid LoggingLevel
|
|
333
|
+
if (!VALID_LOG_LEVELS.includes(level)) {
|
|
334
|
+
throw new Error(`Invalid log level: ${level}. Must be one of: ${VALID_LOG_LEVELS.join(", ")}`);
|
|
335
|
+
}
|
|
336
|
+
// Map and apply log level to modality logger
|
|
337
|
+
const modalityLogLevel = LOG_LEVEL_MAP[level];
|
|
338
|
+
middleware.logger.setLogLevel(modalityLogLevel);
|
|
339
|
+
middleware.logger.info(`Log level set to: ${level}`);
|
|
340
|
+
// Return empty result per MCP spec
|
|
341
|
+
return {};
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
return jsonrpc;
|
|
345
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Session Manager - Simple session lifecycle management
|
|
3
|
+
*/
|
|
4
|
+
export class McpSessionManager {
|
|
5
|
+
sessions = new Map();
|
|
6
|
+
/**
|
|
7
|
+
* Create a new session
|
|
8
|
+
*/
|
|
9
|
+
create() {
|
|
10
|
+
const now = new Date();
|
|
11
|
+
const session = {
|
|
12
|
+
id: crypto.randomUUID(),
|
|
13
|
+
createdAt: now,
|
|
14
|
+
lastActivity: now,
|
|
15
|
+
};
|
|
16
|
+
this.sessions.set(session.id, session);
|
|
17
|
+
return session;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Get session by ID
|
|
21
|
+
*/
|
|
22
|
+
get(sessionId) {
|
|
23
|
+
return this.sessions.get(sessionId);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Update last activity timestamp
|
|
27
|
+
*/
|
|
28
|
+
touch(sessionId) {
|
|
29
|
+
const session = this.sessions.get(sessionId);
|
|
30
|
+
if (session) {
|
|
31
|
+
session.lastActivity = new Date();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Remove a session
|
|
36
|
+
*/
|
|
37
|
+
disconnect(sessionId) {
|
|
38
|
+
return this.sessions.delete(sessionId);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Check if session exists
|
|
42
|
+
*/
|
|
43
|
+
has(sessionId) {
|
|
44
|
+
return this.sessions.has(sessionId);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) Wrapper Utility
|
|
3
|
+
*
|
|
4
|
+
* Wraps JSON-RPC responses in SSE format as per MCP specification.
|
|
5
|
+
* SSE format:
|
|
6
|
+
* event: message
|
|
7
|
+
* id: <unique-id>
|
|
8
|
+
* data: <json-rpc-response>
|
|
9
|
+
*
|
|
10
|
+
* Supports both single-response and streaming modes.
|
|
11
|
+
* Uses modality-kit for JSON-RPC types and error codes.
|
|
12
|
+
*/
|
|
13
|
+
// ============================================
|
|
14
|
+
// SSE HEADERS
|
|
15
|
+
// ============================================
|
|
16
|
+
/**
|
|
17
|
+
* Standard SSE response headers for MCP
|
|
18
|
+
*/
|
|
19
|
+
export const SSE_HEADERS = {
|
|
20
|
+
"Content-Type": "text/event-stream",
|
|
21
|
+
"Cache-Control": "no-cache",
|
|
22
|
+
"Connection": "keep-alive",
|
|
23
|
+
};
|
|
24
|
+
// ============================================
|
|
25
|
+
// CORE SSE FUNCTIONS
|
|
26
|
+
// ============================================
|
|
27
|
+
/**
|
|
28
|
+
* Generate a unique ID for SSE messages
|
|
29
|
+
*/
|
|
30
|
+
function generateSSEId() {
|
|
31
|
+
const timestamp = Date.now();
|
|
32
|
+
const random = Math.random().toString(36).substring(2, 9);
|
|
33
|
+
return `${timestamp}_${random}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Wrap a JSON-RPC response in SSE format
|
|
37
|
+
*/
|
|
38
|
+
export function wrapSSE(jsonrpcResponse) {
|
|
39
|
+
return {
|
|
40
|
+
event: "message",
|
|
41
|
+
id: generateSSEId(),
|
|
42
|
+
data: jsonrpcResponse,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Format SSE message as text for transmission
|
|
47
|
+
*/
|
|
48
|
+
export function formatSSE(sseMessage) {
|
|
49
|
+
return `event: ${sseMessage.event}\nid: ${sseMessage.id}\ndata: ${JSON.stringify(sseMessage.data)}\n\n`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Wrap and format JSON-RPC response in one step
|
|
53
|
+
*/
|
|
54
|
+
export function wrapAndFormatSSE(jsonrpcResponse) {
|
|
55
|
+
const sseMessage = wrapSSE(jsonrpcResponse);
|
|
56
|
+
return formatSSE(sseMessage);
|
|
57
|
+
}
|
|
58
|
+
// ============================================
|
|
59
|
+
// CONVENIENCE SSE FORMATTERS
|
|
60
|
+
// ============================================
|
|
61
|
+
/**
|
|
62
|
+
* Create SSE-formatted success response
|
|
63
|
+
*/
|
|
64
|
+
export function sseSuccess(id, result = {}) {
|
|
65
|
+
return wrapAndFormatSSE({
|
|
66
|
+
jsonrpc: "2.0",
|
|
67
|
+
id,
|
|
68
|
+
result,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Create SSE-formatted error response
|
|
73
|
+
*/
|
|
74
|
+
export function sseError(id, code, message, data) {
|
|
75
|
+
return wrapAndFormatSSE({
|
|
76
|
+
jsonrpc: "2.0",
|
|
77
|
+
id,
|
|
78
|
+
error: { code, message, ...(data !== undefined && { data }) },
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Create SSE-formatted notification response (id: null, empty result)
|
|
83
|
+
*/
|
|
84
|
+
export function sseNotification() {
|
|
85
|
+
return wrapAndFormatSSE({
|
|
86
|
+
jsonrpc: "2.0",
|
|
87
|
+
id: null,
|
|
88
|
+
result: {},
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
// ============================================
|
|
92
|
+
// STREAMING SSE SUPPORT
|
|
93
|
+
// ============================================
|
|
94
|
+
/**
|
|
95
|
+
* SSE Stream writer for true streaming support
|
|
96
|
+
*/
|
|
97
|
+
export class SSEStreamWriter {
|
|
98
|
+
controller = null;
|
|
99
|
+
encoder = new TextEncoder();
|
|
100
|
+
closed = false;
|
|
101
|
+
/**
|
|
102
|
+
* Create a ReadableStream for SSE responses
|
|
103
|
+
*/
|
|
104
|
+
createStream() {
|
|
105
|
+
return new ReadableStream({
|
|
106
|
+
start: (controller) => {
|
|
107
|
+
this.controller = controller;
|
|
108
|
+
},
|
|
109
|
+
cancel: () => {
|
|
110
|
+
this.closed = true;
|
|
111
|
+
this.controller = null;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Send a JSON-RPC response as SSE message
|
|
117
|
+
*/
|
|
118
|
+
send(response) {
|
|
119
|
+
if (this.closed || !this.controller)
|
|
120
|
+
return;
|
|
121
|
+
const formatted = wrapAndFormatSSE(response);
|
|
122
|
+
this.controller.enqueue(this.encoder.encode(formatted));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Send a progress notification
|
|
126
|
+
*/
|
|
127
|
+
sendProgress(progressToken, progress, total) {
|
|
128
|
+
if (this.closed || !this.controller)
|
|
129
|
+
return;
|
|
130
|
+
const notification = {
|
|
131
|
+
jsonrpc: "2.0",
|
|
132
|
+
id: null,
|
|
133
|
+
result: {
|
|
134
|
+
method: "notifications/progress",
|
|
135
|
+
params: {
|
|
136
|
+
progressToken,
|
|
137
|
+
progress,
|
|
138
|
+
...(total !== undefined && { total }),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
const formatted = wrapAndFormatSSE(notification);
|
|
143
|
+
this.controller.enqueue(this.encoder.encode(formatted));
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Send a keep-alive ping (SSE comment)
|
|
147
|
+
*/
|
|
148
|
+
ping() {
|
|
149
|
+
if (this.closed || !this.controller)
|
|
150
|
+
return;
|
|
151
|
+
this.controller.enqueue(this.encoder.encode(": ping\n\n"));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Send raw SSE event
|
|
155
|
+
*/
|
|
156
|
+
sendEvent(event, data, id) {
|
|
157
|
+
if (this.closed || !this.controller)
|
|
158
|
+
return;
|
|
159
|
+
const sseId = id || generateSSEId();
|
|
160
|
+
const formatted = `event: ${event}\nid: ${sseId}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
161
|
+
this.controller.enqueue(this.encoder.encode(formatted));
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Close the stream
|
|
165
|
+
*/
|
|
166
|
+
close() {
|
|
167
|
+
if (this.closed || !this.controller)
|
|
168
|
+
return;
|
|
169
|
+
this.closed = true;
|
|
170
|
+
this.controller.close();
|
|
171
|
+
this.controller = null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Check if stream is still open
|
|
175
|
+
*/
|
|
176
|
+
get isOpen() {
|
|
177
|
+
return !this.closed && this.controller !== null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Create a streaming SSE response
|
|
182
|
+
*/
|
|
183
|
+
export function createSSEStream(handler, headers) {
|
|
184
|
+
const writer = new SSEStreamWriter();
|
|
185
|
+
const stream = writer.createStream();
|
|
186
|
+
// Execute handler asynchronously
|
|
187
|
+
handler(writer)
|
|
188
|
+
.catch((error) => {
|
|
189
|
+
if (writer.isOpen) {
|
|
190
|
+
writer.send({
|
|
191
|
+
jsonrpc: "2.0",
|
|
192
|
+
id: null,
|
|
193
|
+
error: {
|
|
194
|
+
code: -32603,
|
|
195
|
+
message: error instanceof Error ? error.message : "Internal error",
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
})
|
|
200
|
+
.finally(() => {
|
|
201
|
+
writer.close();
|
|
202
|
+
});
|
|
203
|
+
return new Response(stream, {
|
|
204
|
+
headers: { ...SSE_HEADERS, ...headers },
|
|
205
|
+
});
|
|
206
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Middleware for Hono Integration
|
|
3
|
+
*
|
|
4
|
+
* This middleware integrates the Model Context Protocol (MCP) into a Hono web server.
|
|
5
|
+
* It delegates all MCP protocol handling to FastMCP's built-in capabilities, avoiding
|
|
6
|
+
* any custom protocol implementation.
|
|
7
|
+
*
|
|
8
|
+
* Architecture:
|
|
9
|
+
* - Hono serves as the primary and only HTTP server (port 8800)
|
|
10
|
+
* - FastMCP handles all MCP protocol logic, schema conversion, and responses
|
|
11
|
+
* - Middleware acts as a simple proxy to FastMCP's stateless request handling
|
|
12
|
+
* - No manual JSON-RPC method handling - FastMCP does everything
|
|
13
|
+
*
|
|
14
|
+
* Key Principle:
|
|
15
|
+
* - Always reuse FastMCP's built-in functions
|
|
16
|
+
* - Never implement custom MCP protocol logic
|
|
17
|
+
* - Let FastMCP handle initialize, tools/list, tools/call, etc.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* app.use('/mcp', mcpMiddleware);
|
|
21
|
+
* app.use('/mcp/*', mcpMiddleware);
|
|
22
|
+
*
|
|
23
|
+
* https://github.com/modelcontextprotocol/modelcontextprotocol/tree/main/schema
|
|
24
|
+
* https://modelcontextprotocol.io/specification/2025-11-25/schema
|
|
25
|
+
*/
|
|
26
|
+
import type { MiddlewareHandler, Hono } from "hono";
|
|
27
|
+
import { ModalityFastMCP } from "modality-mcp-kit";
|
|
28
|
+
import { getLoggerInstance } from "modality-kit";
|
|
29
|
+
import { McpSessionManager } from "./McpSessionManager.js";
|
|
30
|
+
export interface FastHonoMcpConfig extends Record<string, unknown> {
|
|
31
|
+
name: string;
|
|
32
|
+
version: string;
|
|
33
|
+
}
|
|
34
|
+
export declare class FastHonoMcp extends ModalityFastMCP {
|
|
35
|
+
logger: ReturnType<typeof getLoggerInstance>;
|
|
36
|
+
config: FastHonoMcpConfig;
|
|
37
|
+
sessions: McpSessionManager;
|
|
38
|
+
private currentSessionId;
|
|
39
|
+
constructor(config: FastHonoMcpConfig);
|
|
40
|
+
/**
|
|
41
|
+
* Disconnect and cleanup a session
|
|
42
|
+
*/
|
|
43
|
+
disconnect(sessionId?: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Ensure session exists, create if needed
|
|
46
|
+
*/
|
|
47
|
+
private ensureSession;
|
|
48
|
+
handler(): MiddlewareHandler;
|
|
49
|
+
initHono(app: Hono, path?: string): this;
|
|
50
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Session Manager - Simple session lifecycle management
|
|
3
|
+
*/
|
|
4
|
+
export interface McpSession {
|
|
5
|
+
id: string;
|
|
6
|
+
createdAt: Date;
|
|
7
|
+
lastActivity: Date;
|
|
8
|
+
}
|
|
9
|
+
export declare class McpSessionManager {
|
|
10
|
+
private sessions;
|
|
11
|
+
/**
|
|
12
|
+
* Create a new session
|
|
13
|
+
*/
|
|
14
|
+
create(): McpSession;
|
|
15
|
+
/**
|
|
16
|
+
* Get session by ID
|
|
17
|
+
*/
|
|
18
|
+
get(sessionId: string): McpSession | undefined;
|
|
19
|
+
/**
|
|
20
|
+
* Update last activity timestamp
|
|
21
|
+
*/
|
|
22
|
+
touch(sessionId: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* Remove a session
|
|
25
|
+
*/
|
|
26
|
+
disconnect(sessionId: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Check if session exists
|
|
29
|
+
*/
|
|
30
|
+
has(sessionId: string): boolean;
|
|
31
|
+
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -2,3 +2,4 @@ export { toJsonSchema } from "xsschema";
|
|
|
2
2
|
export { setupAITools, ModalityFastMCP } from "./util_mcp_tools_converter";
|
|
3
3
|
export type { AITools, AITool, FastMCPTool, } from "./schemas/schemas_tool_config";
|
|
4
4
|
export type { FastMCPCompatible, Prompt } from "./util_mcp_tools_converter";
|
|
5
|
+
export { FastHonoMcp } from "./FastHonoMcp";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE (Server-Sent Events) Wrapper Utility
|
|
3
|
+
*
|
|
4
|
+
* Wraps JSON-RPC responses in SSE format as per MCP specification.
|
|
5
|
+
* SSE format:
|
|
6
|
+
* event: message
|
|
7
|
+
* id: <unique-id>
|
|
8
|
+
* data: <json-rpc-response>
|
|
9
|
+
*
|
|
10
|
+
* Supports both single-response and streaming modes.
|
|
11
|
+
* Uses modality-kit for JSON-RPC types and error codes.
|
|
12
|
+
*/
|
|
13
|
+
import type { JSONRPCResponse, JSONRPCId } from "modality-kit";
|
|
14
|
+
interface SSEMessage {
|
|
15
|
+
event: string;
|
|
16
|
+
id: string;
|
|
17
|
+
data: unknown;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Standard SSE response headers for MCP
|
|
21
|
+
*/
|
|
22
|
+
export declare const SSE_HEADERS: {
|
|
23
|
+
readonly "Content-Type": "text/event-stream";
|
|
24
|
+
readonly "Cache-Control": "no-cache";
|
|
25
|
+
readonly Connection: "keep-alive";
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Wrap a JSON-RPC response in SSE format
|
|
29
|
+
*/
|
|
30
|
+
export declare function wrapSSE(jsonrpcResponse: JSONRPCResponse): SSEMessage;
|
|
31
|
+
/**
|
|
32
|
+
* Format SSE message as text for transmission
|
|
33
|
+
*/
|
|
34
|
+
export declare function formatSSE(sseMessage: SSEMessage): string;
|
|
35
|
+
/**
|
|
36
|
+
* Wrap and format JSON-RPC response in one step
|
|
37
|
+
*/
|
|
38
|
+
export declare function wrapAndFormatSSE(jsonrpcResponse: JSONRPCResponse): string;
|
|
39
|
+
/**
|
|
40
|
+
* Create SSE-formatted success response
|
|
41
|
+
*/
|
|
42
|
+
export declare function sseSuccess(id: JSONRPCId, result?: unknown): string;
|
|
43
|
+
/**
|
|
44
|
+
* Create SSE-formatted error response
|
|
45
|
+
*/
|
|
46
|
+
export declare function sseError(id: JSONRPCId, code: number, message: string, data?: unknown): string;
|
|
47
|
+
/**
|
|
48
|
+
* Create SSE-formatted notification response (id: null, empty result)
|
|
49
|
+
*/
|
|
50
|
+
export declare function sseNotification(): string;
|
|
51
|
+
/**
|
|
52
|
+
* SSE Stream writer for true streaming support
|
|
53
|
+
*/
|
|
54
|
+
export declare class SSEStreamWriter {
|
|
55
|
+
private controller;
|
|
56
|
+
private encoder;
|
|
57
|
+
private closed;
|
|
58
|
+
/**
|
|
59
|
+
* Create a ReadableStream for SSE responses
|
|
60
|
+
*/
|
|
61
|
+
createStream(): ReadableStream<Uint8Array>;
|
|
62
|
+
/**
|
|
63
|
+
* Send a JSON-RPC response as SSE message
|
|
64
|
+
*/
|
|
65
|
+
send(response: JSONRPCResponse): void;
|
|
66
|
+
/**
|
|
67
|
+
* Send a progress notification
|
|
68
|
+
*/
|
|
69
|
+
sendProgress(progressToken: string | number, progress: number, total?: number): void;
|
|
70
|
+
/**
|
|
71
|
+
* Send a keep-alive ping (SSE comment)
|
|
72
|
+
*/
|
|
73
|
+
ping(): void;
|
|
74
|
+
/**
|
|
75
|
+
* Send raw SSE event
|
|
76
|
+
*/
|
|
77
|
+
sendEvent(event: string, data: unknown, id?: string): void;
|
|
78
|
+
/**
|
|
79
|
+
* Close the stream
|
|
80
|
+
*/
|
|
81
|
+
close(): void;
|
|
82
|
+
/**
|
|
83
|
+
* Check if stream is still open
|
|
84
|
+
*/
|
|
85
|
+
get isOpen(): boolean;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Create a streaming SSE response
|
|
89
|
+
*/
|
|
90
|
+
export declare function createSSEStream(handler: (writer: SSEStreamWriter) => Promise<void>, headers?: Record<string, string>): Response;
|
|
91
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
2
|
+
"version": "0.3.0",
|
|
3
3
|
"name": "modality-mcp-kit",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
@@ -22,15 +22,18 @@
|
|
|
22
22
|
"author": "Hill <hill@kimo.com>",
|
|
23
23
|
"license": "ISC",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"modality-kit": "^0.14.
|
|
25
|
+
"modality-kit": "^0.14.17",
|
|
26
26
|
"xsschema": "0.3.5",
|
|
27
27
|
"zod": "^3.25.76",
|
|
28
28
|
"zod-to-json-schema": "^3.25.0"
|
|
29
29
|
},
|
|
30
30
|
"devDependencies": {
|
|
31
|
-
"@types/bun": "^1.3.
|
|
31
|
+
"@types/bun": "^1.3.5",
|
|
32
32
|
"typescript": "^5.9.3"
|
|
33
33
|
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"hono": "^4.11.2"
|
|
36
|
+
},
|
|
34
37
|
"exports": {
|
|
35
38
|
"types": "./dist/types/index.d.ts",
|
|
36
39
|
"require": "./dist/index.js",
|
|
@@ -42,7 +45,7 @@
|
|
|
42
45
|
"scripts": {
|
|
43
46
|
"build": "bun tsc -p ./",
|
|
44
47
|
"build:types": "echo 'Starting build validation...' && bun --version && echo 'Running TypeScript type checking...' && bun tsc --noEmit --strict && echo '✅ Build validation successful - no TypeScript errors found' || (echo '❌ Build failed' && exit 1)",
|
|
45
|
-
"test": "bun test",
|
|
48
|
+
"test": "bun build:types && bun test",
|
|
46
49
|
"prepublishOnly": "npm run build && npm run test"
|
|
47
50
|
},
|
|
48
51
|
"files": ["package.json", "README.md", "dist"]
|