@goonnguyen/human-mcp 1.2.0 → 1.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.
Files changed (71) hide show
  1. package/.claude/agents/project-manager.md +2 -2
  2. package/.env.example +28 -1
  3. package/.github/workflows/publish.yml +43 -6
  4. package/.opencode/agent/code-reviewer.md +142 -0
  5. package/.opencode/agent/debugger.md +74 -0
  6. package/.opencode/agent/docs-manager.md +119 -0
  7. package/.opencode/agent/git-manager.md +60 -0
  8. package/.opencode/agent/planner-researcher.md +100 -0
  9. package/.opencode/agent/project-manager.md +113 -0
  10. package/.opencode/agent/system-architecture.md +200 -0
  11. package/.opencode/agent/tester.md +96 -0
  12. package/.opencode/agent/ui-ux-developer.md +97 -0
  13. package/.opencode/command/cook.md +7 -0
  14. package/.opencode/command/debug.md +10 -0
  15. package/.opencode/command/fix/ci.md +8 -0
  16. package/.opencode/command/fix/fast.md +5 -0
  17. package/.opencode/command/fix/hard.md +7 -0
  18. package/.opencode/command/fix/test.md +16 -0
  19. package/.opencode/command/git/cm.md +5 -0
  20. package/.opencode/command/git/cp.md +4 -0
  21. package/.opencode/command/plan/ci.md +12 -0
  22. package/.opencode/command/plan/two.md +13 -0
  23. package/.opencode/command/plan.md +10 -0
  24. package/.opencode/command/test.md +7 -0
  25. package/.opencode/command/watzup.md +8 -0
  26. package/CHANGELOG.md +21 -0
  27. package/CLAUDE.md +5 -3
  28. package/QUICKSTART.md +3 -3
  29. package/README.md +551 -20
  30. package/bun.lock +275 -3
  31. package/dist/index.js +71091 -17256
  32. package/docs/README.md +51 -0
  33. package/docs/codebase-structure-architecture-code-standards.md +17 -5
  34. package/docs/project-overview-pdr.md +37 -21
  35. package/docs/project-roadmap.md +494 -0
  36. package/human-mcp.png +0 -0
  37. package/package.json +9 -1
  38. package/plans/002-sse-fallback-http-transport-plan.md +161 -0
  39. package/plans/003-fix-test-infrastructure-and-ci-plan.md +699 -0
  40. package/plans/003-http-transport-local-file-access-plan.md +880 -0
  41. package/plans/004-fix-typescript-compilation-errors-plan.md +388 -0
  42. package/plans/005-comprehensive-test-infrastructure-fix-plan.md +854 -0
  43. package/src/index.ts +2 -0
  44. package/src/tools/eyes/index.ts +7 -7
  45. package/src/tools/eyes/processors/image.ts +90 -0
  46. package/src/transports/http/file-interceptor.ts +134 -0
  47. package/src/transports/http/routes.ts +165 -4
  48. package/src/transports/http/server.ts +64 -14
  49. package/src/transports/http/session.ts +11 -3
  50. package/src/transports/http/sse-routes.ts +210 -0
  51. package/src/transports/index.ts +11 -6
  52. package/src/transports/types.ts +13 -0
  53. package/src/utils/cloudflare-r2.ts +107 -0
  54. package/src/utils/config.ts +26 -0
  55. package/tests/integration/http-transport-files.test.ts +190 -0
  56. package/tests/integration/server.test.ts +4 -1
  57. package/tests/integration/sse-transport.test.ts +142 -0
  58. package/tests/setup.ts +45 -1
  59. package/tests/types/api-responses.ts +35 -0
  60. package/tests/types/test-types.ts +105 -0
  61. package/tests/unit/cloudflare-r2.test.ts +118 -0
  62. package/tests/unit/eyes-analyze.test.ts +150 -0
  63. package/tests/unit/formatters.test.ts +1 -1
  64. package/tests/unit/sse-routes.test.ts +92 -0
  65. package/tests/utils/error-scenarios.ts +198 -0
  66. package/tests/utils/index.ts +3 -0
  67. package/tests/utils/mock-helpers.ts +99 -0
  68. package/tests/utils/test-data-generators.ts +217 -0
  69. package/tests/utils/test-server-manager.ts +172 -0
  70. package/tsconfig.json +1 -1
  71. package/plans/reports/001-from-qa-engineer-to-development-team-test-suite-report.md +0 -188
package/src/index.ts CHANGED
@@ -18,6 +18,8 @@ async function main() {
18
18
  sessionMode: config.transport.http.sessionMode,
19
19
  enableSse: config.transport.http.enableSse,
20
20
  enableJsonResponse: config.transport.http.enableJsonResponse,
21
+ enableSseFallback: config.transport.http.enableSseFallback,
22
+ ssePaths: config.transport.http.ssePaths,
21
23
  security: config.transport.http.security
22
24
  } : undefined
23
25
  };
@@ -17,9 +17,9 @@ import type { Config } from "@/utils/config.js";
17
17
  export async function registerEyesTool(server: McpServer, config: Config) {
18
18
  const geminiClient = new GeminiClient(config);
19
19
 
20
- // Register eyes.analyze tool
20
+ // Register eyes_analyze tool
21
21
  server.registerTool(
22
- "eyes.analyze",
22
+ "eyes_analyze",
23
23
  {
24
24
  title: "Vision Analysis Tool",
25
25
  description: "Analyze images, videos, and GIFs using AI vision capabilities",
@@ -36,7 +36,7 @@ export async function registerEyesTool(server: McpServer, config: Config) {
36
36
  return await handleAnalyze(geminiClient, args, config);
37
37
  } catch (error) {
38
38
  const mcpError = handleError(error);
39
- logger.error(`Tool eyes.analyze error:`, mcpError);
39
+ logger.error(`Tool eyes_analyze error:`, mcpError);
40
40
 
41
41
  return {
42
42
  content: [{
@@ -49,9 +49,9 @@ export async function registerEyesTool(server: McpServer, config: Config) {
49
49
  }
50
50
  );
51
51
 
52
- // Register eyes.compare tool
52
+ // Register eyes_compare tool
53
53
  server.registerTool(
54
- "eyes.compare",
54
+ "eyes_compare",
55
55
  {
56
56
  title: "Image Comparison Tool",
57
57
  description: "Compare two images and identify differences",
@@ -66,7 +66,7 @@ export async function registerEyesTool(server: McpServer, config: Config) {
66
66
  return await handleCompare(geminiClient, args);
67
67
  } catch (error) {
68
68
  const mcpError = handleError(error);
69
- logger.error(`Tool eyes.compare error:`, mcpError);
69
+ logger.error(`Tool eyes_compare error:`, mcpError);
70
70
 
71
71
  return {
72
72
  content: [{
@@ -173,7 +173,7 @@ Be precise with locations and measurements where possible.`;
173
173
  }
174
174
  ]);
175
175
 
176
- const result = await response.response;
176
+ const result = response.response;
177
177
  const comparisonText = result.text();
178
178
 
179
179
  return {
@@ -5,6 +5,7 @@ import type { AnalysisOptions, ProcessingResult } from "@/types";
5
5
  import { createPrompt, parseAnalysisResponse } from "../utils/formatters.js";
6
6
  import { logger } from "@/utils/logger.js";
7
7
  import { ProcessingError } from "@/utils/errors.js";
8
+ import { getCloudflareR2 } from "@/utils/cloudflare-r2.js";
8
9
 
9
10
  export async function processImage(
10
11
  model: GenerativeModel,
@@ -58,6 +59,50 @@ export async function processImage(
58
59
  }
59
60
 
60
61
  async function loadImage(source: string, fetchTimeout?: number): Promise<{ imageData: string; mimeType: string }> {
62
+ // Detect Claude Desktop virtual paths and auto-upload to Cloudflare
63
+ if (source.startsWith('/mnt/user-data/') || source.startsWith('/mnt/')) {
64
+ logger.info(`Detected Claude Desktop virtual path: ${source}`);
65
+
66
+ // Extract filename from path
67
+ const filename = source.split('/').pop() || 'upload.jpg';
68
+
69
+ // Try to read from a temporary upload directory (if middleware saved it)
70
+ const tempPath = `/tmp/mcp-uploads/${filename}`;
71
+
72
+ try {
73
+ // Check if file was temporarily saved by middleware
74
+ if (await fs.access(tempPath).then(() => true).catch(() => false)) {
75
+ const buffer = await fs.readFile(tempPath);
76
+
77
+ // Upload to Cloudflare R2 if configured
78
+ const cloudflare = getCloudflareR2();
79
+ if (cloudflare) {
80
+ const publicUrl = await cloudflare.uploadFile(buffer, filename);
81
+
82
+ // Clean up temp file
83
+ await fs.unlink(tempPath).catch(() => {});
84
+
85
+ // Now fetch from the CDN URL
86
+ return loadImage(publicUrl, fetchTimeout);
87
+ }
88
+ }
89
+ } catch (error) {
90
+ logger.warn(`Could not process temp file: ${error instanceof Error ? error.message : 'Unknown error'}`);
91
+ }
92
+
93
+ // If no temp file or Cloudflare not configured, provide helpful error
94
+ throw new ProcessingError(
95
+ `Local file access not supported via HTTP transport.\n` +
96
+ `The file path "${source}" is not accessible.\n\n` +
97
+ `Solutions:\n` +
98
+ `1. Upload your file to Cloudflare R2 first using the /mcp/upload endpoint\n` +
99
+ `2. Use a public URL instead of a local file path\n` +
100
+ `3. Convert the image to a base64 data URI\n` +
101
+ `4. Use the stdio transport for local file access`
102
+ );
103
+ }
104
+
105
+ // Existing base64 handling
61
106
  if (source.startsWith('data:image/')) {
62
107
  const [header, data] = source.split(',');
63
108
  if (!header || !data) {
@@ -67,12 +112,27 @@ async function loadImage(source: string, fetchTimeout?: number): Promise<{ image
67
112
  if (!mimeMatch || !mimeMatch[1]) {
68
113
  throw new ProcessingError("Invalid base64 image format");
69
114
  }
115
+
116
+ // Optional: For large base64 images, upload to Cloudflare R2 if configured
117
+ const cloudflare = getCloudflareR2();
118
+ if (cloudflare && data.length > 1024 * 1024) { // > 1MB base64
119
+ logger.info('Large base64 image detected, uploading to Cloudflare R2');
120
+ try {
121
+ const publicUrl = await cloudflare.uploadBase64(data, mimeMatch[1]);
122
+ return loadImage(publicUrl, fetchTimeout);
123
+ } catch (error) {
124
+ logger.warn('Failed to upload large base64 to Cloudflare R2:', error);
125
+ // Continue with base64 processing
126
+ }
127
+ }
128
+
70
129
  return {
71
130
  imageData: data,
72
131
  mimeType: mimeMatch[1]
73
132
  };
74
133
  }
75
134
 
135
+ // Existing URL handling
76
136
  if (source.startsWith('http://') || source.startsWith('https://')) {
77
137
  const controller = new AbortController();
78
138
  const timeoutId = setTimeout(() => controller.abort(), fetchTimeout || 30000);
@@ -106,7 +166,31 @@ async function loadImage(source: string, fetchTimeout?: number): Promise<{ image
106
166
  }
107
167
  }
108
168
 
169
+ // Local file handling - auto-upload to Cloudflare for HTTP transport
109
170
  try {
171
+ const stats = await fs.stat(source);
172
+ if (!stats.isFile()) {
173
+ throw new ProcessingError(`Path is not a file: ${source}`);
174
+ }
175
+
176
+ // If using HTTP transport, upload to Cloudflare R2 if configured
177
+ const cloudflare = getCloudflareR2();
178
+ if (process.env.TRANSPORT_TYPE === 'http' && cloudflare) {
179
+ logger.info(`HTTP transport detected, uploading local file to Cloudflare R2: ${source}`);
180
+ try {
181
+ const buffer = await fs.readFile(source);
182
+ const filename = source.split('/').pop() || 'upload.jpg';
183
+ const publicUrl = await cloudflare.uploadFile(buffer, filename);
184
+
185
+ // Fetch from CDN
186
+ return loadImage(publicUrl, fetchTimeout);
187
+ } catch (error) {
188
+ logger.warn(`Failed to upload to Cloudflare R2: ${error instanceof Error ? error.message : 'Unknown error'}`);
189
+ // Continue with local file processing
190
+ }
191
+ }
192
+
193
+ // For stdio transport or when Cloudflare is not configured, process locally
110
194
  const buffer = await fs.readFile(source);
111
195
  const processedImage = await sharp(buffer)
112
196
  .resize(1024, 1024, { fit: 'inside', withoutEnlargement: true })
@@ -118,6 +202,12 @@ async function loadImage(source: string, fetchTimeout?: number): Promise<{ image
118
202
  mimeType: 'image/jpeg'
119
203
  };
120
204
  } catch (error) {
205
+ if (error instanceof Error && error.message.includes('ENOENT')) {
206
+ throw new ProcessingError(
207
+ `File not found: ${source}\n` +
208
+ `When using HTTP transport, files are automatically uploaded to Cloudflare R2 if configured.`
209
+ );
210
+ }
121
211
  throw new ProcessingError(`Failed to load image file: ${error instanceof Error ? error.message : 'Unknown error'}`);
122
212
  }
123
213
  }
@@ -0,0 +1,134 @@
1
+ import type { Request, Response, NextFunction } from 'express';
2
+ import { getCloudflareR2 } from '@/utils/cloudflare-r2.js';
3
+ import { logger } from '@/utils/logger.js';
4
+ import fs from 'fs/promises';
5
+ import path from 'path';
6
+
7
+ export async function fileInterceptorMiddleware(
8
+ req: Request,
9
+ res: Response,
10
+ next: NextFunction
11
+ ) {
12
+ // Only intercept tool calls with file paths
13
+ if (req.body?.method === 'tools/call' && req.body?.params?.arguments) {
14
+ const args = req.body.params.arguments;
15
+
16
+ // Check for source fields that might contain file paths
17
+ const fileFields = ['source', 'source1', 'source2', 'path', 'filePath'];
18
+
19
+ for (const field of fileFields) {
20
+ if (args[field] && typeof args[field] === 'string') {
21
+ const filePath = args[field];
22
+
23
+ // Detect Claude Desktop virtual paths
24
+ if (filePath.startsWith('/mnt/user-data/') || filePath.startsWith('/mnt/')) {
25
+ logger.info(`Intercepting Claude Desktop virtual path: ${filePath}`);
26
+
27
+ try {
28
+ // Extract filename
29
+ const filename = path.basename(filePath);
30
+
31
+ // Check if we have a temporary file saved by Claude Desktop
32
+ const tempPath = path.join('/tmp/claude-uploads', filename);
33
+
34
+ if (await fs.access(tempPath).then(() => true).catch(() => false)) {
35
+ const cloudflare = getCloudflareR2();
36
+ if (cloudflare) {
37
+ // File exists in temp, upload to Cloudflare
38
+ const buffer = await fs.readFile(tempPath);
39
+ const publicUrl = await cloudflare.uploadFile(buffer, filename);
40
+
41
+ // Replace the virtual path with CDN URL
42
+ args[field] = publicUrl;
43
+
44
+ // Clean up temp file
45
+ await fs.unlink(tempPath).catch(() => {});
46
+
47
+ logger.info(`Replaced virtual path with CDN URL: ${publicUrl}`);
48
+ }
49
+ } else {
50
+ // No temp file, try to extract from request if it's base64
51
+ // This handles cases where Claude Desktop might send base64 inline
52
+ if ((req.body.params as any).fileData && (req.body.params as any).fileData[field]) {
53
+ const base64Data = (req.body.params as any).fileData[field];
54
+ const mimeType = (req.body.params as any).fileMimeTypes?.[field] || 'image/jpeg';
55
+
56
+ const cloudflare = getCloudflareR2();
57
+ if (cloudflare) {
58
+ const publicUrl = await cloudflare.uploadBase64(
59
+ base64Data,
60
+ mimeType,
61
+ filename
62
+ );
63
+
64
+ args[field] = publicUrl;
65
+ logger.info(`Uploaded inline base64 to CDN: ${publicUrl}`);
66
+ }
67
+ } else {
68
+ // Provide helpful error response
69
+ logger.warn(`Cannot access virtual path: ${filePath}`);
70
+ return res.status(400).json({
71
+ jsonrpc: '2.0',
72
+ error: {
73
+ code: -32602,
74
+ message: 'File not accessible via HTTP transport',
75
+ data: {
76
+ path: filePath,
77
+ suggestions: [
78
+ 'Upload the file using the /mcp/upload endpoint first',
79
+ 'Use a public URL instead of a local file path',
80
+ 'Convert the image to a base64 data URI',
81
+ 'Switch to stdio transport for local file access'
82
+ ]
83
+ }
84
+ },
85
+ id: req.body.id
86
+ });
87
+ }
88
+ }
89
+ } catch (error) {
90
+ logger.error(`Error processing virtual path: ${error instanceof Error ? error.message : 'Unknown error'}`);
91
+ return res.status(500).json({
92
+ jsonrpc: '2.0',
93
+ error: {
94
+ code: -32603,
95
+ message: `Failed to process file: ${error instanceof Error ? error.message : 'Unknown error'}`
96
+ },
97
+ id: req.body.id
98
+ });
99
+ }
100
+ }
101
+
102
+ // Handle regular local paths when in HTTP mode
103
+ else if (!filePath.startsWith('http') && !filePath.startsWith('data:')) {
104
+ if (process.env.TRANSPORT_TYPE === 'http') {
105
+ const cloudflare = getCloudflareR2();
106
+ if (cloudflare) {
107
+ try {
108
+ // Check if file exists locally
109
+ await fs.access(filePath);
110
+
111
+ // Upload to Cloudflare R2
112
+ const buffer = await fs.readFile(filePath);
113
+ const filename = path.basename(filePath);
114
+ const publicUrl = await cloudflare.uploadFile(buffer, filename);
115
+
116
+ // Replace local path with CDN URL
117
+ args[field] = publicUrl;
118
+
119
+ logger.info(`Auto-uploaded local file to CDN: ${publicUrl}`);
120
+ } catch (error) {
121
+ if (error instanceof Error && error.message.includes('ENOENT')) {
122
+ logger.warn(`Local file not found: ${filePath}`);
123
+ }
124
+ // Continue without modification if file doesn't exist or cloudflare not configured
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ next();
134
+ }
@@ -1,15 +1,23 @@
1
1
  import { Router } from "express";
2
- import { randomUUID } from "node:crypto";
3
2
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6
5
  import { SessionManager } from "./session.js";
7
6
  import type { HttpTransportConfig } from "../types.js";
7
+ import multer from 'multer';
8
+ import { getCloudflareR2 } from '@/utils/cloudflare-r2.js';
9
+ import { logger } from '@/utils/logger.js';
10
+
11
+ // Interface for SSE session checking (to avoid circular dependency)
12
+ interface SSESessionChecker {
13
+ hasSession(sessionId: string): boolean;
14
+ }
8
15
 
9
16
  export function createRoutes(
10
17
  mcpServer: McpServer,
11
18
  sessionManager: SessionManager,
12
- config: HttpTransportConfig
19
+ config: HttpTransportConfig,
20
+ sseSessionChecker?: SSESessionChecker
13
21
  ): Router {
14
22
  const router = Router();
15
23
 
@@ -21,7 +29,7 @@ export function createRoutes(
21
29
  if (config.sessionMode === 'stateless') {
22
30
  await handleStatelessRequest(mcpServer, req, res);
23
31
  } else {
24
- await handleStatefulRequest(mcpServer, sessionManager, sessionId, req, res);
32
+ await handleStatefulRequest(mcpServer, sessionManager, sessionId, req, res, sseSessionChecker);
25
33
  }
26
34
  } catch (error) {
27
35
  handleError(res, error);
@@ -72,6 +80,145 @@ export function createRoutes(
72
80
  res.status(204).send();
73
81
  });
74
82
 
83
+ // Configure multer for memory storage
84
+ const upload = multer({
85
+ storage: multer.memoryStorage(),
86
+ limits: {
87
+ fileSize: 100 * 1024 * 1024, // 100MB limit
88
+ },
89
+ fileFilter: (req, file, cb) => {
90
+ // Accept images, videos, and GIFs
91
+ if (file.mimetype.startsWith('image/') ||
92
+ file.mimetype.startsWith('video/') ||
93
+ file.mimetype === 'image/gif') {
94
+ cb(null, true);
95
+ } else {
96
+ cb(new Error('Invalid file type. Only images and videos are allowed.'));
97
+ }
98
+ }
99
+ });
100
+
101
+ // POST /upload - Handle file uploads to Cloudflare R2
102
+ router.post('/upload', upload.single('file'), async (req, res) => {
103
+ try {
104
+ if (!req.file) {
105
+ res.status(400).json({
106
+ jsonrpc: '2.0',
107
+ error: {
108
+ code: -32600,
109
+ message: 'No file uploaded'
110
+ },
111
+ id: null
112
+ });
113
+ return;
114
+ }
115
+
116
+ const cloudflare = getCloudflareR2();
117
+ if (!cloudflare) {
118
+ res.status(500).json({
119
+ jsonrpc: '2.0',
120
+ error: {
121
+ code: -32603,
122
+ message: 'Cloudflare R2 not configured. Please set up environment variables.'
123
+ },
124
+ id: null
125
+ });
126
+ return;
127
+ }
128
+
129
+ // Upload to Cloudflare R2
130
+ const publicUrl = await cloudflare.uploadFile(
131
+ req.file.buffer,
132
+ req.file.originalname
133
+ );
134
+
135
+ res.json({
136
+ jsonrpc: '2.0',
137
+ result: {
138
+ success: true,
139
+ url: publicUrl,
140
+ originalName: req.file.originalname,
141
+ size: req.file.size,
142
+ mimeType: req.file.mimetype,
143
+ message: 'File uploaded successfully to Cloudflare R2'
144
+ },
145
+ id: (req.body as any)?.id || null
146
+ });
147
+ } catch (error) {
148
+ logger.error('Upload error:', error);
149
+ res.status(500).json({
150
+ jsonrpc: '2.0',
151
+ error: {
152
+ code: -32603,
153
+ message: `Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`
154
+ },
155
+ id: (req.body as any)?.id || null
156
+ });
157
+ }
158
+ });
159
+
160
+ // POST /upload-base64 - Handle base64 uploads
161
+ router.post('/upload-base64', async (req, res) => {
162
+ try {
163
+ const { data, mimeType, filename } = req.body;
164
+
165
+ if (!data || !mimeType) {
166
+ res.status(400).json({
167
+ jsonrpc: '2.0',
168
+ error: {
169
+ code: -32600,
170
+ message: 'Missing required fields: data and mimeType'
171
+ },
172
+ id: null
173
+ });
174
+ return;
175
+ }
176
+
177
+ const cloudflare = getCloudflareR2();
178
+ if (!cloudflare) {
179
+ res.status(500).json({
180
+ jsonrpc: '2.0',
181
+ error: {
182
+ code: -32603,
183
+ message: 'Cloudflare R2 not configured. Please set up environment variables.'
184
+ },
185
+ id: null
186
+ });
187
+ return;
188
+ }
189
+
190
+ // Remove data URI prefix if present
191
+ const base64Data = data.replace(/^data:.*?;base64,/, '');
192
+
193
+ // Upload to Cloudflare R2
194
+ const publicUrl = await cloudflare.uploadBase64(
195
+ base64Data,
196
+ mimeType,
197
+ filename
198
+ );
199
+
200
+ res.json({
201
+ jsonrpc: '2.0',
202
+ result: {
203
+ success: true,
204
+ url: publicUrl,
205
+ message: 'Base64 data uploaded successfully to Cloudflare R2'
206
+ },
207
+ id: req.body?.id || null
208
+ });
209
+ } catch (error) {
210
+ logger.error('Base64 upload error:', error);
211
+ res.status(500).json({
212
+ jsonrpc: '2.0',
213
+ error: {
214
+ code: -32603,
215
+ message: `Failed to upload base64 data: ${error instanceof Error ? error.message : 'Unknown error'}`
216
+ },
217
+ id: req.body?.id || null
218
+ });
219
+ }
220
+ });
221
+
75
222
  return router;
76
223
  }
77
224
 
@@ -97,8 +244,22 @@ async function handleStatefulRequest(
97
244
  sessionManager: SessionManager,
98
245
  sessionId: string | undefined,
99
246
  req: any,
100
- res: any
247
+ res: any,
248
+ sseSessionChecker?: SSESessionChecker
101
249
  ): Promise<void> {
250
+ // Check if sessionId is being used by SSE transport
251
+ if (sessionId && sseSessionChecker?.hasSession(sessionId)) {
252
+ res.status(400).json({
253
+ jsonrpc: '2.0',
254
+ error: {
255
+ code: -32600,
256
+ message: 'Session ID is already in use by SSE transport',
257
+ },
258
+ id: null,
259
+ });
260
+ return;
261
+ }
262
+
102
263
  let transport = sessionId ?
103
264
  await sessionManager.getTransport(sessionId) : null;
104
265
 
@@ -4,14 +4,16 @@ import compression from "compression";
4
4
  import helmet from "helmet";
5
5
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
6
  import { createRoutes } from "./routes.js";
7
+ import { createSSERoutes } from "./sse-routes.js";
7
8
  import { SessionManager } from "./session.js";
8
9
  import { createSecurityMiddleware } from "./middleware.js";
9
- import type { HttpTransportConfig } from "../types.js";
10
+ import { fileInterceptorMiddleware } from "./file-interceptor.js";
11
+ import type { HttpTransportConfig, HttpServerHandle } from "../types.js";
10
12
 
11
13
  export async function startHttpTransport(
12
14
  mcpServer: McpServer,
13
15
  config: HttpTransportConfig
14
- ): Promise<void> {
16
+ ): Promise<HttpServerHandle> {
15
17
  const app = express();
16
18
  const sessionManager = new SessionManager(config.sessionMode, config);
17
19
 
@@ -27,40 +29,88 @@ export async function startHttpTransport(
27
29
  app.use(cors({
28
30
  origin: config.security?.corsOrigins || '*',
29
31
  exposedHeaders: ['Mcp-Session-Id'],
30
- allowedHeaders: ['Content-Type', 'mcp-session-id'],
32
+ allowedHeaders: ['Content-Type', 'mcp-session-id', 'Authorization'],
31
33
  credentials: true
32
34
  }));
33
35
  }
34
36
 
35
37
  app.use(createSecurityMiddleware(config.security));
38
+
39
+ // Add file interceptor middleware before routes
40
+ app.use(fileInterceptorMiddleware);
41
+
42
+ // Create SSE routes first if enabled to get SSE manager reference
43
+ let sseManager: any = undefined;
44
+ if (config.enableSseFallback) {
45
+ console.log('Enabling SSE fallback transport');
46
+ const sseRoutes = createSSERoutes(mcpServer, config, sessionManager);
47
+ app.use(sseRoutes);
48
+
49
+ // Store SSE manager reference for cleanup and cross-validation
50
+ sseManager = (sseRoutes as any).sseManager;
51
+ (app as any).sseManager = sseManager;
52
+ }
36
53
 
37
- // Create routes
38
- const routes = createRoutes(mcpServer, sessionManager, config);
54
+ // Create routes with SSE session checker
55
+ const routes = createRoutes(mcpServer, sessionManager, config, sseManager);
39
56
  app.use('/mcp', routes);
40
57
 
41
58
  // Health check endpoint
42
59
  app.get('/health', (req, res) => {
43
- res.json({ status: 'healthy', transport: 'streamable-http' });
60
+ const healthStatus: any = {
61
+ status: 'healthy',
62
+ transport: 'streamable-http'
63
+ };
64
+
65
+ if (config.enableSseFallback) {
66
+ healthStatus.sseFallback = 'enabled';
67
+ healthStatus.ssePaths = config.ssePaths;
68
+ }
69
+
70
+ res.json(healthStatus);
44
71
  });
45
72
 
46
73
  // Start server
47
74
  const port = config.port || 3000;
48
75
  const host = config.host || '0.0.0.0';
49
76
 
50
- app.listen(port, host, () => {
77
+ const server = app.listen(port, host, () => {
51
78
  console.log(`MCP HTTP Server listening on http://${host}:${port}`);
52
79
  console.log(`Health check: http://${host}:${port}/health`);
53
80
  console.log(`MCP endpoint: http://${host}:${port}/mcp`);
54
81
  });
55
82
 
56
- // Graceful shutdown handling
57
- process.on('SIGTERM', async () => {
83
+ // Create cleanup function
84
+ const cleanup = async () => {
58
85
  console.log('Shutting down HTTP server...');
59
86
  await sessionManager.cleanup();
60
- });
87
+
88
+ // Cleanup SSE sessions if enabled
89
+ if (config.enableSseFallback && (app as any).sseManager) {
90
+ await (app as any).sseManager.cleanup();
91
+ }
92
+ };
61
93
 
62
- process.on('SIGINT', async () => {
63
- console.log('Shutting down HTTP server...');
64
- await sessionManager.cleanup();
65
- });
94
+ // Graceful shutdown handling
95
+ process.on('SIGTERM', cleanup);
96
+ process.on('SIGINT', cleanup);
97
+
98
+ // Return server handle
99
+ const handle: HttpServerHandle = {
100
+ app,
101
+ server,
102
+ sessionManager,
103
+ sseManager,
104
+ async close() {
105
+ await cleanup();
106
+ return new Promise<void>((resolve, reject) => {
107
+ server.close((err) => {
108
+ if (err) reject(err);
109
+ else resolve();
110
+ });
111
+ });
112
+ }
113
+ };
114
+
115
+ return handle;
66
116
  }
@@ -63,8 +63,11 @@ export class SessionManager {
63
63
  async terminateSession(sessionId: string): Promise<void> {
64
64
  const transport = this.transports.get(sessionId);
65
65
  if (transport) {
66
- transport.close();
66
+ // Remove from map first to prevent circular cleanup
67
67
  this.transports.delete(sessionId);
68
+ // Clear the onclose handler to prevent recursion
69
+ transport.onclose = undefined;
70
+ transport.close();
68
71
  }
69
72
 
70
73
  if (this.store) {
@@ -73,10 +76,15 @@ export class SessionManager {
73
76
  }
74
77
 
75
78
  async cleanup(): Promise<void> {
76
- for (const [sessionId, transport] of this.transports) {
79
+ // Create a copy of the map entries to avoid modification during iteration
80
+ const transportEntries = Array.from(this.transports.entries());
81
+ this.transports.clear();
82
+
83
+ for (const [sessionId, transport] of transportEntries) {
84
+ // Clear the onclose handler to prevent recursion during cleanup
85
+ transport.onclose = undefined;
77
86
  transport.close();
78
87
  }
79
- this.transports.clear();
80
88
 
81
89
  if (this.store) {
82
90
  await this.store.cleanup();