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.
@@ -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 targetSession = sessionId || this.currentSessionId;
65
- const disconnected = this.sessions.disconnect(targetSession);
63
+ const disconnected = this.sessions.disconnect(sessionId);
66
64
  if (disconnected) {
67
- this.logger?.info(`Session disconnected: ${targetSession}`);
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 (!this.sessions.has(this.currentSessionId)) {
76
- const session = this.sessions.create();
77
- this.currentSessionId = session.id;
78
- this.logger?.info(`Session connected: ${this.currentSessionId}`);
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
- // Validate session ID matches
98
- if (requestSessionId && requestSessionId !== this.currentSessionId) {
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 || this.currentSessionId);
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
- // Ensure session exists (creates new one if disconnected)
110
- this.ensureSession();
111
- const headers = {
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 locally (no response needed for notifications)
133
+ // Handle notifications/initialized
117
134
  try {
118
135
  const requestData = JSON.parse(bodyText);
119
136
  if (requestData?.method === "notifications/initialized") {
120
- return c.text(sseNotification(), 200, {
121
- ...SSE_HEADERS,
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
- }, headers);
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
- return c.text(sseError(null, -32603, message), 500, SSE_HEADERS);
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
  }
@@ -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
- controller = null;
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
- * Create a ReadableStream for SSE responses
111
+ * Get the readable stream for the Response
103
112
  */
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
- });
113
+ getReadableStream() {
114
+ return this.readable;
114
115
  }
115
116
  /**
116
- * Send a JSON-RPC response as SSE message
117
+ * Write string data as encoded bytes
117
118
  */
118
- send(response) {
119
- if (this.closed || !this.controller)
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.controller.enqueue(this.encoder.encode(formatted));
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.controller.enqueue(this.encoder.encode(formatted));
148
+ await this.write(formatted);
144
149
  }
145
150
  /**
146
151
  * Send a keep-alive ping (SSE comment)
147
152
  */
148
- ping() {
149
- if (this.closed || !this.controller)
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.controller.enqueue(this.encoder.encode(formatted));
162
+ await this.write(formatted);
162
163
  }
163
164
  /**
164
165
  * Close the stream
165
166
  */
166
- close() {
167
- if (this.closed || !this.controller)
167
+ async close() {
168
+ if (this.closed || !this.writer)
168
169
  return;
169
170
  this.closed = true;
170
- this.controller.close();
171
- this.controller = null;
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.controller !== null;
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
- handler(writer)
188
- .catch((error) => {
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(stream, {
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?: string): boolean;
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 controller;
58
+ private writer;
59
+ private readable;
56
60
  private encoder;
57
61
  private closed;
62
+ constructor();
58
63
  /**
59
- * Create a ReadableStream for SSE responses
64
+ * Get the readable stream for the Response
60
65
  */
61
- createStream(): ReadableStream<Uint8Array>;
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
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.3.2",
2
+ "version": "1.4.0",
3
3
  "name": "modality-mcp-kit",
4
4
  "repository": {
5
5
  "type": "git",