nitrostack 1.0.17 → 1.0.19
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/ARCHITECTURE.md +302 -0
- package/dist/cli/commands/build.js +1 -1
- package/dist/cli/commands/build.js.map +1 -1
- package/dist/cli/commands/start.js +3 -3
- package/dist/cli/commands/start.js.map +1 -1
- package/dist/cli/mcp-dev-wrapper.js +2 -0
- package/dist/cli/mcp-dev-wrapper.js.map +1 -1
- package/dist/core/server.d.ts.map +1 -1
- package/dist/core/server.js +58 -14
- package/dist/core/server.js.map +1 -1
- package/dist/core/transports/streamable-http.d.ts +145 -0
- package/dist/core/transports/streamable-http.d.ts.map +1 -0
- package/dist/core/transports/streamable-http.js +691 -0
- package/dist/core/transports/streamable-http.js.map +1 -0
- package/package.json +2 -2
- package/src/studio/app/auth/callback/page.tsx +17 -2
- package/templates/typescript-auth/src/index.ts +27 -13
- package/templates/typescript-auth-api-key/src/index.ts +29 -14
- package/templates/typescript-auth-api-key/src/widgets/next.config.js +9 -1
- package/templates/typescript-oauth/src/index.ts +30 -9
- package/templates/typescript-oauth/src/widgets/next.config.js +9 -1
- package/templates/typescript-starter/src/index.ts +28 -4
- package/templates/typescript-starter/src/widgets/next.config.js +9 -1
- package/src/studio/package-lock.json +0 -3129
|
@@ -0,0 +1,691 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Streamable HTTP Transport for MCP
|
|
3
|
+
*
|
|
4
|
+
* Implements the MCP Streamable HTTP transport specification (2025-06-18).
|
|
5
|
+
* https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#streamable-http
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - Single MCP endpoint supporting both POST and GET
|
|
9
|
+
* - POST for sending messages to server
|
|
10
|
+
* - GET for SSE streams from server
|
|
11
|
+
* - Session management with Mcp-Session-Id header
|
|
12
|
+
* - Resumability support with Last-Event-ID
|
|
13
|
+
* - Multiple concurrent client connections
|
|
14
|
+
* - Protocol version header support
|
|
15
|
+
*/
|
|
16
|
+
import express from 'express';
|
|
17
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
18
|
+
/**
|
|
19
|
+
* Streamable HTTP Transport
|
|
20
|
+
* Implements MCP Streamable HTTP specification
|
|
21
|
+
*/
|
|
22
|
+
export class StreamableHttpTransport {
|
|
23
|
+
app;
|
|
24
|
+
server = null;
|
|
25
|
+
sessions = new Map();
|
|
26
|
+
activeStreams = new Map(); // For sessionless mode
|
|
27
|
+
messageHandler;
|
|
28
|
+
closeHandler;
|
|
29
|
+
errorHandler;
|
|
30
|
+
options;
|
|
31
|
+
sessionCleanupInterval;
|
|
32
|
+
constructor(options = {}) {
|
|
33
|
+
this.options = {
|
|
34
|
+
port: options.port || 3000,
|
|
35
|
+
host: options.host || 'localhost',
|
|
36
|
+
endpoint: options.endpoint || '/mcp',
|
|
37
|
+
enableSessions: options.enableSessions === true, // Default to false for simpler clients
|
|
38
|
+
sessionTimeout: options.sessionTimeout || 30 * 60 * 1000, // 30 minutes
|
|
39
|
+
enableCors: options.enableCors !== false, // Default to true
|
|
40
|
+
};
|
|
41
|
+
this.app = options.app || express();
|
|
42
|
+
// CRITICAL: Disable Express's automatic OPTIONS handling
|
|
43
|
+
this.app.set('x-powered-by', false);
|
|
44
|
+
this.setupMiddleware();
|
|
45
|
+
this.setupRoutes();
|
|
46
|
+
this.startSessionCleanup();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Setup Express middleware
|
|
50
|
+
*/
|
|
51
|
+
setupMiddleware() {
|
|
52
|
+
// CORS (if enabled) - MUST be the very first middleware, handles ALL requests
|
|
53
|
+
if (this.options.enableCors) {
|
|
54
|
+
// Add CORS headers to ALL responses
|
|
55
|
+
this.app.use((req, res, next) => {
|
|
56
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
57
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
58
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization, Mcp-Session-Id, MCP-Protocol-Version, Last-Event-ID');
|
|
59
|
+
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
60
|
+
// Handle OPTIONS immediately
|
|
61
|
+
if (req.method === 'OPTIONS') {
|
|
62
|
+
res.status(200).end();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
next();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
// Security: Validate Origin header to prevent DNS rebinding attacks (skip if CORS enabled)
|
|
69
|
+
if (!this.options.enableCors) {
|
|
70
|
+
this.app.use((req, res, next) => {
|
|
71
|
+
const origin = req.get('Origin');
|
|
72
|
+
const host = req.get('Host');
|
|
73
|
+
if (origin && host) {
|
|
74
|
+
const originHost = new URL(origin).host;
|
|
75
|
+
if (originHost !== host && !this.isLocalhost(originHost)) {
|
|
76
|
+
res.status(403).json({ error: 'Invalid Origin header' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
next();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
// JSON parsing
|
|
84
|
+
this.app.use(express.json());
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* Setup MCP endpoint routes
|
|
88
|
+
*/
|
|
89
|
+
setupRoutes() {
|
|
90
|
+
const endpoint = this.options.endpoint;
|
|
91
|
+
// IMPORTANT: Add OPTIONS handlers FIRST to override Express's auto-OPTIONS
|
|
92
|
+
if (this.options.enableCors) {
|
|
93
|
+
// Main endpoint OPTIONS
|
|
94
|
+
this.app.options(endpoint, (req, res) => {
|
|
95
|
+
res.sendStatus(200);
|
|
96
|
+
});
|
|
97
|
+
// SSE endpoint OPTIONS
|
|
98
|
+
this.app.options(`${endpoint}/sse`, (req, res) => {
|
|
99
|
+
res.sendStatus(200);
|
|
100
|
+
});
|
|
101
|
+
// Message endpoint OPTIONS
|
|
102
|
+
this.app.options(`${endpoint}/message`, (req, res) => {
|
|
103
|
+
res.sendStatus(200);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// MCP Endpoint - POST for sending messages to server
|
|
107
|
+
this.app.post(endpoint, async (req, res) => {
|
|
108
|
+
await this.handlePost(req, res);
|
|
109
|
+
});
|
|
110
|
+
// MCP Endpoint - GET for SSE streams
|
|
111
|
+
this.app.get(endpoint, (req, res) => {
|
|
112
|
+
this.handleGet(req, res);
|
|
113
|
+
});
|
|
114
|
+
// MCP Endpoint - DELETE for session termination
|
|
115
|
+
this.app.delete(endpoint, (req, res) => {
|
|
116
|
+
this.handleDelete(req, res);
|
|
117
|
+
});
|
|
118
|
+
// Backward compatibility: /sse endpoint (alias for GET /mcp)
|
|
119
|
+
this.app.get(`${endpoint}/sse`, (req, res) => {
|
|
120
|
+
this.handleGet(req, res);
|
|
121
|
+
});
|
|
122
|
+
// Backward compatibility: /message endpoint (alias for POST /mcp)
|
|
123
|
+
this.app.post(`${endpoint}/message`, async (req, res) => {
|
|
124
|
+
// Simple message handler that doesn't require all the session/SSE logic
|
|
125
|
+
try {
|
|
126
|
+
const message = req.body;
|
|
127
|
+
if (!message || !message.jsonrpc) {
|
|
128
|
+
res.status(400).json({ error: 'Invalid JSON-RPC message' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
// Pass to message handler
|
|
132
|
+
if (this.messageHandler) {
|
|
133
|
+
await this.messageHandler(message);
|
|
134
|
+
}
|
|
135
|
+
res.json({ status: 'received' });
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
console.error('Error handling message:', error);
|
|
139
|
+
res.status(500).json({ error: error.message });
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
// Info endpoint for GET on /message
|
|
143
|
+
this.app.get(`${endpoint}/message`, (req, res) => {
|
|
144
|
+
res.json({
|
|
145
|
+
endpoint: `${endpoint}/message`,
|
|
146
|
+
method: 'POST',
|
|
147
|
+
description: 'Send JSON-RPC messages to the MCP server',
|
|
148
|
+
usage: 'POST with Content-Type: application/json',
|
|
149
|
+
example: {
|
|
150
|
+
jsonrpc: '2.0',
|
|
151
|
+
method: 'initialize',
|
|
152
|
+
params: {
|
|
153
|
+
protocolVersion: '2024-11-05',
|
|
154
|
+
capabilities: {},
|
|
155
|
+
clientInfo: { name: 'test-client', version: '1.0.0' }
|
|
156
|
+
},
|
|
157
|
+
id: 1
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
// Health check
|
|
162
|
+
this.app.get(`${endpoint}/health`, (req, res) => {
|
|
163
|
+
res.json({
|
|
164
|
+
status: 'ok',
|
|
165
|
+
transport: 'streamable-http',
|
|
166
|
+
version: '2025-06-18',
|
|
167
|
+
sessions: this.sessions.size,
|
|
168
|
+
uptime: process.uptime(),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Handle POST requests (client sending messages to server)
|
|
174
|
+
*/
|
|
175
|
+
async handlePost(req, res) {
|
|
176
|
+
try {
|
|
177
|
+
const message = req.body;
|
|
178
|
+
const sessionId = req.get('Mcp-Session-Id');
|
|
179
|
+
const accept = req.get('Accept') || '';
|
|
180
|
+
// Validate JSON-RPC message
|
|
181
|
+
if (!message || !message.jsonrpc || message.jsonrpc !== '2.0') {
|
|
182
|
+
res.status(400).json({
|
|
183
|
+
jsonrpc: '2.0',
|
|
184
|
+
error: { code: -32600, message: 'Invalid JSON-RPC message' }
|
|
185
|
+
});
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// Check session
|
|
189
|
+
if (this.options.enableSessions && sessionId) {
|
|
190
|
+
const session = this.sessions.get(sessionId);
|
|
191
|
+
if (!session) {
|
|
192
|
+
res.status(404).json({
|
|
193
|
+
jsonrpc: '2.0',
|
|
194
|
+
error: { code: -32001, message: 'Session not found' }
|
|
195
|
+
});
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
session.lastActivity = Date.now();
|
|
199
|
+
}
|
|
200
|
+
// Handle different message types
|
|
201
|
+
const messageType = this.getMessageType(message);
|
|
202
|
+
if (messageType === 'notification' || messageType === 'response') {
|
|
203
|
+
// Notification or Response: Return 202 Accepted
|
|
204
|
+
if (this.messageHandler) {
|
|
205
|
+
await this.messageHandler(message);
|
|
206
|
+
}
|
|
207
|
+
res.status(202).send();
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (messageType === 'request') {
|
|
211
|
+
// Request: Accept header check (be lenient - if not specified, assume they want SSE)
|
|
212
|
+
const supportsSSE = !accept || accept.includes('text/event-stream') || accept.includes('*/*');
|
|
213
|
+
const supportsJSON = accept.includes('application/json');
|
|
214
|
+
// Pass to message handler
|
|
215
|
+
if (this.messageHandler) {
|
|
216
|
+
await this.messageHandler(message);
|
|
217
|
+
}
|
|
218
|
+
// For InitializeRequest, create session if enabled
|
|
219
|
+
if (this.isInitializeRequest(message)) {
|
|
220
|
+
if (this.options.enableSessions && !sessionId) {
|
|
221
|
+
const newSessionId = this.generateSessionId();
|
|
222
|
+
const session = {
|
|
223
|
+
id: newSessionId,
|
|
224
|
+
streams: new Map(),
|
|
225
|
+
lastActivity: Date.now(),
|
|
226
|
+
messageQueue: [],
|
|
227
|
+
eventIdCounter: 0,
|
|
228
|
+
};
|
|
229
|
+
this.sessions.set(newSessionId, session);
|
|
230
|
+
res.setHeader('Mcp-Session-Id', newSessionId);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
// For SSE: Just acknowledge receipt, response will come via existing SSE stream
|
|
234
|
+
if (supportsSSE) {
|
|
235
|
+
// Accept the request
|
|
236
|
+
res.status(202).send();
|
|
237
|
+
// Response will be sent via the send() method to existing SSE streams
|
|
238
|
+
}
|
|
239
|
+
else {
|
|
240
|
+
// Single JSON response (less common)
|
|
241
|
+
res.setHeader('Content-Type', 'application/json');
|
|
242
|
+
// Response will be sent by the protocol layer
|
|
243
|
+
res._mcpWaitingForResponse = true;
|
|
244
|
+
res._mcpRequestId = message.id;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
console.error('POST error:', error);
|
|
250
|
+
res.status(500).json({
|
|
251
|
+
jsonrpc: '2.0',
|
|
252
|
+
error: { code: -32603, message: 'Internal error' }
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Handle GET requests (client opening SSE stream)
|
|
258
|
+
*/
|
|
259
|
+
handleGet(req, res) {
|
|
260
|
+
const sessionId = req.get('Mcp-Session-Id');
|
|
261
|
+
const lastEventId = req.get('Last-Event-ID');
|
|
262
|
+
const accept = req.get('Accept') || '';
|
|
263
|
+
// Check if client explicitly doesn't want SSE (e.g., asking for JSON only)
|
|
264
|
+
const rejectsSSE = accept && !accept.includes('*/*') && !accept.includes('text/event-stream') && accept.length > 0;
|
|
265
|
+
if (rejectsSSE) {
|
|
266
|
+
res.status(405).send('Method Not Allowed - This endpoint provides Server-Sent Events');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
// Check session
|
|
270
|
+
let session;
|
|
271
|
+
if (this.options.enableSessions) {
|
|
272
|
+
if (!sessionId) {
|
|
273
|
+
res.status(400).json({ error: 'Mcp-Session-Id required' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
session = this.sessions.get(sessionId);
|
|
277
|
+
if (!session) {
|
|
278
|
+
res.status(404).json({ error: 'Session not found' });
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
session.lastActivity = Date.now();
|
|
282
|
+
}
|
|
283
|
+
// Setup SSE
|
|
284
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
285
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
286
|
+
res.setHeader('Connection', 'keep-alive');
|
|
287
|
+
res.flushHeaders();
|
|
288
|
+
// Create stream
|
|
289
|
+
const streamId = uuidv4();
|
|
290
|
+
const stream = {
|
|
291
|
+
id: streamId,
|
|
292
|
+
response: res,
|
|
293
|
+
eventIdCounter: 0,
|
|
294
|
+
closed: false,
|
|
295
|
+
};
|
|
296
|
+
// Send endpoint event immediately (required by MCP SDK)
|
|
297
|
+
// This tells the client where to POST messages
|
|
298
|
+
const endpointUrl = `${req.protocol}://${req.get('host')}${this.options.endpoint}`;
|
|
299
|
+
try {
|
|
300
|
+
res.write(`event: endpoint\n`);
|
|
301
|
+
res.write(`data: ${endpointUrl}\n\n`);
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
console.error('Error sending endpoint event:', error);
|
|
305
|
+
stream.closed = true;
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
// Add to session or activeStreams
|
|
309
|
+
if (session) {
|
|
310
|
+
session.streams.set(streamId, stream);
|
|
311
|
+
// Resume support: replay messages after lastEventId
|
|
312
|
+
if (lastEventId) {
|
|
313
|
+
this.replayMessages(session, stream, lastEventId);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
else {
|
|
317
|
+
// Sessionless mode: track in activeStreams
|
|
318
|
+
this.activeStreams.set(streamId, stream);
|
|
319
|
+
}
|
|
320
|
+
// Handle client disconnect
|
|
321
|
+
req.on('close', () => {
|
|
322
|
+
stream.closed = true;
|
|
323
|
+
if (session) {
|
|
324
|
+
session.streams.delete(streamId);
|
|
325
|
+
}
|
|
326
|
+
else {
|
|
327
|
+
this.activeStreams.delete(streamId);
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
// Send ping every 30 seconds to keep connection alive
|
|
331
|
+
const pingInterval = setInterval(() => {
|
|
332
|
+
if (stream.closed) {
|
|
333
|
+
clearInterval(pingInterval);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
try {
|
|
337
|
+
res.write(': ping\n\n');
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
clearInterval(pingInterval);
|
|
341
|
+
stream.closed = true;
|
|
342
|
+
}
|
|
343
|
+
}, 30000);
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Handle DELETE requests (session termination)
|
|
347
|
+
*/
|
|
348
|
+
handleDelete(req, res) {
|
|
349
|
+
const sessionId = req.get('Mcp-Session-Id');
|
|
350
|
+
if (!sessionId) {
|
|
351
|
+
res.status(400).json({ error: 'Mcp-Session-Id required' });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const session = this.sessions.get(sessionId);
|
|
355
|
+
if (!session) {
|
|
356
|
+
res.status(404).json({ error: 'Session not found' });
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
// Close all streams
|
|
360
|
+
for (const stream of session.streams.values()) {
|
|
361
|
+
try {
|
|
362
|
+
stream.response.end();
|
|
363
|
+
stream.closed = true;
|
|
364
|
+
}
|
|
365
|
+
catch (error) {
|
|
366
|
+
// Ignore
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
// Remove session
|
|
370
|
+
this.sessions.delete(sessionId);
|
|
371
|
+
res.status(200).json({ status: 'session terminated' });
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Start SSE stream for a request
|
|
375
|
+
*/
|
|
376
|
+
async startSSEStream(req, res, request, sessionId) {
|
|
377
|
+
// Setup SSE
|
|
378
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
379
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
380
|
+
res.setHeader('Connection', 'keep-alive');
|
|
381
|
+
res.flushHeaders();
|
|
382
|
+
// Create stream
|
|
383
|
+
const streamId = uuidv4();
|
|
384
|
+
const stream = {
|
|
385
|
+
id: streamId,
|
|
386
|
+
response: res,
|
|
387
|
+
eventIdCounter: 0,
|
|
388
|
+
closed: false,
|
|
389
|
+
};
|
|
390
|
+
// Store stream reference for this request
|
|
391
|
+
req._mcpStreamId = streamId;
|
|
392
|
+
req._mcpStream = stream;
|
|
393
|
+
// Add to session or activeStreams
|
|
394
|
+
if (sessionId) {
|
|
395
|
+
const session = this.sessions.get(sessionId);
|
|
396
|
+
if (session) {
|
|
397
|
+
session.streams.set(streamId, stream);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
else {
|
|
401
|
+
// Sessionless mode: track in activeStreams
|
|
402
|
+
this.activeStreams.set(streamId, stream);
|
|
403
|
+
}
|
|
404
|
+
// Handle client disconnect
|
|
405
|
+
req.on('close', () => {
|
|
406
|
+
stream.closed = true;
|
|
407
|
+
if (sessionId) {
|
|
408
|
+
const session = this.sessions.get(sessionId);
|
|
409
|
+
if (session) {
|
|
410
|
+
session.streams.delete(streamId);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
this.activeStreams.delete(streamId);
|
|
415
|
+
}
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* Send message to client(s)
|
|
420
|
+
*/
|
|
421
|
+
async send(message) {
|
|
422
|
+
// Find target session/stream
|
|
423
|
+
// For responses, send to the stream that made the request
|
|
424
|
+
if (this.isResponse(message)) {
|
|
425
|
+
const response = message;
|
|
426
|
+
await this.sendToRequestStream(response);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
// For requests and notifications, send to all active streams
|
|
430
|
+
// First, send to session-based streams
|
|
431
|
+
for (const session of this.sessions.values()) {
|
|
432
|
+
for (const stream of session.streams.values()) {
|
|
433
|
+
if (!stream.closed) {
|
|
434
|
+
await this.sendToStream(stream, message, session);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Then, send to sessionless streams
|
|
439
|
+
for (const stream of this.activeStreams.values()) {
|
|
440
|
+
if (!stream.closed) {
|
|
441
|
+
await this.sendToStreamSessionless(stream, message);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Send message to a specific stream
|
|
447
|
+
*/
|
|
448
|
+
async sendToStream(stream, message, session) {
|
|
449
|
+
try {
|
|
450
|
+
const eventId = `${session.id}-${++stream.eventIdCounter}`;
|
|
451
|
+
const data = JSON.stringify(message);
|
|
452
|
+
stream.response.write(`id: ${eventId}\n`);
|
|
453
|
+
stream.response.write(`data: ${data}\n\n`);
|
|
454
|
+
// Store in queue for resumability
|
|
455
|
+
session.messageQueue.push({
|
|
456
|
+
message,
|
|
457
|
+
streamId: stream.id,
|
|
458
|
+
eventId,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
catch (error) {
|
|
462
|
+
console.error('Error sending to stream:', error);
|
|
463
|
+
stream.closed = true;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Send response to the stream that made the request
|
|
468
|
+
*/
|
|
469
|
+
async sendToRequestStream(response) {
|
|
470
|
+
// Find the stream associated with this request
|
|
471
|
+
// For session-based streams
|
|
472
|
+
for (const session of this.sessions.values()) {
|
|
473
|
+
for (const stream of session.streams.values()) {
|
|
474
|
+
if (!stream.closed) {
|
|
475
|
+
await this.sendToStream(stream, response, session);
|
|
476
|
+
// Close stream after sending response (per spec)
|
|
477
|
+
setTimeout(() => {
|
|
478
|
+
try {
|
|
479
|
+
stream.response.end();
|
|
480
|
+
stream.closed = true;
|
|
481
|
+
session.streams.delete(stream.id);
|
|
482
|
+
}
|
|
483
|
+
catch (error) {
|
|
484
|
+
// Ignore
|
|
485
|
+
}
|
|
486
|
+
}, 100);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
// For sessionless streams
|
|
491
|
+
for (const stream of this.activeStreams.values()) {
|
|
492
|
+
if (!stream.closed) {
|
|
493
|
+
await this.sendToStreamSessionless(stream, response);
|
|
494
|
+
// Close stream after sending response (per spec)
|
|
495
|
+
setTimeout(() => {
|
|
496
|
+
try {
|
|
497
|
+
stream.response.end();
|
|
498
|
+
stream.closed = true;
|
|
499
|
+
this.activeStreams.delete(stream.id);
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
// Ignore
|
|
503
|
+
}
|
|
504
|
+
}, 100);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
/**
|
|
509
|
+
* Send message to a sessionless stream
|
|
510
|
+
*/
|
|
511
|
+
async sendToStreamSessionless(stream, message) {
|
|
512
|
+
try {
|
|
513
|
+
const eventId = `${stream.id}-${++stream.eventIdCounter}`;
|
|
514
|
+
const data = JSON.stringify(message);
|
|
515
|
+
stream.response.write(`id: ${eventId}\n`);
|
|
516
|
+
stream.response.write(`data: ${data}\n\n`);
|
|
517
|
+
}
|
|
518
|
+
catch (error) {
|
|
519
|
+
console.error('Error sending to sessionless stream:', error);
|
|
520
|
+
stream.closed = true;
|
|
521
|
+
this.activeStreams.delete(stream.id);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Replay messages for resumability
|
|
526
|
+
*/
|
|
527
|
+
replayMessages(session, stream, lastEventId) {
|
|
528
|
+
const messages = session.messageQueue.filter((msg) => msg.streamId === stream.id && msg.eventId > lastEventId);
|
|
529
|
+
for (const { message, eventId } of messages) {
|
|
530
|
+
try {
|
|
531
|
+
const data = JSON.stringify(message);
|
|
532
|
+
stream.response.write(`id: ${eventId}\n`);
|
|
533
|
+
stream.response.write(`data: ${data}\n\n`);
|
|
534
|
+
}
|
|
535
|
+
catch (error) {
|
|
536
|
+
console.error('Error replaying message:', error);
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Start the HTTP server
|
|
543
|
+
*/
|
|
544
|
+
async start() {
|
|
545
|
+
if (this.server) {
|
|
546
|
+
await this.close();
|
|
547
|
+
}
|
|
548
|
+
return new Promise((resolve, reject) => {
|
|
549
|
+
const errorHandler = (error) => {
|
|
550
|
+
console.error(`Failed to start Streamable HTTP transport: ${error.message}`);
|
|
551
|
+
this.server = null;
|
|
552
|
+
reject(error);
|
|
553
|
+
};
|
|
554
|
+
try {
|
|
555
|
+
const server = this.app.listen(this.options.port, this.options.host);
|
|
556
|
+
server.once('error', errorHandler);
|
|
557
|
+
server.once('listening', () => {
|
|
558
|
+
server.removeListener('error', errorHandler);
|
|
559
|
+
server.on('error', (error) => {
|
|
560
|
+
if (this.errorHandler) {
|
|
561
|
+
this.errorHandler(error);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
this.server = server;
|
|
565
|
+
console.error(`🌐 MCP Streamable HTTP transport listening on http://${this.options.host}:${this.options.port}${this.options.endpoint}`);
|
|
566
|
+
console.error(` Protocol: MCP 2025-06-18`);
|
|
567
|
+
console.error(` Sessions: ${this.options.enableSessions ? 'enabled' : 'disabled'}`);
|
|
568
|
+
resolve();
|
|
569
|
+
});
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
reject(error);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Close the transport
|
|
578
|
+
*/
|
|
579
|
+
async close() {
|
|
580
|
+
// Clear session cleanup
|
|
581
|
+
if (this.sessionCleanupInterval) {
|
|
582
|
+
clearInterval(this.sessionCleanupInterval);
|
|
583
|
+
}
|
|
584
|
+
// Close all sessions
|
|
585
|
+
for (const session of this.sessions.values()) {
|
|
586
|
+
for (const stream of session.streams.values()) {
|
|
587
|
+
try {
|
|
588
|
+
stream.response.end();
|
|
589
|
+
stream.closed = true;
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
// Ignore
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
this.sessions.clear();
|
|
597
|
+
// Close HTTP server
|
|
598
|
+
if (this.server) {
|
|
599
|
+
return new Promise((resolve) => {
|
|
600
|
+
const server = this.server;
|
|
601
|
+
this.server = null;
|
|
602
|
+
server.closeAllConnections?.();
|
|
603
|
+
server.close((err) => {
|
|
604
|
+
if (err) {
|
|
605
|
+
console.error('HTTP server close error:', err.message);
|
|
606
|
+
}
|
|
607
|
+
if (this.closeHandler) {
|
|
608
|
+
this.closeHandler();
|
|
609
|
+
}
|
|
610
|
+
resolve();
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
if (this.closeHandler) {
|
|
615
|
+
this.closeHandler();
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Set message handler
|
|
620
|
+
*/
|
|
621
|
+
set onmessage(handler) {
|
|
622
|
+
this.messageHandler = handler;
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Set close handler
|
|
626
|
+
*/
|
|
627
|
+
set onclose(handler) {
|
|
628
|
+
this.closeHandler = handler;
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Set error handler
|
|
632
|
+
*/
|
|
633
|
+
set onerror(handler) {
|
|
634
|
+
this.errorHandler = handler;
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Start session cleanup interval
|
|
638
|
+
*/
|
|
639
|
+
startSessionCleanup() {
|
|
640
|
+
this.sessionCleanupInterval = setInterval(() => {
|
|
641
|
+
const now = Date.now();
|
|
642
|
+
for (const [sessionId, session] of this.sessions.entries()) {
|
|
643
|
+
if (now - session.lastActivity > this.options.sessionTimeout) {
|
|
644
|
+
// Cleanup expired session
|
|
645
|
+
for (const stream of session.streams.values()) {
|
|
646
|
+
try {
|
|
647
|
+
stream.response.end();
|
|
648
|
+
stream.closed = true;
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
// Ignore
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
this.sessions.delete(sessionId);
|
|
655
|
+
console.error(`Session ${sessionId} expired and cleaned up`);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}, 60000); // Check every minute
|
|
659
|
+
}
|
|
660
|
+
/**
|
|
661
|
+
* Helper methods
|
|
662
|
+
*/
|
|
663
|
+
generateSessionId() {
|
|
664
|
+
return uuidv4();
|
|
665
|
+
}
|
|
666
|
+
getMessageType(message) {
|
|
667
|
+
if ('method' in message && 'id' in message)
|
|
668
|
+
return 'request';
|
|
669
|
+
if ('result' in message || 'error' in message)
|
|
670
|
+
return 'response';
|
|
671
|
+
return 'notification';
|
|
672
|
+
}
|
|
673
|
+
isResponse(message) {
|
|
674
|
+
return 'result' in message || 'error' in message;
|
|
675
|
+
}
|
|
676
|
+
isInitializeRequest(message) {
|
|
677
|
+
return 'method' in message && message.method === 'initialize';
|
|
678
|
+
}
|
|
679
|
+
isLocalhost(host) {
|
|
680
|
+
// Extract hostname without port (host can be "localhost:3000")
|
|
681
|
+
const hostname = host.split(':')[0];
|
|
682
|
+
return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1';
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Get the Express app (for adding custom routes)
|
|
686
|
+
*/
|
|
687
|
+
getApp() {
|
|
688
|
+
return this.app;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
//# sourceMappingURL=streamable-http.js.map
|