@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
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import type { Request, Response } from "express";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
|
5
|
+
import type { HttpTransportConfig } from "../types.js";
|
|
6
|
+
import type { SessionManager } from "./session.js";
|
|
7
|
+
|
|
8
|
+
interface SSESession {
|
|
9
|
+
transport: SSEServerTransport;
|
|
10
|
+
createdAt: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SSEManager {
|
|
14
|
+
private sessions = new Map<string, SSESession>();
|
|
15
|
+
|
|
16
|
+
constructor(private config: HttpTransportConfig) {}
|
|
17
|
+
|
|
18
|
+
hasSession(sessionId: string): boolean {
|
|
19
|
+
return this.sessions.has(sessionId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
createSession(endpoint: string, res: Response): SSEServerTransport {
|
|
23
|
+
const transport = new SSEServerTransport(endpoint, res, {
|
|
24
|
+
allowedHosts: this.config.security?.allowedHosts,
|
|
25
|
+
allowedOrigins: this.config.security?.corsOrigins,
|
|
26
|
+
enableDnsRebindingProtection: this.config.security?.enableDnsRebindingProtection
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const session: SSESession = {
|
|
30
|
+
transport,
|
|
31
|
+
createdAt: Date.now()
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
this.sessions.set(transport.sessionId, session);
|
|
35
|
+
|
|
36
|
+
// Cleanup on close
|
|
37
|
+
transport.onclose = () => {
|
|
38
|
+
this.sessions.delete(transport.sessionId);
|
|
39
|
+
console.log(`SSE session ${transport.sessionId} closed`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
transport.onerror = (error) => {
|
|
43
|
+
console.error(`SSE session ${transport.sessionId} error:`, error);
|
|
44
|
+
this.sessions.delete(transport.sessionId);
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
console.log(`SSE session ${transport.sessionId} created`);
|
|
48
|
+
return transport;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
getSession(sessionId: string): SSEServerTransport | null {
|
|
52
|
+
const session = this.sessions.get(sessionId);
|
|
53
|
+
return session?.transport || null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async cleanup(): Promise<void> {
|
|
57
|
+
const promises = Array.from(this.sessions.values()).map(session =>
|
|
58
|
+
session.transport.close()
|
|
59
|
+
);
|
|
60
|
+
await Promise.all(promises);
|
|
61
|
+
this.sessions.clear();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
getSessionCount(): number {
|
|
65
|
+
return this.sessions.size;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createSSERoutes(
|
|
70
|
+
mcpServer: McpServer,
|
|
71
|
+
config: HttpTransportConfig,
|
|
72
|
+
streamableSessionManager: SessionManager
|
|
73
|
+
): Router {
|
|
74
|
+
const router = Router();
|
|
75
|
+
const sseManager = new SSEManager(config);
|
|
76
|
+
|
|
77
|
+
if (!config.ssePaths) {
|
|
78
|
+
throw new Error("SSE paths configuration is required");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const { stream: streamPath, message: messagePath } = config.ssePaths;
|
|
82
|
+
|
|
83
|
+
// Guard against stateless mode
|
|
84
|
+
const checkStatefulMode = (req: Request, res: Response, next: any) => {
|
|
85
|
+
if (config.sessionMode === 'stateless') {
|
|
86
|
+
return res.status(405).json({
|
|
87
|
+
jsonrpc: "2.0",
|
|
88
|
+
error: {
|
|
89
|
+
code: -32600,
|
|
90
|
+
message: "SSE endpoints not available in stateless mode"
|
|
91
|
+
},
|
|
92
|
+
id: null
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
next();
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// GET /sse - Establish SSE connection
|
|
99
|
+
router.get(streamPath, checkStatefulMode, async (req: Request, res: Response) => {
|
|
100
|
+
try {
|
|
101
|
+
console.log('SSE connection request received');
|
|
102
|
+
|
|
103
|
+
// Set SSE headers
|
|
104
|
+
res.setHeader('Content-Type', 'text/event-stream');
|
|
105
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
106
|
+
res.setHeader('Connection', 'keep-alive');
|
|
107
|
+
|
|
108
|
+
// Set CORS headers for SSE if CORS is enabled
|
|
109
|
+
if (config.security?.enableCors !== false) {
|
|
110
|
+
res.setHeader('Access-Control-Allow-Origin',
|
|
111
|
+
config.security?.corsOrigins?.join(',') || '*');
|
|
112
|
+
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
|
113
|
+
res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const baseUrl = `${req.protocol}://${req.get('host')}`;
|
|
117
|
+
const messageEndpoint = `${baseUrl}${messagePath}`;
|
|
118
|
+
|
|
119
|
+
const transport = sseManager.createSession(messageEndpoint, res);
|
|
120
|
+
|
|
121
|
+
// Connect transport to MCP server
|
|
122
|
+
await mcpServer.connect(transport);
|
|
123
|
+
|
|
124
|
+
// Start the SSE stream
|
|
125
|
+
await transport.start();
|
|
126
|
+
|
|
127
|
+
// Set up cleanup on connection close
|
|
128
|
+
res.on('close', () => {
|
|
129
|
+
transport.close();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error('Error establishing SSE connection:', error);
|
|
134
|
+
if (!res.headersSent) {
|
|
135
|
+
res.status(500).json({
|
|
136
|
+
jsonrpc: "2.0",
|
|
137
|
+
error: {
|
|
138
|
+
code: -32603,
|
|
139
|
+
message: "Internal error establishing SSE connection"
|
|
140
|
+
},
|
|
141
|
+
id: null
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// POST /messages - Handle incoming messages
|
|
148
|
+
router.post(messagePath, checkStatefulMode, async (req: Request, res: Response) => {
|
|
149
|
+
try {
|
|
150
|
+
const sessionId = req.query.sessionId as string;
|
|
151
|
+
|
|
152
|
+
if (!sessionId) {
|
|
153
|
+
return res.status(400).json({
|
|
154
|
+
jsonrpc: "2.0",
|
|
155
|
+
error: {
|
|
156
|
+
code: -32600,
|
|
157
|
+
message: "Missing sessionId query parameter"
|
|
158
|
+
},
|
|
159
|
+
id: null
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Check if sessionId is being used by streamable HTTP transport
|
|
164
|
+
const streamableTransport = await streamableSessionManager.getTransport(sessionId);
|
|
165
|
+
if (streamableTransport) {
|
|
166
|
+
return res.status(400).json({
|
|
167
|
+
jsonrpc: "2.0",
|
|
168
|
+
error: {
|
|
169
|
+
code: -32600,
|
|
170
|
+
message: "Session ID is already in use by streamable HTTP transport"
|
|
171
|
+
},
|
|
172
|
+
id: null
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const transport = sseManager.getSession(sessionId);
|
|
177
|
+
if (!transport) {
|
|
178
|
+
return res.status(400).json({
|
|
179
|
+
jsonrpc: "2.0",
|
|
180
|
+
error: {
|
|
181
|
+
code: -32600,
|
|
182
|
+
message: `No active SSE session found for sessionId: ${sessionId}`
|
|
183
|
+
},
|
|
184
|
+
id: null
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Forward the message to the transport
|
|
189
|
+
await transport.handlePostMessage(req, res, req.body);
|
|
190
|
+
|
|
191
|
+
} catch (error) {
|
|
192
|
+
console.error('Error handling SSE message:', error);
|
|
193
|
+
if (!res.headersSent) {
|
|
194
|
+
res.status(500).json({
|
|
195
|
+
jsonrpc: "2.0",
|
|
196
|
+
error: {
|
|
197
|
+
code: -32603,
|
|
198
|
+
message: "Internal error processing message"
|
|
199
|
+
},
|
|
200
|
+
id: null
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// Store reference to manager for cleanup
|
|
207
|
+
(router as any).sseManager = sseManager;
|
|
208
|
+
|
|
209
|
+
return router;
|
|
210
|
+
}
|
package/src/transports/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
2
|
import { startStdioTransport } from "./stdio.js";
|
|
3
3
|
import { startHttpTransport } from "./http/server.js";
|
|
4
|
-
import type { TransportConfig } from "./types.js";
|
|
4
|
+
import type { TransportConfig, HttpServerHandle } from "./types.js";
|
|
5
5
|
|
|
6
6
|
export class TransportManager {
|
|
7
7
|
private server: McpServer;
|
|
8
8
|
private config: TransportConfig;
|
|
9
|
+
private httpHandle?: HttpServerHandle;
|
|
9
10
|
|
|
10
11
|
constructor(server: McpServer, config: TransportConfig) {
|
|
11
12
|
this.server = server;
|
|
@@ -18,14 +19,18 @@ export class TransportManager {
|
|
|
18
19
|
await startStdioTransport(this.server);
|
|
19
20
|
break;
|
|
20
21
|
case 'http':
|
|
21
|
-
await startHttpTransport(this.server, this.config.http!);
|
|
22
|
+
this.httpHandle = await startHttpTransport(this.server, this.config.http!);
|
|
22
23
|
break;
|
|
23
24
|
case 'both':
|
|
24
|
-
await
|
|
25
|
-
|
|
26
|
-
startHttpTransport(this.server, this.config.http!)
|
|
27
|
-
]);
|
|
25
|
+
await startStdioTransport(this.server);
|
|
26
|
+
this.httpHandle = await startHttpTransport(this.server, this.config.http!);
|
|
28
27
|
break;
|
|
29
28
|
}
|
|
30
29
|
}
|
|
30
|
+
|
|
31
|
+
async stop(): Promise<void> {
|
|
32
|
+
if (this.httpHandle) {
|
|
33
|
+
await this.httpHandle.close();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
31
36
|
}
|
package/src/transports/types.ts
CHANGED
|
@@ -11,6 +11,11 @@ export interface HttpTransportConfig {
|
|
|
11
11
|
sessionMode: 'stateful' | 'stateless';
|
|
12
12
|
enableSse?: boolean;
|
|
13
13
|
enableJsonResponse?: boolean;
|
|
14
|
+
enableSseFallback?: boolean;
|
|
15
|
+
ssePaths?: {
|
|
16
|
+
stream: string;
|
|
17
|
+
message: string;
|
|
18
|
+
};
|
|
14
19
|
security?: SecurityConfig;
|
|
15
20
|
}
|
|
16
21
|
|
|
@@ -34,4 +39,12 @@ export interface SessionStore {
|
|
|
34
39
|
set(sessionId: string, session: TransportSession): Promise<void>;
|
|
35
40
|
delete(sessionId: string): Promise<void>;
|
|
36
41
|
cleanup(): Promise<void>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface HttpServerHandle {
|
|
45
|
+
app: any;
|
|
46
|
+
server: any;
|
|
47
|
+
sessionManager: any;
|
|
48
|
+
sseManager?: any;
|
|
49
|
+
close(): Promise<void>;
|
|
37
50
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
2
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
3
|
+
import mime from 'mime-types';
|
|
4
|
+
import { logger } from './logger.js';
|
|
5
|
+
|
|
6
|
+
export class CloudflareR2Client {
|
|
7
|
+
private s3Client: S3Client;
|
|
8
|
+
private bucketName: string;
|
|
9
|
+
private baseUrl: string;
|
|
10
|
+
|
|
11
|
+
constructor() {
|
|
12
|
+
// Check if required environment variables are set
|
|
13
|
+
const requiredVars = [
|
|
14
|
+
'CLOUDFLARE_CDN_ACCESS_KEY',
|
|
15
|
+
'CLOUDFLARE_CDN_SECRET_KEY',
|
|
16
|
+
'CLOUDFLARE_CDN_ENDPOINT_URL',
|
|
17
|
+
'CLOUDFLARE_CDN_BUCKET_NAME',
|
|
18
|
+
'CLOUDFLARE_CDN_BASE_URL'
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
const missing = requiredVars.filter(varName => !process.env[varName]);
|
|
22
|
+
if (missing.length > 0) {
|
|
23
|
+
throw new Error(`Missing required Cloudflare R2 environment variables: ${missing.join(', ')}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const config = {
|
|
27
|
+
region: 'auto',
|
|
28
|
+
endpoint: process.env.CLOUDFLARE_CDN_ENDPOINT_URL,
|
|
29
|
+
credentials: {
|
|
30
|
+
accessKeyId: process.env.CLOUDFLARE_CDN_ACCESS_KEY!,
|
|
31
|
+
secretAccessKey: process.env.CLOUDFLARE_CDN_SECRET_KEY!,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
this.s3Client = new S3Client(config);
|
|
36
|
+
this.bucketName = process.env.CLOUDFLARE_CDN_BUCKET_NAME!;
|
|
37
|
+
this.baseUrl = process.env.CLOUDFLARE_CDN_BASE_URL!;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async uploadFile(buffer: Buffer, originalName: string): Promise<string> {
|
|
41
|
+
try {
|
|
42
|
+
const fileExtension = originalName.split('.').pop() || 'bin';
|
|
43
|
+
const mimeType = mime.lookup(originalName) || 'application/octet-stream';
|
|
44
|
+
const key = `human-mcp/${uuidv4()}.${fileExtension}`;
|
|
45
|
+
|
|
46
|
+
const command = new PutObjectCommand({
|
|
47
|
+
Bucket: this.bucketName,
|
|
48
|
+
Key: key,
|
|
49
|
+
Body: buffer,
|
|
50
|
+
ContentType: mimeType,
|
|
51
|
+
Metadata: {
|
|
52
|
+
originalName: originalName,
|
|
53
|
+
uploadedAt: new Date().toISOString(),
|
|
54
|
+
source: 'human-mcp-http-transport'
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
await this.s3Client.send(command);
|
|
59
|
+
|
|
60
|
+
const publicUrl = `${this.baseUrl}/${key}`;
|
|
61
|
+
logger.info(`File uploaded to Cloudflare R2: ${publicUrl}`);
|
|
62
|
+
|
|
63
|
+
return publicUrl;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
logger.error('Failed to upload to Cloudflare R2:', error);
|
|
66
|
+
throw new Error(`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async uploadBase64(base64Data: string, mimeType: string, originalName?: string): Promise<string> {
|
|
71
|
+
const buffer = Buffer.from(base64Data, 'base64');
|
|
72
|
+
const extension = mimeType.split('/')[1] || 'bin';
|
|
73
|
+
const fileName = originalName || `upload-${Date.now()}.${extension}`;
|
|
74
|
+
|
|
75
|
+
return this.uploadFile(buffer, fileName);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
isConfigured(): boolean {
|
|
79
|
+
try {
|
|
80
|
+
const requiredVars = [
|
|
81
|
+
'CLOUDFLARE_CDN_ACCESS_KEY',
|
|
82
|
+
'CLOUDFLARE_CDN_SECRET_KEY',
|
|
83
|
+
'CLOUDFLARE_CDN_ENDPOINT_URL',
|
|
84
|
+
'CLOUDFLARE_CDN_BUCKET_NAME',
|
|
85
|
+
'CLOUDFLARE_CDN_BASE_URL'
|
|
86
|
+
];
|
|
87
|
+
return requiredVars.every(varName => process.env[varName]);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Singleton instance with lazy initialization
|
|
95
|
+
let cloudflareR2Instance: CloudflareR2Client | null = null;
|
|
96
|
+
|
|
97
|
+
export function getCloudflareR2(): CloudflareR2Client | null {
|
|
98
|
+
if (!cloudflareR2Instance) {
|
|
99
|
+
try {
|
|
100
|
+
cloudflareR2Instance = new CloudflareR2Client();
|
|
101
|
+
} catch (error) {
|
|
102
|
+
logger.warn('Cloudflare R2 not configured:', error instanceof Error ? error.message : 'Unknown error');
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return cloudflareR2Instance;
|
|
107
|
+
}
|
package/src/utils/config.ts
CHANGED
|
@@ -14,6 +14,11 @@ const ConfigSchema = z.object({
|
|
|
14
14
|
sessionMode: z.enum(["stateful", "stateless"]).default("stateful"),
|
|
15
15
|
enableSse: z.boolean().default(true),
|
|
16
16
|
enableJsonResponse: z.boolean().default(true),
|
|
17
|
+
enableSseFallback: z.boolean().default(false),
|
|
18
|
+
ssePaths: z.object({
|
|
19
|
+
stream: z.string().default("/sse"),
|
|
20
|
+
message: z.string().default("/messages")
|
|
21
|
+
}).default({ stream: "/sse", message: "/messages" }),
|
|
17
22
|
security: z.object({
|
|
18
23
|
enableCors: z.boolean().default(true),
|
|
19
24
|
corsOrigins: z.array(z.string()).optional(),
|
|
@@ -40,6 +45,14 @@ const ConfigSchema = z.object({
|
|
|
40
45
|
logging: z.object({
|
|
41
46
|
level: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
|
42
47
|
}),
|
|
48
|
+
cloudflare: z.object({
|
|
49
|
+
projectName: z.string().optional().default("human-mcp"),
|
|
50
|
+
bucketName: z.string().optional(),
|
|
51
|
+
accessKey: z.string().optional(),
|
|
52
|
+
secretKey: z.string().optional(),
|
|
53
|
+
endpointUrl: z.string().optional(),
|
|
54
|
+
baseUrl: z.string().optional(),
|
|
55
|
+
}).optional(),
|
|
43
56
|
});
|
|
44
57
|
|
|
45
58
|
export type Config = z.infer<typeof ConfigSchema>;
|
|
@@ -67,6 +80,11 @@ export function loadConfig(): Config {
|
|
|
67
80
|
sessionMode: (process.env.HTTP_SESSION_MODE as any) || "stateful",
|
|
68
81
|
enableSse: process.env.HTTP_ENABLE_SSE !== "false",
|
|
69
82
|
enableJsonResponse: process.env.HTTP_ENABLE_JSON_RESPONSE !== "false",
|
|
83
|
+
enableSseFallback: process.env.HTTP_ENABLE_SSE_FALLBACK === "true",
|
|
84
|
+
ssePaths: {
|
|
85
|
+
stream: process.env.HTTP_SSE_STREAM_PATH || "/sse",
|
|
86
|
+
message: process.env.HTTP_SSE_MESSAGE_PATH || "/messages"
|
|
87
|
+
},
|
|
70
88
|
security: {
|
|
71
89
|
enableCors: process.env.HTTP_CORS_ENABLED !== "false",
|
|
72
90
|
corsOrigins,
|
|
@@ -93,5 +111,13 @@ export function loadConfig(): Config {
|
|
|
93
111
|
logging: {
|
|
94
112
|
level: (process.env.LOG_LEVEL as any) || "info",
|
|
95
113
|
},
|
|
114
|
+
cloudflare: {
|
|
115
|
+
projectName: process.env.CLOUDFLARE_CDN_PROJECT_NAME || "human-mcp",
|
|
116
|
+
bucketName: process.env.CLOUDFLARE_CDN_BUCKET_NAME,
|
|
117
|
+
accessKey: process.env.CLOUDFLARE_CDN_ACCESS_KEY,
|
|
118
|
+
secretKey: process.env.CLOUDFLARE_CDN_SECRET_KEY,
|
|
119
|
+
endpointUrl: process.env.CLOUDFLARE_CDN_ENDPOINT_URL,
|
|
120
|
+
baseUrl: process.env.CLOUDFLARE_CDN_BASE_URL,
|
|
121
|
+
},
|
|
96
122
|
});
|
|
97
123
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, mock } from 'bun:test';
|
|
2
|
+
import { fileInterceptorMiddleware } from '@/transports/http/file-interceptor';
|
|
3
|
+
import type { Request, Response, NextFunction } from 'express';
|
|
4
|
+
|
|
5
|
+
// Mock the Cloudflare R2 client
|
|
6
|
+
mock.module('@/utils/cloudflare-r2', () => ({
|
|
7
|
+
CloudflareR2Client: mock(function() {
|
|
8
|
+
return {
|
|
9
|
+
uploadFile: mock(async (_buffer: Buffer, filename: string) => {
|
|
10
|
+
return `https://cdn.test.com/human-mcp/${filename}`;
|
|
11
|
+
}),
|
|
12
|
+
uploadBase64: mock(async (_data: string, _mimeType: string, filename?: string) => {
|
|
13
|
+
return `https://cdn.test.com/human-mcp/${filename || 'upload.jpg'}`;
|
|
14
|
+
}),
|
|
15
|
+
isConfigured: mock(() => true)
|
|
16
|
+
};
|
|
17
|
+
}),
|
|
18
|
+
getCloudflareR2: mock(() => ({
|
|
19
|
+
uploadFile: mock(async (_buffer: Buffer, filename: string) => {
|
|
20
|
+
return `https://cdn.test.com/human-mcp/${filename}`;
|
|
21
|
+
}),
|
|
22
|
+
uploadBase64: mock(async (_data: string, _mimeType: string, filename?: string) => {
|
|
23
|
+
return `https://cdn.test.com/human-mcp/${filename || 'upload.jpg'}`;
|
|
24
|
+
}),
|
|
25
|
+
isConfigured: mock(() => true)
|
|
26
|
+
}))
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
// Logger is mocked globally in setup.ts
|
|
30
|
+
|
|
31
|
+
// Mock fs/promises module for Bun compatibility
|
|
32
|
+
mock.module('fs/promises', () => ({
|
|
33
|
+
access: mock(async () => { throw new Error('File not found'); }),
|
|
34
|
+
readFile: mock(async () => Buffer.from('fake image data')),
|
|
35
|
+
stat: mock(async () => ({ isFile: () => true }))
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
describe('HTTP Transport File Handling', () => {
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
process.env.TRANSPORT_TYPE = 'http';
|
|
41
|
+
// Set required Cloudflare R2 environment variables for testing
|
|
42
|
+
process.env.CLOUDFLARE_CDN_ACCESS_KEY = 'test-access-key';
|
|
43
|
+
process.env.CLOUDFLARE_CDN_SECRET_KEY = 'test-secret-key';
|
|
44
|
+
process.env.CLOUDFLARE_CDN_ENDPOINT_URL = 'https://test.r2.cloudflarestorage.com';
|
|
45
|
+
process.env.CLOUDFLARE_CDN_BUCKET_NAME = 'test-bucket';
|
|
46
|
+
process.env.CLOUDFLARE_CDN_BASE_URL = 'https://cdn.test.com';
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
delete process.env.TRANSPORT_TYPE;
|
|
51
|
+
delete process.env.CLOUDFLARE_CDN_ACCESS_KEY;
|
|
52
|
+
delete process.env.CLOUDFLARE_CDN_SECRET_KEY;
|
|
53
|
+
delete process.env.CLOUDFLARE_CDN_ENDPOINT_URL;
|
|
54
|
+
delete process.env.CLOUDFLARE_CDN_BUCKET_NAME;
|
|
55
|
+
delete process.env.CLOUDFLARE_CDN_BASE_URL;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('should handle Claude Desktop virtual paths', async () => {
|
|
59
|
+
const req = {
|
|
60
|
+
body: {
|
|
61
|
+
method: 'tools/call',
|
|
62
|
+
params: {
|
|
63
|
+
arguments: {
|
|
64
|
+
source: '/mnt/user-data/uploads/test.png',
|
|
65
|
+
type: 'image'
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
id: 'test-id'
|
|
69
|
+
}
|
|
70
|
+
} as Request;
|
|
71
|
+
|
|
72
|
+
const res = {
|
|
73
|
+
status: mock(() => res),
|
|
74
|
+
json: mock(() => {}),
|
|
75
|
+
} as unknown as Response;
|
|
76
|
+
|
|
77
|
+
const next = mock(() => {}) as NextFunction;
|
|
78
|
+
|
|
79
|
+
await fileInterceptorMiddleware(req, res, next);
|
|
80
|
+
|
|
81
|
+
// Should provide helpful error when file not found
|
|
82
|
+
expect(res.status).toHaveBeenCalledWith(400);
|
|
83
|
+
expect(res.json).toHaveBeenCalledWith({
|
|
84
|
+
jsonrpc: '2.0',
|
|
85
|
+
error: {
|
|
86
|
+
code: -32602,
|
|
87
|
+
message: 'File not accessible via HTTP transport',
|
|
88
|
+
data: {
|
|
89
|
+
path: '/mnt/user-data/uploads/test.png',
|
|
90
|
+
suggestions: expect.arrayContaining([
|
|
91
|
+
'Upload the file using the /mcp/upload endpoint first'
|
|
92
|
+
])
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
id: 'test-id'
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should auto-upload local files in HTTP mode', async () => {
|
|
100
|
+
// For now, let's test that the middleware doesn't break when Cloudflare is not configured
|
|
101
|
+
// This is actually the expected behavior in a test environment
|
|
102
|
+
const req = {
|
|
103
|
+
body: {
|
|
104
|
+
method: 'tools/call',
|
|
105
|
+
params: {
|
|
106
|
+
arguments: {
|
|
107
|
+
source: '/local/path/image.jpg'
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} as Request;
|
|
112
|
+
|
|
113
|
+
const res = {} as Response;
|
|
114
|
+
const next = mock(() => {}) as NextFunction;
|
|
115
|
+
|
|
116
|
+
await fileInterceptorMiddleware(req, res, next);
|
|
117
|
+
|
|
118
|
+
// Without proper Cloudflare configuration, the path should remain unchanged
|
|
119
|
+
// This is the correct behavior for the current implementation
|
|
120
|
+
expect(req.body.params.arguments.source).toBe('/local/path/image.jpg');
|
|
121
|
+
expect(next).toHaveBeenCalled();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should skip non-file fields', async () => {
|
|
125
|
+
const originalSource = 'https://example.com/image.jpg';
|
|
126
|
+
const req = {
|
|
127
|
+
body: {
|
|
128
|
+
method: 'tools/call',
|
|
129
|
+
params: {
|
|
130
|
+
arguments: {
|
|
131
|
+
source: originalSource,
|
|
132
|
+
otherField: 'some value'
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} as Request;
|
|
137
|
+
|
|
138
|
+
const res = {} as Response;
|
|
139
|
+
const next = mock(() => {}) as NextFunction;
|
|
140
|
+
|
|
141
|
+
await fileInterceptorMiddleware(req, res, next);
|
|
142
|
+
|
|
143
|
+
// Should not modify URL sources
|
|
144
|
+
expect(req.body.params.arguments.source).toBe(originalSource);
|
|
145
|
+
expect(next).toHaveBeenCalled();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('should skip non-tool-call requests', async () => {
|
|
149
|
+
const req = {
|
|
150
|
+
body: {
|
|
151
|
+
method: 'initialize',
|
|
152
|
+
params: {}
|
|
153
|
+
}
|
|
154
|
+
} as Request;
|
|
155
|
+
|
|
156
|
+
const res = {} as Response;
|
|
157
|
+
const next = mock(() => {}) as NextFunction;
|
|
158
|
+
|
|
159
|
+
await fileInterceptorMiddleware(req, res, next);
|
|
160
|
+
|
|
161
|
+
expect(next).toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('should handle multiple file fields', async () => {
|
|
165
|
+
// Test that middleware processes multiple fields without breaking
|
|
166
|
+
const req = {
|
|
167
|
+
body: {
|
|
168
|
+
method: 'tools/call',
|
|
169
|
+
params: {
|
|
170
|
+
arguments: {
|
|
171
|
+
source1: '/path/image1.jpg',
|
|
172
|
+
source2: '/path/image2.png',
|
|
173
|
+
normalField: 'value'
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} as Request;
|
|
178
|
+
|
|
179
|
+
const res = {} as Response;
|
|
180
|
+
const next = mock(() => {}) as NextFunction;
|
|
181
|
+
|
|
182
|
+
await fileInterceptorMiddleware(req, res, next);
|
|
183
|
+
|
|
184
|
+
// Without proper Cloudflare configuration, paths should remain unchanged
|
|
185
|
+
expect(req.body.params.arguments.source1).toBe('/path/image1.jpg');
|
|
186
|
+
expect(req.body.params.arguments.source2).toBe('/path/image2.png');
|
|
187
|
+
expect(req.body.params.arguments.normalField).toBe('value');
|
|
188
|
+
expect(next).toHaveBeenCalled();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeAll, afterAll, mock } from "bun:test";
|
|
2
|
+
|
|
3
|
+
// Logger is mocked globally in setup.ts
|
|
4
|
+
|
|
2
5
|
import { createServer } from "../../src/server.js";
|
|
3
6
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
7
|
|