converse-mcp-server 1.0.1

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.
@@ -0,0 +1,548 @@
1
+ /**
2
+ * HTTP Streaming Transport for MCP Server
3
+ *
4
+ * Implements StreamableHTTPServerTransport to replace stdio transport,
5
+ * eliminating console output interference and providing better local development experience.
6
+ */
7
+
8
+ import express from 'express';
9
+ import cors from 'cors';
10
+ import { randomUUID } from 'node:crypto';
11
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
12
+ import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
13
+ import { createLogger } from '../utils/logger.js';
14
+
15
+ const logger = createLogger('http-transport');
16
+
17
+ /**
18
+ * HTTP Transport Server for MCP
19
+ * Manages Express server with MCP endpoints and session management
20
+ */
21
+ export class HTTPTransportServer {
22
+ constructor(config = {}) {
23
+ this.config = {
24
+ // Server settings
25
+ port: config.port || 3000,
26
+ host: config.host || 'localhost',
27
+ requestTimeout: config.requestTimeout || 300000,
28
+ maxRequestSize: config.maxRequestSize || '10mb',
29
+
30
+ // Session management
31
+ sessionTimeout: config.sessionTimeout || 1800000,
32
+ sessionCleanupInterval: config.sessionCleanupInterval || 300000,
33
+ maxConcurrentSessions: config.maxConcurrentSessions || 100,
34
+
35
+ // CORS configuration
36
+ enableCors: config.enableCors !== false,
37
+ corsOptions: config.corsOptions || {
38
+ origin: '*',
39
+ methods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
40
+ allowedHeaders: ['Content-Type', 'mcp-session-id', 'Authorization'],
41
+ credentials: false,
42
+ exposedHeaders: ['Mcp-Session-Id'],
43
+ },
44
+
45
+ // Security settings
46
+ enableDnsRebindingProtection: config.enableDnsRebindingProtection || false,
47
+ allowedHosts: config.allowedHosts || ['127.0.0.1', 'localhost'],
48
+ rateLimitEnabled: config.rateLimitEnabled || false,
49
+ rateLimitWindow: config.rateLimitWindow || 900000,
50
+ rateLimitMaxRequests: config.rateLimitMaxRequests || 1000,
51
+
52
+ ...config
53
+ };
54
+
55
+ this.app = express();
56
+ this.server = null;
57
+ this.transports = new Map(); // sessionId -> transport
58
+ this.sessionTimers = new Map(); // sessionId -> timeout timer
59
+ this.mcpServer = null;
60
+ this.isStarted = false;
61
+ this.cleanupInterval = null;
62
+ }
63
+
64
+ /**
65
+ * Initialize the HTTP transport server
66
+ * @param {object} mcpServer - MCP Server instance
67
+ */
68
+ async initialize(mcpServer) {
69
+ this.mcpServer = mcpServer;
70
+ this.setupMiddleware();
71
+ this.setupRoutes();
72
+ return this;
73
+ }
74
+
75
+ /**
76
+ * Setup Express middleware
77
+ */
78
+ setupMiddleware() {
79
+ // JSON parsing with size limit
80
+ this.app.use(express.json({
81
+ limit: this.config.maxRequestSize,
82
+ strict: true
83
+ }));
84
+
85
+ // Request timeout middleware
86
+ this.app.use((req, res, next) => {
87
+ req.setTimeout(this.config.requestTimeout, () => {
88
+ logger.warn('Request timeout', {
89
+ data: {
90
+ method: req.method,
91
+ path: req.path,
92
+ timeout: this.config.requestTimeout
93
+ }
94
+ });
95
+ if (!res.headersSent) {
96
+ res.status(408).json({
97
+ jsonrpc: '2.0',
98
+ error: {
99
+ code: -32000,
100
+ message: 'Request timeout',
101
+ },
102
+ id: null,
103
+ });
104
+ }
105
+ });
106
+ next();
107
+ });
108
+
109
+ // Rate limiting middleware (if enabled)
110
+ if (this.config.rateLimitEnabled) {
111
+ const rateLimitMap = new Map();
112
+
113
+ this.app.use((req, res, next) => {
114
+ const clientId = req.ip || req.connection.remoteAddress;
115
+ const now = Date.now();
116
+ const windowStart = now - this.config.rateLimitWindow;
117
+
118
+ // Clean old entries
119
+ const clientRequests = rateLimitMap.get(clientId) || [];
120
+ const validRequests = clientRequests.filter(time => time > windowStart);
121
+
122
+ if (validRequests.length >= this.config.rateLimitMaxRequests) {
123
+ logger.warn('Rate limit exceeded', {
124
+ data: {
125
+ clientId,
126
+ requests: validRequests.length,
127
+ limit: this.config.rateLimitMaxRequests
128
+ }
129
+ });
130
+ res.status(429).json({
131
+ jsonrpc: '2.0',
132
+ error: {
133
+ code: -32000,
134
+ message: 'Rate limit exceeded',
135
+ },
136
+ id: null,
137
+ });
138
+ return;
139
+ }
140
+
141
+ validRequests.push(now);
142
+ rateLimitMap.set(clientId, validRequests);
143
+ next();
144
+ });
145
+
146
+ logger.debug('Rate limiting enabled', {
147
+ data: {
148
+ window: this.config.rateLimitWindow,
149
+ maxRequests: this.config.rateLimitMaxRequests
150
+ }
151
+ });
152
+ }
153
+
154
+ // CORS configuration for browser clients
155
+ if (this.config.enableCors) {
156
+ this.app.use(cors(this.config.corsOptions));
157
+ logger.debug('CORS enabled for HTTP transport', {
158
+ data: { corsOptions: this.config.corsOptions }
159
+ });
160
+ }
161
+
162
+ // Request logging
163
+ this.app.use((req, res, next) => {
164
+ logger.debug('HTTP request received', {
165
+ data: {
166
+ method: req.method,
167
+ path: req.path,
168
+ sessionId: req.headers['mcp-session-id']
169
+ }
170
+ });
171
+ next();
172
+ });
173
+ }
174
+
175
+ /**
176
+ * Setup MCP HTTP routes
177
+ */
178
+ setupRoutes() {
179
+ // Main MCP endpoint - handles POST requests for client-to-server communication
180
+ this.app.post('/mcp', async (req, res) => {
181
+ await this.handleMcpRequest(req, res);
182
+ });
183
+
184
+ // SSE endpoint - handles GET requests for server-to-client notifications
185
+ this.app.get('/mcp', async (req, res) => {
186
+ await this.handleSseRequest(req, res);
187
+ });
188
+
189
+ // Session termination - handles DELETE requests
190
+ this.app.delete('/mcp', async (req, res) => {
191
+ await this.handleSessionTermination(req, res);
192
+ });
193
+
194
+ // Health check endpoint
195
+ this.app.get('/health', (req, res) => {
196
+ res.json({
197
+ status: 'healthy',
198
+ transport: 'http',
199
+ server: this.mcpServer ? 'connected' : 'disconnected',
200
+ sessions: this.transports.size,
201
+ timestamp: new Date().toISOString()
202
+ });
203
+ });
204
+
205
+ // Server info endpoint
206
+ this.app.get('/info', (req, res) => {
207
+ res.json({
208
+ name: this.mcpServer?.serverCapabilities?.name || 'unknown',
209
+ version: this.mcpServer?.serverCapabilities?.version || 'unknown',
210
+ transport: 'http-streaming',
211
+ endpoints: {
212
+ mcp: '/mcp',
213
+ health: '/health',
214
+ info: '/info'
215
+ },
216
+ sessions: this.transports.size
217
+ });
218
+ });
219
+ }
220
+
221
+ /**
222
+ * Handle MCP POST requests (client-to-server communication)
223
+ */
224
+ async handleMcpRequest(req, res) {
225
+ try {
226
+ const sessionId = req.headers['mcp-session-id'];
227
+ let transport;
228
+
229
+ if (sessionId && this.transports.has(sessionId)) {
230
+ // Reuse existing transport and reset session timeout
231
+ transport = this.transports.get(sessionId);
232
+ this.resetSessionTimeout(sessionId);
233
+ logger.debug('Reusing existing transport', { data: { sessionId } });
234
+ } else if (!sessionId && isInitializeRequest(req.body)) {
235
+ // Check session limit before creating new transport
236
+ if (this.transports.size >= this.config.maxConcurrentSessions) {
237
+ logger.warn('Maximum concurrent sessions reached', {
238
+ data: {
239
+ currentSessions: this.transports.size,
240
+ maxSessions: this.config.maxConcurrentSessions
241
+ }
242
+ });
243
+ res.status(503).json({
244
+ jsonrpc: '2.0',
245
+ error: {
246
+ code: -32000,
247
+ message: 'Maximum concurrent sessions reached. Please try again later.',
248
+ },
249
+ id: null,
250
+ });
251
+ return;
252
+ }
253
+
254
+ // New initialization request
255
+ transport = await this.createNewTransport();
256
+ logger.info('Created new MCP transport', {});
257
+ } else {
258
+ // Invalid request
259
+ logger.warn('Invalid MCP request - no session ID or not initialize request', {
260
+ data: { sessionId, hasInitialize: isInitializeRequest(req.body) }
261
+ });
262
+ res.status(400).json({
263
+ jsonrpc: '2.0',
264
+ error: {
265
+ code: -32000,
266
+ message: 'Bad Request: No valid session ID provided',
267
+ },
268
+ id: null,
269
+ });
270
+ return;
271
+ }
272
+
273
+ // Handle the request through the transport
274
+ await transport.handleRequest(req, res, req.body);
275
+
276
+ } catch (error) {
277
+ logger.error('Error handling MCP request', { error });
278
+ if (!res.headersSent) {
279
+ res.status(500).json({
280
+ jsonrpc: '2.0',
281
+ error: {
282
+ code: -32603,
283
+ message: 'Internal server error',
284
+ },
285
+ id: null,
286
+ });
287
+ }
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Handle SSE GET requests (server-to-client notifications)
293
+ */
294
+ async handleSseRequest(req, res) {
295
+ const sessionId = req.headers['mcp-session-id'];
296
+
297
+ if (!sessionId || !this.transports.has(sessionId)) {
298
+ logger.warn('SSE request with invalid session ID', { data: { sessionId } });
299
+ res.status(400).send('Invalid or missing session ID');
300
+ return;
301
+ }
302
+
303
+ try {
304
+ const transport = this.transports.get(sessionId);
305
+ this.resetSessionTimeout(sessionId);
306
+ await transport.handleRequest(req, res);
307
+ logger.debug('SSE connection established', { data: { sessionId } });
308
+ } catch (error) {
309
+ logger.error('Error handling SSE request', { error, data: { sessionId } });
310
+ if (!res.headersSent) {
311
+ res.status(500).send('Internal server error');
312
+ }
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Handle session termination DELETE requests
318
+ */
319
+ async handleSessionTermination(req, res) {
320
+ const sessionId = req.headers['mcp-session-id'];
321
+
322
+ if (!sessionId || !this.transports.has(sessionId)) {
323
+ logger.warn('Session termination with invalid session ID', { data: { sessionId } });
324
+ res.status(400).send('Invalid or missing session ID');
325
+ return;
326
+ }
327
+
328
+ try {
329
+ const transport = this.transports.get(sessionId);
330
+ await transport.handleRequest(req, res);
331
+
332
+ // Clean up the transport
333
+ this.transports.delete(sessionId);
334
+ logger.info('Session terminated', { data: { sessionId } });
335
+ } catch (error) {
336
+ logger.error('Error terminating session', { error, data: { sessionId } });
337
+ if (!res.headersSent) {
338
+ res.status(500).send('Internal server error');
339
+ }
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Create a new MCP transport with session management
345
+ */
346
+ async createNewTransport() {
347
+ const transport = new StreamableHTTPServerTransport({
348
+ sessionIdGenerator: () => randomUUID(),
349
+ onsessioninitialized: (sessionId) => {
350
+ this.transports.set(sessionId, transport);
351
+ this.setupSessionTimeout(sessionId);
352
+ logger.debug('Transport session initialized', { data: { sessionId } });
353
+ },
354
+ enableDnsRebindingProtection: this.config.enableDnsRebindingProtection,
355
+ allowedHosts: this.config.allowedHosts,
356
+ });
357
+
358
+ // Clean up transport when closed
359
+ transport.onclose = () => {
360
+ if (transport.sessionId) {
361
+ this.cleanupSession(transport.sessionId);
362
+ logger.debug('Transport session closed', {
363
+ data: { sessionId: transport.sessionId }
364
+ });
365
+ }
366
+ };
367
+
368
+ // Connect to the MCP server
369
+ await this.mcpServer.connect(transport);
370
+
371
+ return transport;
372
+ }
373
+
374
+ /**
375
+ * Setup session timeout for a given session
376
+ */
377
+ setupSessionTimeout(sessionId) {
378
+ // Clear existing timeout if any
379
+ if (this.sessionTimers.has(sessionId)) {
380
+ clearTimeout(this.sessionTimers.get(sessionId));
381
+ }
382
+
383
+ // Set new timeout
384
+ const timeoutId = setTimeout(() => {
385
+ logger.info('Session timeout expired', { data: { sessionId } });
386
+ this.cleanupSession(sessionId);
387
+ }, this.config.sessionTimeout);
388
+
389
+ this.sessionTimers.set(sessionId, timeoutId);
390
+ }
391
+
392
+ /**
393
+ * Reset session timeout for active session
394
+ */
395
+ resetSessionTimeout(sessionId) {
396
+ if (this.transports.has(sessionId)) {
397
+ this.setupSessionTimeout(sessionId);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Clean up session resources
403
+ * Note: Following MCP SDK pattern - only clean up our references,
404
+ * let transport.onclose handle its own cleanup
405
+ */
406
+ cleanupSession(sessionId) {
407
+ // Clear timeout
408
+ if (this.sessionTimers.has(sessionId)) {
409
+ clearTimeout(this.sessionTimers.get(sessionId));
410
+ this.sessionTimers.delete(sessionId);
411
+ }
412
+
413
+ // Remove transport from our map (don't call transport.close() here)
414
+ if (this.transports.has(sessionId)) {
415
+ this.transports.delete(sessionId);
416
+ logger.debug('Transport session cleaned up', { data: { sessionId } });
417
+ }
418
+ }
419
+
420
+ /**
421
+ * Periodic cleanup of expired sessions
422
+ */
423
+ startSessionCleanup() {
424
+ if (this.cleanupInterval) {
425
+ clearInterval(this.cleanupInterval);
426
+ }
427
+
428
+ this.cleanupInterval = setInterval(() => {
429
+ logger.debug('Running session cleanup', {
430
+ data: { activeSessions: this.transports.size }
431
+ });
432
+
433
+ // The timeout mechanism handles cleanup automatically,
434
+ // but we can add additional checks here if needed
435
+ }, this.config.sessionCleanupInterval);
436
+ }
437
+
438
+ /**
439
+ * Start the HTTP server
440
+ */
441
+ async start() {
442
+ if (this.isStarted) {
443
+ throw new Error('HTTP transport server is already started');
444
+ }
445
+
446
+ return new Promise((resolve, reject) => {
447
+ this.server = this.app.listen(this.config.port, this.config.host, (err) => {
448
+ if (err) {
449
+ logger.error('Failed to start HTTP transport server', { error: err });
450
+ reject(err);
451
+ return;
452
+ }
453
+
454
+ this.isStarted = true;
455
+ this.startSessionCleanup();
456
+
457
+ const address = this.server.address();
458
+ logger.info('HTTP transport server started', {
459
+ data: {
460
+ host: address.address,
461
+ port: address.port,
462
+ endpoint: `http://${this.config.host}:${address.port}/mcp`,
463
+ sessionTimeout: this.config.sessionTimeout,
464
+ maxSessions: this.config.maxConcurrentSessions
465
+ }
466
+ });
467
+ resolve(address);
468
+ });
469
+ });
470
+ }
471
+
472
+ /**
473
+ * Stop the HTTP server
474
+ */
475
+ async stop() {
476
+ if (!this.isStarted || !this.server) {
477
+ return;
478
+ }
479
+
480
+ return new Promise((resolve) => {
481
+ // Stop session cleanup interval
482
+ if (this.cleanupInterval) {
483
+ clearInterval(this.cleanupInterval);
484
+ this.cleanupInterval = null;
485
+ }
486
+
487
+ // Clear all session timers
488
+ for (const [sessionId, timeoutId] of this.sessionTimers) {
489
+ clearTimeout(timeoutId);
490
+ }
491
+ this.sessionTimers.clear();
492
+
493
+ // Clear transport references (let the server close handle actual transport cleanup)
494
+ this.transports.clear();
495
+
496
+ // Close all active connections first to prevent hanging handles
497
+ if (this.server.listening) {
498
+ this.server.closeAllConnections?.(); // Available in Node 18.02+
499
+ }
500
+
501
+ this.server.close((err) => {
502
+ this.isStarted = false;
503
+ if (err) {
504
+ logger.warn('Error closing HTTP server', { error: err });
505
+ } else {
506
+ logger.info('HTTP transport server stopped');
507
+ }
508
+ resolve();
509
+ });
510
+ });
511
+ }
512
+
513
+ /**
514
+ * Get server status information
515
+ */
516
+ getStatus() {
517
+ return {
518
+ isStarted: this.isStarted,
519
+ port: this.config.port,
520
+ host: this.config.host,
521
+ activeSessions: this.transports.size,
522
+ maxSessions: this.config.maxConcurrentSessions,
523
+ sessionIds: Array.from(this.transports.keys()),
524
+ address: this.server?.address() || null,
525
+ configuration: {
526
+ sessionTimeout: this.config.sessionTimeout,
527
+ sessionCleanupInterval: this.config.sessionCleanupInterval,
528
+ requestTimeout: this.config.requestTimeout,
529
+ maxRequestSize: this.config.maxRequestSize,
530
+ corsEnabled: this.config.enableCors,
531
+ rateLimitEnabled: this.config.rateLimitEnabled,
532
+ dnsRebindingProtection: this.config.enableDnsRebindingProtection
533
+ }
534
+ };
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Factory function to create and configure HTTP transport server
540
+ * @param {object} mcpServer - MCP Server instance
541
+ * @param {object} config - HTTP transport configuration
542
+ * @returns {Promise<HTTPTransportServer>}
543
+ */
544
+ export async function createHTTPTransport(mcpServer, config = {}) {
545
+ const httpTransport = new HTTPTransportServer(config);
546
+ await httpTransport.initialize(mcpServer);
547
+ return httpTransport;
548
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Safe Console Utility
3
+ *
4
+ * Provides console functions that respect MCP transport mode and logging settings.
5
+ * Prevents console output from corrupting stdio transport JSON-RPC streams.
6
+ */
7
+
8
+ /**
9
+ * Check if console output should be suppressed
10
+ * @returns {boolean} True if console output should be suppressed
11
+ */
12
+ function shouldSuppressConsole() {
13
+ return (
14
+ process.env.LOG_LEVEL === 'silent' ||
15
+ process.env.NODE_ENV === 'test' ||
16
+ process.env.MCP_TRANSPORT === 'stdio'
17
+ );
18
+ }
19
+
20
+ /**
21
+ * Safe console.log that respects transport mode
22
+ * @param {...any} args - Arguments to log
23
+ */
24
+ export function debugLog(...args) {
25
+ if (!shouldSuppressConsole()) {
26
+ console.log(...args);
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Safe console.error that respects transport mode
32
+ * @param {...any} args - Arguments to log
33
+ */
34
+ export function debugError(...args) {
35
+ if (!shouldSuppressConsole()) {
36
+ console.error(...args);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Safe console.warn that respects transport mode
42
+ * @param {...any} args - Arguments to log
43
+ */
44
+ export function debugWarn(...args) {
45
+ if (!shouldSuppressConsole()) {
46
+ console.warn(...args);
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Force console output (for critical errors only)
52
+ * @param {...any} args - Arguments to log
53
+ */
54
+ export function forceLog(...args) {
55
+ console.log(...args);
56
+ }
57
+
58
+ /**
59
+ * Force console error output (for critical errors only)
60
+ * @param {...any} args - Arguments to log
61
+ */
62
+ export function forceError(...args) {
63
+ console.error(...args);
64
+ }