@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.
- package/.claude/agents/project-manager.md +2 -2
- package/.env.example +28 -1
- package/.github/workflows/publish.yml +43 -6
- package/.opencode/agent/code-reviewer.md +142 -0
- package/.opencode/agent/debugger.md +74 -0
- package/.opencode/agent/docs-manager.md +119 -0
- package/.opencode/agent/git-manager.md +60 -0
- package/.opencode/agent/planner-researcher.md +100 -0
- package/.opencode/agent/project-manager.md +113 -0
- package/.opencode/agent/system-architecture.md +200 -0
- package/.opencode/agent/tester.md +96 -0
- package/.opencode/agent/ui-ux-developer.md +97 -0
- package/.opencode/command/cook.md +7 -0
- package/.opencode/command/debug.md +10 -0
- package/.opencode/command/fix/ci.md +8 -0
- package/.opencode/command/fix/fast.md +5 -0
- package/.opencode/command/fix/hard.md +7 -0
- package/.opencode/command/fix/test.md +16 -0
- package/.opencode/command/git/cm.md +5 -0
- package/.opencode/command/git/cp.md +4 -0
- package/.opencode/command/plan/ci.md +12 -0
- package/.opencode/command/plan/two.md +13 -0
- package/.opencode/command/plan.md +10 -0
- package/.opencode/command/test.md +7 -0
- package/.opencode/command/watzup.md +8 -0
- package/CHANGELOG.md +21 -0
- package/CLAUDE.md +5 -3
- package/QUICKSTART.md +3 -3
- package/README.md +551 -20
- package/bun.lock +275 -3
- package/dist/index.js +71091 -17256
- package/docs/README.md +51 -0
- package/docs/codebase-structure-architecture-code-standards.md +17 -5
- package/docs/project-overview-pdr.md +37 -21
- package/docs/project-roadmap.md +494 -0
- package/human-mcp.png +0 -0
- package/package.json +9 -1
- package/plans/002-sse-fallback-http-transport-plan.md +161 -0
- package/plans/003-fix-test-infrastructure-and-ci-plan.md +699 -0
- package/plans/003-http-transport-local-file-access-plan.md +880 -0
- package/plans/004-fix-typescript-compilation-errors-plan.md +388 -0
- package/plans/005-comprehensive-test-infrastructure-fix-plan.md +854 -0
- package/src/index.ts +2 -0
- package/src/tools/eyes/index.ts +7 -7
- package/src/tools/eyes/processors/image.ts +90 -0
- package/src/transports/http/file-interceptor.ts +134 -0
- package/src/transports/http/routes.ts +165 -4
- package/src/transports/http/server.ts +64 -14
- package/src/transports/http/session.ts +11 -3
- package/src/transports/http/sse-routes.ts +210 -0
- package/src/transports/index.ts +11 -6
- package/src/transports/types.ts +13 -0
- package/src/utils/cloudflare-r2.ts +107 -0
- package/src/utils/config.ts +26 -0
- package/tests/integration/http-transport-files.test.ts +190 -0
- package/tests/integration/server.test.ts +4 -1
- package/tests/integration/sse-transport.test.ts +142 -0
- package/tests/setup.ts +45 -1
- package/tests/types/api-responses.ts +35 -0
- package/tests/types/test-types.ts +105 -0
- package/tests/unit/cloudflare-r2.test.ts +118 -0
- package/tests/unit/eyes-analyze.test.ts +150 -0
- package/tests/unit/formatters.test.ts +1 -1
- package/tests/unit/sse-routes.test.ts +92 -0
- package/tests/utils/error-scenarios.ts +198 -0
- package/tests/utils/index.ts +3 -0
- package/tests/utils/mock-helpers.ts +99 -0
- package/tests/utils/test-data-generators.ts +217 -0
- package/tests/utils/test-server-manager.ts +172 -0
- package/tsconfig.json +1 -1
- 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
|
};
|
package/src/tools/eyes/index.ts
CHANGED
|
@@ -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
|
|
20
|
+
// Register eyes_analyze tool
|
|
21
21
|
server.registerTool(
|
|
22
|
-
"
|
|
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
|
|
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
|
|
52
|
+
// Register eyes_compare tool
|
|
53
53
|
server.registerTool(
|
|
54
|
-
"
|
|
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
|
|
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 =
|
|
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
|
|
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<
|
|
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
|
-
|
|
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
|
-
//
|
|
57
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|