modality-mcp-kit 1.3.2 → 1.4.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 +53 -28
- package/dist/sse-wrapper.js +49 -41
- package/dist/types/FastHonoMcp.d.ts +6 -2
- package/dist/types/sse-wrapper.d.ts +26 -8
- package/package.json +1 -1
package/dist/FastHonoMcp.js
CHANGED
|
@@ -37,7 +37,6 @@ export class FastHonoMcp extends ModalityFastMCP {
|
|
|
37
37
|
logger;
|
|
38
38
|
config;
|
|
39
39
|
sessions = new McpSessionManager();
|
|
40
|
-
currentSessionId = "";
|
|
41
40
|
mcpPath = defaultMcpPath;
|
|
42
41
|
constructor(config) {
|
|
43
42
|
super();
|
|
@@ -61,25 +60,37 @@ export class FastHonoMcp extends ModalityFastMCP {
|
|
|
61
60
|
* Disconnect and cleanup a session
|
|
62
61
|
*/
|
|
63
62
|
disconnect(sessionId) {
|
|
64
|
-
const
|
|
65
|
-
const disconnected = this.sessions.disconnect(targetSession);
|
|
63
|
+
const disconnected = this.sessions.disconnect(sessionId);
|
|
66
64
|
if (disconnected) {
|
|
67
|
-
this.logger?.info(`Session disconnected: ${
|
|
65
|
+
this.logger?.info(`Session disconnected: ${sessionId}`);
|
|
68
66
|
}
|
|
69
67
|
return disconnected;
|
|
70
68
|
}
|
|
71
69
|
/**
|
|
72
70
|
* Ensure session exists, create if needed
|
|
71
|
+
* Returns the session ID to use
|
|
73
72
|
*/
|
|
74
|
-
ensureSession() {
|
|
75
|
-
if (
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
}
|
|
80
|
-
else {
|
|
81
|
-
this.sessions.touch(this.currentSessionId);
|
|
73
|
+
ensureSession(requestSessionId) {
|
|
74
|
+
if (requestSessionId && this.sessions.has(requestSessionId)) {
|
|
75
|
+
// Session exists, update activity
|
|
76
|
+
this.sessions.touch(requestSessionId);
|
|
77
|
+
return requestSessionId;
|
|
82
78
|
}
|
|
79
|
+
// Create new session (either no ID provided or ID doesn't exist)
|
|
80
|
+
const session = this.sessions.create();
|
|
81
|
+
this.logger?.info(`Session created: ${session.id}`);
|
|
82
|
+
return session.id;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Get CORS headers for cross-origin requests
|
|
86
|
+
*/
|
|
87
|
+
getCorsHeaders() {
|
|
88
|
+
return {
|
|
89
|
+
"Access-Control-Allow-Origin": "*",
|
|
90
|
+
"Access-Control-Allow-Methods": "POST, OPTIONS, DELETE",
|
|
91
|
+
"Access-Control-Allow-Headers": "content-type,mcp-protocol-version,mcp-session-id",
|
|
92
|
+
"Access-Control-Max-Age": "86400",
|
|
93
|
+
};
|
|
83
94
|
}
|
|
84
95
|
handler() {
|
|
85
96
|
return async (c, next) => {
|
|
@@ -90,15 +101,23 @@ export class FastHonoMcp extends ModalityFastMCP {
|
|
|
90
101
|
if (!url.pathname.startsWith(this.mcpPath)) {
|
|
91
102
|
return next();
|
|
92
103
|
}
|
|
104
|
+
// Set CORS headers once for all responses
|
|
105
|
+
const corsHeaders = this.getCorsHeaders();
|
|
106
|
+
Object.entries(corsHeaders).forEach(([key, value]) => {
|
|
107
|
+
c.header(key, value);
|
|
108
|
+
});
|
|
93
109
|
try {
|
|
110
|
+
// Handle CORS preflight OPTIONS request
|
|
111
|
+
if (c.req.method === "OPTIONS" && url.pathname === this.mcpPath) {
|
|
112
|
+
return c.body(null, 204);
|
|
113
|
+
}
|
|
94
114
|
// Handle DELETE for session disconnect
|
|
95
115
|
if (c.req.method === "DELETE" && url.pathname === this.mcpPath) {
|
|
96
116
|
const requestSessionId = c.req.header("mcp-session-id");
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
return c.json({ error: "Session ID mismatch" }, 400);
|
|
117
|
+
if (!requestSessionId) {
|
|
118
|
+
return c.json({ error: "Missing mcp-session-id header" }, 400);
|
|
100
119
|
}
|
|
101
|
-
const disconnected = this.disconnect(requestSessionId
|
|
120
|
+
const disconnected = this.disconnect(requestSessionId);
|
|
102
121
|
if (disconnected) {
|
|
103
122
|
return c.body(null, 204);
|
|
104
123
|
}
|
|
@@ -106,38 +125,44 @@ export class FastHonoMcp extends ModalityFastMCP {
|
|
|
106
125
|
}
|
|
107
126
|
// Handle main MCP endpoint
|
|
108
127
|
if (c.req.method === "POST" && url.pathname === this.mcpPath) {
|
|
109
|
-
|
|
110
|
-
this.ensureSession();
|
|
111
|
-
|
|
112
|
-
"mcp-session-id": this.currentSessionId,
|
|
113
|
-
};
|
|
128
|
+
const requestSessionId = c.req.header("mcp-session-id");
|
|
129
|
+
const sessionId = this.ensureSession(requestSessionId);
|
|
130
|
+
c.header("mcp-session-id", sessionId);
|
|
114
131
|
const bodyText = await c.req.text();
|
|
115
132
|
this.logger.info("MCP Middleware Received Body", { bodyText });
|
|
116
|
-
// Handle notifications/initialized
|
|
133
|
+
// Handle notifications/initialized
|
|
117
134
|
try {
|
|
118
135
|
const requestData = JSON.parse(bodyText);
|
|
119
136
|
if (requestData?.method === "notifications/initialized") {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
...headers,
|
|
137
|
+
Object.entries(SSE_HEADERS).forEach(([key, value]) => {
|
|
138
|
+
c.header(key, value);
|
|
123
139
|
});
|
|
140
|
+
return c.text(sseNotification(), 200);
|
|
124
141
|
}
|
|
125
142
|
}
|
|
126
143
|
catch {
|
|
127
144
|
// Not valid JSON, continue with normal processing
|
|
128
145
|
}
|
|
129
146
|
// Use streaming SSE response
|
|
147
|
+
const responseHeaders = {
|
|
148
|
+
"mcp-session-id": sessionId,
|
|
149
|
+
...corsHeaders,
|
|
150
|
+
};
|
|
130
151
|
return createSSEStream(async (writer) => {
|
|
131
152
|
const result = await createJsonRpcManager(this).validateMessage(bodyText);
|
|
132
|
-
writer.send(result);
|
|
133
|
-
|
|
153
|
+
await writer.send(result);
|
|
154
|
+
await writer.close();
|
|
155
|
+
}, responseHeaders);
|
|
134
156
|
}
|
|
135
157
|
return c.json({ error: `Use ${this.mcpPath} for MCP requests` }, 400);
|
|
136
158
|
}
|
|
137
159
|
catch (error) {
|
|
138
160
|
this.logger.error(`FastHonoMcp (${url.pathname}) Middleware Error`, error);
|
|
139
161
|
const message = error instanceof Error ? error.message : "Internal error";
|
|
140
|
-
|
|
162
|
+
Object.entries(SSE_HEADERS).forEach(([key, value]) => {
|
|
163
|
+
c.header(key, value);
|
|
164
|
+
});
|
|
165
|
+
return c.text(sseError(null, -32603, message), 500);
|
|
141
166
|
}
|
|
142
167
|
};
|
|
143
168
|
}
|
package/dist/sse-wrapper.js
CHANGED
|
@@ -15,11 +15,13 @@
|
|
|
15
15
|
// ============================================
|
|
16
16
|
/**
|
|
17
17
|
* Standard SSE response headers for MCP
|
|
18
|
+
* Transfer-Encoding: chunked is required for streaming responses
|
|
18
19
|
*/
|
|
19
20
|
export const SSE_HEADERS = {
|
|
20
21
|
"Content-Type": "text/event-stream",
|
|
21
22
|
"Cache-Control": "no-cache",
|
|
22
23
|
"Connection": "keep-alive",
|
|
24
|
+
"Transfer-Encoding": "chunked",
|
|
23
25
|
};
|
|
24
26
|
// ============================================
|
|
25
27
|
// CORE SSE FUNCTIONS
|
|
@@ -93,40 +95,43 @@ export function sseNotification() {
|
|
|
93
95
|
// ============================================
|
|
94
96
|
/**
|
|
95
97
|
* SSE Stream writer for true streaming support
|
|
98
|
+
* Uses TransformStream with TextEncoder for HTTP-compatible byte streaming
|
|
96
99
|
*/
|
|
97
100
|
export class SSEStreamWriter {
|
|
98
|
-
|
|
101
|
+
writer = null;
|
|
102
|
+
readable;
|
|
99
103
|
encoder = new TextEncoder();
|
|
100
104
|
closed = false;
|
|
105
|
+
constructor() {
|
|
106
|
+
const { readable, writable } = new TransformStream();
|
|
107
|
+
this.readable = readable;
|
|
108
|
+
this.writer = writable.getWriter();
|
|
109
|
+
}
|
|
101
110
|
/**
|
|
102
|
-
*
|
|
111
|
+
* Get the readable stream for the Response
|
|
103
112
|
*/
|
|
104
|
-
|
|
105
|
-
return
|
|
106
|
-
start: (controller) => {
|
|
107
|
-
this.controller = controller;
|
|
108
|
-
},
|
|
109
|
-
cancel: () => {
|
|
110
|
-
this.closed = true;
|
|
111
|
-
this.controller = null;
|
|
112
|
-
},
|
|
113
|
-
});
|
|
113
|
+
getReadableStream() {
|
|
114
|
+
return this.readable;
|
|
114
115
|
}
|
|
115
116
|
/**
|
|
116
|
-
*
|
|
117
|
+
* Write string data as encoded bytes
|
|
117
118
|
*/
|
|
118
|
-
|
|
119
|
-
if (this.closed || !this.
|
|
119
|
+
async write(data) {
|
|
120
|
+
if (this.closed || !this.writer)
|
|
120
121
|
return;
|
|
122
|
+
await this.writer.write(this.encoder.encode(data));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Send a JSON-RPC response as SSE message
|
|
126
|
+
*/
|
|
127
|
+
async send(response) {
|
|
121
128
|
const formatted = wrapAndFormatSSE(response);
|
|
122
|
-
this.
|
|
129
|
+
await this.write(formatted);
|
|
123
130
|
}
|
|
124
131
|
/**
|
|
125
132
|
* Send a progress notification
|
|
126
133
|
*/
|
|
127
|
-
sendProgress(progressToken, progress, total) {
|
|
128
|
-
if (this.closed || !this.controller)
|
|
129
|
-
return;
|
|
134
|
+
async sendProgress(progressToken, progress, total) {
|
|
130
135
|
const notification = {
|
|
131
136
|
jsonrpc: "2.0",
|
|
132
137
|
id: null,
|
|
@@ -140,54 +145,58 @@ export class SSEStreamWriter {
|
|
|
140
145
|
},
|
|
141
146
|
};
|
|
142
147
|
const formatted = wrapAndFormatSSE(notification);
|
|
143
|
-
this.
|
|
148
|
+
await this.write(formatted);
|
|
144
149
|
}
|
|
145
150
|
/**
|
|
146
151
|
* Send a keep-alive ping (SSE comment)
|
|
147
152
|
*/
|
|
148
|
-
ping() {
|
|
149
|
-
|
|
150
|
-
return;
|
|
151
|
-
this.controller.enqueue(this.encoder.encode(": ping\n\n"));
|
|
153
|
+
async ping() {
|
|
154
|
+
await this.write(": ping\n\n");
|
|
152
155
|
}
|
|
153
156
|
/**
|
|
154
157
|
* Send raw SSE event
|
|
155
158
|
*/
|
|
156
|
-
sendEvent(event, data, id) {
|
|
157
|
-
if (this.closed || !this.controller)
|
|
158
|
-
return;
|
|
159
|
+
async sendEvent(event, data, id) {
|
|
159
160
|
const sseId = id || generateSSEId();
|
|
160
161
|
const formatted = `event: ${event}\nid: ${sseId}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
161
|
-
this.
|
|
162
|
+
await this.write(formatted);
|
|
162
163
|
}
|
|
163
164
|
/**
|
|
164
165
|
* Close the stream
|
|
165
166
|
*/
|
|
166
|
-
close() {
|
|
167
|
-
if (this.closed || !this.
|
|
167
|
+
async close() {
|
|
168
|
+
if (this.closed || !this.writer)
|
|
168
169
|
return;
|
|
169
170
|
this.closed = true;
|
|
170
|
-
this.
|
|
171
|
-
this.
|
|
171
|
+
await this.writer.close();
|
|
172
|
+
this.writer = null;
|
|
172
173
|
}
|
|
173
174
|
/**
|
|
174
175
|
* Check if stream is still open
|
|
175
176
|
*/
|
|
176
177
|
get isOpen() {
|
|
177
|
-
return !this.closed && this.
|
|
178
|
+
return !this.closed && this.writer !== null;
|
|
178
179
|
}
|
|
179
180
|
}
|
|
180
181
|
/**
|
|
181
182
|
* Create a streaming SSE response
|
|
183
|
+
*
|
|
184
|
+
* The stream lifecycle is managed as follows:
|
|
185
|
+
* - Handler sends data via writer.send()
|
|
186
|
+
* - Handler MUST call writer.close() when done sending all data
|
|
187
|
+
* - On error, an error response is sent and stream is closed
|
|
188
|
+
* - Client disconnect triggers cancel callback for cleanup
|
|
189
|
+
*
|
|
190
|
+
* IMPORTANT: The handler is responsible for calling writer.close()
|
|
191
|
+
* when it's finished sending data. Not closing will keep the connection open.
|
|
182
192
|
*/
|
|
183
193
|
export function createSSEStream(handler, headers) {
|
|
184
194
|
const writer = new SSEStreamWriter();
|
|
185
|
-
const stream = writer.createStream();
|
|
186
195
|
// Execute handler asynchronously
|
|
187
|
-
|
|
188
|
-
|
|
196
|
+
// Handler is responsible for calling writer.close() when done
|
|
197
|
+
handler(writer).catch(async (error) => {
|
|
189
198
|
if (writer.isOpen) {
|
|
190
|
-
writer.send({
|
|
199
|
+
await writer.send({
|
|
191
200
|
jsonrpc: "2.0",
|
|
192
201
|
id: null,
|
|
193
202
|
error: {
|
|
@@ -195,12 +204,11 @@ export function createSSEStream(handler, headers) {
|
|
|
195
204
|
message: error instanceof Error ? error.message : "Internal error",
|
|
196
205
|
},
|
|
197
206
|
});
|
|
207
|
+
// Close stream on error to signal completion
|
|
208
|
+
await writer.close();
|
|
198
209
|
}
|
|
199
|
-
})
|
|
200
|
-
.finally(() => {
|
|
201
|
-
writer.close();
|
|
202
210
|
});
|
|
203
|
-
return new Response(
|
|
211
|
+
return new Response(writer.getReadableStream(), {
|
|
204
212
|
headers: { ...SSE_HEADERS, ...headers },
|
|
205
213
|
});
|
|
206
214
|
}
|
|
@@ -38,17 +38,21 @@ export declare class FastHonoMcp extends ModalityFastMCP {
|
|
|
38
38
|
logger: ReturnType<typeof getLoggerInstance>;
|
|
39
39
|
config: FastHonoMcpConfig;
|
|
40
40
|
sessions: McpSessionManager;
|
|
41
|
-
private currentSessionId;
|
|
42
41
|
mcpPath: string;
|
|
43
42
|
constructor(config: FastHonoMcpConfig);
|
|
44
43
|
initHono(app: Hono): this;
|
|
45
44
|
/**
|
|
46
45
|
* Disconnect and cleanup a session
|
|
47
46
|
*/
|
|
48
|
-
disconnect(sessionId
|
|
47
|
+
disconnect(sessionId: string): boolean;
|
|
49
48
|
/**
|
|
50
49
|
* Ensure session exists, create if needed
|
|
50
|
+
* Returns the session ID to use
|
|
51
51
|
*/
|
|
52
52
|
private ensureSession;
|
|
53
|
+
/**
|
|
54
|
+
* Get CORS headers for cross-origin requests
|
|
55
|
+
*/
|
|
56
|
+
private getCorsHeaders;
|
|
53
57
|
handler(): MiddlewareHandler;
|
|
54
58
|
}
|
|
@@ -18,11 +18,13 @@ interface SSEMessage {
|
|
|
18
18
|
}
|
|
19
19
|
/**
|
|
20
20
|
* Standard SSE response headers for MCP
|
|
21
|
+
* Transfer-Encoding: chunked is required for streaming responses
|
|
21
22
|
*/
|
|
22
23
|
export declare const SSE_HEADERS: {
|
|
23
24
|
readonly "Content-Type": "text/event-stream";
|
|
24
25
|
readonly "Cache-Control": "no-cache";
|
|
25
26
|
readonly Connection: "keep-alive";
|
|
27
|
+
readonly "Transfer-Encoding": "chunked";
|
|
26
28
|
};
|
|
27
29
|
/**
|
|
28
30
|
* Wrap a JSON-RPC response in SSE format
|
|
@@ -50,35 +52,42 @@ export declare function sseError(id: JSONRPCId, code: number, message: string, d
|
|
|
50
52
|
export declare function sseNotification(): string;
|
|
51
53
|
/**
|
|
52
54
|
* SSE Stream writer for true streaming support
|
|
55
|
+
* Uses TransformStream with TextEncoder for HTTP-compatible byte streaming
|
|
53
56
|
*/
|
|
54
57
|
export declare class SSEStreamWriter {
|
|
55
|
-
private
|
|
58
|
+
private writer;
|
|
59
|
+
private readable;
|
|
56
60
|
private encoder;
|
|
57
61
|
private closed;
|
|
62
|
+
constructor();
|
|
58
63
|
/**
|
|
59
|
-
*
|
|
64
|
+
* Get the readable stream for the Response
|
|
60
65
|
*/
|
|
61
|
-
|
|
66
|
+
getReadableStream(): ReadableStream<Uint8Array>;
|
|
67
|
+
/**
|
|
68
|
+
* Write string data as encoded bytes
|
|
69
|
+
*/
|
|
70
|
+
private write;
|
|
62
71
|
/**
|
|
63
72
|
* Send a JSON-RPC response as SSE message
|
|
64
73
|
*/
|
|
65
|
-
send(response: JSONRPCResponse): void
|
|
74
|
+
send(response: JSONRPCResponse): Promise<void>;
|
|
66
75
|
/**
|
|
67
76
|
* Send a progress notification
|
|
68
77
|
*/
|
|
69
|
-
sendProgress(progressToken: string | number, progress: number, total?: number): void
|
|
78
|
+
sendProgress(progressToken: string | number, progress: number, total?: number): Promise<void>;
|
|
70
79
|
/**
|
|
71
80
|
* Send a keep-alive ping (SSE comment)
|
|
72
81
|
*/
|
|
73
|
-
ping(): void
|
|
82
|
+
ping(): Promise<void>;
|
|
74
83
|
/**
|
|
75
84
|
* Send raw SSE event
|
|
76
85
|
*/
|
|
77
|
-
sendEvent(event: string, data: unknown, id?: string): void
|
|
86
|
+
sendEvent(event: string, data: unknown, id?: string): Promise<void>;
|
|
78
87
|
/**
|
|
79
88
|
* Close the stream
|
|
80
89
|
*/
|
|
81
|
-
close(): void
|
|
90
|
+
close(): Promise<void>;
|
|
82
91
|
/**
|
|
83
92
|
* Check if stream is still open
|
|
84
93
|
*/
|
|
@@ -86,6 +95,15 @@ export declare class SSEStreamWriter {
|
|
|
86
95
|
}
|
|
87
96
|
/**
|
|
88
97
|
* Create a streaming SSE response
|
|
98
|
+
*
|
|
99
|
+
* The stream lifecycle is managed as follows:
|
|
100
|
+
* - Handler sends data via writer.send()
|
|
101
|
+
* - Handler MUST call writer.close() when done sending all data
|
|
102
|
+
* - On error, an error response is sent and stream is closed
|
|
103
|
+
* - Client disconnect triggers cancel callback for cleanup
|
|
104
|
+
*
|
|
105
|
+
* IMPORTANT: The handler is responsible for calling writer.close()
|
|
106
|
+
* when it's finished sending data. Not closing will keep the connection open.
|
|
89
107
|
*/
|
|
90
108
|
export declare function createSSEStream(handler: (writer: SSEStreamWriter) => Promise<void>, headers?: Record<string, string>): Response;
|
|
91
109
|
export {};
|
package/package.json
CHANGED