Reubencf commited on
Commit
80540c8
·
1 Parent(s): ff732bb

hosting the MCP on HF

Browse files
.claude/settings.local.json CHANGED
@@ -31,7 +31,8 @@
31
  "Bash(node test-api.js:*)",
32
  "Bash(REUBENOS_URL=https://huggingface.co/spaces/MCP-1st-Birthday/Reuben_OS node test-api.js:*)",
33
  "Bash(curl:*)",
34
- "Bash(REUBENOS_URL=https://mcp-1st-birthday-reuben-os.hf.space node test-api.js:*)"
 
35
  ],
36
  "deny": [],
37
  "ask": []
 
31
  "Bash(node test-api.js:*)",
32
  "Bash(REUBENOS_URL=https://huggingface.co/spaces/MCP-1st-Birthday/Reuben_OS node test-api.js:*)",
33
  "Bash(curl:*)",
34
+ "Bash(REUBENOS_URL=https://mcp-1st-birthday-reuben-os.hf.space node test-api.js:*)",
35
+ "Bash(node test-download.js:*)"
36
  ],
37
  "deny": [],
38
  "ask": []
app/api/download/route.ts CHANGED
@@ -18,11 +18,14 @@ export async function GET(request: NextRequest) {
18
  return NextResponse.json({ error: 'File path required' }, { status: 400 })
19
  }
20
 
21
- const fullPath = path.join(DOCS_DIR, filePath)
 
 
 
22
 
23
- // Security check
24
- if (!fullPath.startsWith(DOCS_DIR)) {
25
- return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
26
  }
27
 
28
  if (!fs.existsSync(fullPath)) {
@@ -35,7 +38,7 @@ export async function GET(request: NextRequest) {
35
  }
36
 
37
  const fileBuffer = fs.readFileSync(fullPath)
38
- const fileName = path.basename(filePath)
39
  const ext = path.extname(fileName).toLowerCase()
40
 
41
  // Determine content type
@@ -92,14 +95,14 @@ export async function GET(request: NextRequest) {
92
 
93
  // If not preview mode, add download header
94
  if (!preview) {
95
- headers.set('Content-Disposition', `attachment; filename="${fileName}"`)
96
  } else {
97
  // For preview, use inline disposition for supported types
98
  if (['application/pdf', 'text/plain', 'text/markdown', 'application/json'].includes(contentType) ||
99
  contentType.startsWith('image/') || contentType.startsWith('text/')) {
100
- headers.set('Content-Disposition', `inline; filename="${fileName}"`)
101
  } else {
102
- headers.set('Content-Disposition', `attachment; filename="${fileName}"`)
103
  }
104
  }
105
 
 
18
  return NextResponse.json({ error: 'File path required' }, { status: 400 })
19
  }
20
 
21
+ // Normalize the path to handle both forward and backward slashes
22
+ const normalizedPath = filePath.replace(/\\/g, '/')
23
+ const fullPath = path.resolve(path.join(DOCS_DIR, normalizedPath))
24
+ const resolvedDocsDir = path.resolve(DOCS_DIR)
25
 
26
+ // Security check - ensure the resolved path is within the allowed directory
27
+ if (!fullPath.startsWith(resolvedDocsDir)) {
28
+ return NextResponse.json({ error: 'Invalid path - access denied' }, { status: 400 })
29
  }
30
 
31
  if (!fs.existsSync(fullPath)) {
 
38
  }
39
 
40
  const fileBuffer = fs.readFileSync(fullPath)
41
+ const fileName = path.basename(normalizedPath)
42
  const ext = path.extname(fileName).toLowerCase()
43
 
44
  // Determine content type
 
95
 
96
  // If not preview mode, add download header
97
  if (!preview) {
98
+ headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`)
99
  } else {
100
  // For preview, use inline disposition for supported types
101
  if (['application/pdf', 'text/plain', 'text/markdown', 'application/json'].includes(contentType) ||
102
  contentType.startsWith('image/') || contentType.startsWith('text/')) {
103
+ headers.set('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`)
104
  } else {
105
+ headers.set('Content-Disposition', `attachment; filename="${encodeURIComponent(fileName)}"`)
106
  }
107
  }
108
 
app/api/sessions/download/route.ts CHANGED
@@ -25,19 +25,29 @@ export async function GET(request: NextRequest) {
25
 
26
  if (isPublic) {
27
  rootDir = PUBLIC_DIR
28
- filePath = path.join(PUBLIC_DIR, fileName)
 
 
 
29
  } else {
30
  if (!key) {
31
  return NextResponse.json({ error: 'Passkey is required for secure files' }, { status: 403 })
32
  }
33
  const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, '')
34
  rootDir = path.join(DATA_DIR, sanitizedKey)
35
- filePath = path.join(rootDir, fileName)
 
 
 
36
  }
37
 
38
- // Security check
 
 
 
 
39
  if (!filePath.startsWith(rootDir)) {
40
- return NextResponse.json({ error: 'Invalid path' }, { status: 400 })
41
  }
42
 
43
  if (!fs.existsSync(filePath)) {
@@ -50,7 +60,9 @@ export async function GET(request: NextRequest) {
50
  }
51
 
52
  const fileBuffer = fs.readFileSync(filePath)
53
- const ext = path.extname(fileName).toLowerCase()
 
 
54
 
55
  // Determine content type
56
  let contentType = 'application/octet-stream'
@@ -92,7 +104,7 @@ export async function GET(request: NextRequest) {
92
  const headers = new Headers({
93
  'Content-Type': contentType,
94
  'Content-Length': fileBuffer.length.toString(),
95
- 'Content-Disposition': `attachment; filename="${fileName}"`
96
  })
97
 
98
  return new NextResponse(fileBuffer, { headers })
 
25
 
26
  if (isPublic) {
27
  rootDir = PUBLIC_DIR
28
+ // fileName might be a relative path like "folder/file.txt"
29
+ // Normalize the path to handle both cases
30
+ const normalizedPath = fileName.replace(/\\/g, '/')
31
+ filePath = path.join(PUBLIC_DIR, normalizedPath)
32
  } else {
33
  if (!key) {
34
  return NextResponse.json({ error: 'Passkey is required for secure files' }, { status: 403 })
35
  }
36
  const sanitizedKey = key.replace(/[^a-zA-Z0-9_-]/g, '')
37
  rootDir = path.join(DATA_DIR, sanitizedKey)
38
+ // fileName might be a relative path like "folder/file.txt"
39
+ // Normalize the path to handle both cases
40
+ const normalizedPath = fileName.replace(/\\/g, '/')
41
+ filePath = path.join(rootDir, normalizedPath)
42
  }
43
 
44
+ // Resolve the absolute path to prevent directory traversal attacks
45
+ filePath = path.resolve(filePath)
46
+ rootDir = path.resolve(rootDir)
47
+
48
+ // Security check - ensure the resolved path is within the allowed directory
49
  if (!filePath.startsWith(rootDir)) {
50
+ return NextResponse.json({ error: 'Invalid path - access denied' }, { status: 400 })
51
  }
52
 
53
  if (!fs.existsSync(filePath)) {
 
60
  }
61
 
62
  const fileBuffer = fs.readFileSync(filePath)
63
+ // Extract the base filename from the path (in case it's a relative path)
64
+ const baseFileName = path.basename(fileName)
65
+ const ext = path.extname(baseFileName).toLowerCase()
66
 
67
  // Determine content type
68
  let contentType = 'application/octet-stream'
 
104
  const headers = new Headers({
105
  'Content-Type': contentType,
106
  'Content-Length': fileBuffer.length.toString(),
107
+ 'Content-Disposition': `attachment; filename="${encodeURIComponent(baseFileName)}"`
108
  })
109
 
110
  return new NextResponse(fileBuffer, { headers })
pages/api/mcp.ts ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
2
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
+ import {
4
+ CallToolRequestSchema,
5
+ ListToolsRequestSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+ import fs from 'fs';
8
+ import path from 'path';
9
+ import fetch from 'node-fetch';
10
+
11
+ declare global {
12
+ var mcpTransports: Map<string, any>;
13
+ }
14
+
15
+ // Global state to store active transports
16
+ // In a serverless environment (like Vercel), this might not work reliably for long-lived connections across requests.
17
+ // But for a persistent container (Hugging Face Spaces), this is fine.
18
+ if (!global.mcpTransports) {
19
+ global.mcpTransports = new Map();
20
+ }
21
+
22
+ // Configuration
23
+ const DATA_DIR = process.env.SPACE_ID ? '/data' : path.join(process.cwd(), 'public', 'data');
24
+ const PUBLIC_DIR = path.join(DATA_DIR, 'public');
25
+ const BASE_URL = process.env.REUBENOS_URL || 'http://localhost:3000';
26
+
27
+ // Helper functions
28
+ function ensureDirectories() {
29
+ if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR, { recursive: true });
30
+ if (!fs.existsSync(PUBLIC_DIR)) fs.mkdirSync(PUBLIC_DIR, { recursive: true });
31
+ }
32
+
33
+ function isValidPasskey(passkey: string): boolean {
34
+ return !!passkey && /^[a-zA-Z0-9_-]+$/.test(passkey) && passkey.length >= 4;
35
+ }
36
+
37
+ function sanitizeFileName(fileName: string): string {
38
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, '_');
39
+ }
40
+
41
+ function sanitizePasskey(passkey: string): string {
42
+ return passkey.replace(/[^a-zA-Z0-9_-]/g, '');
43
+ }
44
+
45
+ import { NextApiRequest, NextApiResponse } from 'next';
46
+
47
+ export default async function handler(req: NextApiRequest, res: NextApiResponse) {
48
+ ensureDirectories();
49
+
50
+ if (req.method === 'GET') {
51
+ // Handle SSE Connection
52
+ const transport = new SSEServerTransport('/api/mcp', res);
53
+
54
+ // Create a new server instance for this connection
55
+ const server = new Server(
56
+ {
57
+ name: 'reubenos-mcp-server',
58
+ version: '3.0.0',
59
+ },
60
+ {
61
+ capabilities: {
62
+ tools: {},
63
+ },
64
+ }
65
+ );
66
+
67
+ // Setup tools
68
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
69
+ tools: [
70
+ {
71
+ name: 'save_file',
72
+ description: 'Save a file to Reuben OS. Use your passkey for secure storage or save to public folder.',
73
+ inputSchema: {
74
+ type: 'object',
75
+ properties: {
76
+ fileName: { type: 'string', description: 'File name' },
77
+ content: { type: 'string', description: 'File content' },
78
+ passkey: { type: 'string', description: 'Passkey for secure storage' },
79
+ isPublic: { type: 'boolean', description: 'Save to public folder' },
80
+ },
81
+ required: ['fileName', 'content'],
82
+ },
83
+ },
84
+ {
85
+ name: 'list_files',
86
+ description: 'List all files in your secure storage or public folder',
87
+ inputSchema: {
88
+ type: 'object',
89
+ properties: {
90
+ passkey: { type: 'string', description: 'Passkey' },
91
+ isPublic: { type: 'boolean', description: 'List public files' },
92
+ },
93
+ },
94
+ },
95
+ {
96
+ name: 'delete_file',
97
+ description: 'Delete a specific file',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ fileName: { type: 'string' },
102
+ passkey: { type: 'string' },
103
+ isPublic: { type: 'boolean' },
104
+ },
105
+ required: ['fileName'],
106
+ },
107
+ },
108
+ {
109
+ name: 'read_file',
110
+ description: 'Read the content of a file',
111
+ inputSchema: {
112
+ type: 'object',
113
+ properties: {
114
+ fileName: { type: 'string' },
115
+ passkey: { type: 'string' },
116
+ isPublic: { type: 'boolean' },
117
+ },
118
+ required: ['fileName'],
119
+ },
120
+ },
121
+ {
122
+ name: 'generate_song_audio',
123
+ description: 'Generate an AI song with audio',
124
+ inputSchema: {
125
+ type: 'object',
126
+ properties: {
127
+ title: { type: 'string' },
128
+ style: { type: 'string' },
129
+ lyrics: { type: 'string' },
130
+ },
131
+ required: ['title', 'style', 'lyrics'],
132
+ },
133
+ },
134
+ {
135
+ name: 'generate_story_audio',
136
+ description: 'Generate audio narration for a story',
137
+ inputSchema: {
138
+ type: 'object',
139
+ properties: {
140
+ title: { type: 'string' },
141
+ content: { type: 'string' },
142
+ },
143
+ required: ['title', 'content'],
144
+ },
145
+ },
146
+ {
147
+ name: 'deploy_quiz',
148
+ description: 'Deploy an interactive quiz to Reuben OS Quiz App',
149
+ inputSchema: {
150
+ type: 'object',
151
+ properties: {
152
+ passkey: { type: 'string', description: 'Passkey for secure storage' },
153
+ isPublic: { type: 'boolean', description: 'Make quiz public' },
154
+ quizData: {
155
+ type: 'object',
156
+ description: 'Quiz configuration',
157
+ properties: {
158
+ title: { type: 'string' },
159
+ description: { type: 'string' },
160
+ questions: { type: 'array' },
161
+ timeLimit: { type: 'number' },
162
+ },
163
+ required: ['title', 'questions'],
164
+ },
165
+ },
166
+ required: ['quizData'],
167
+ },
168
+ },
169
+ {
170
+ name: 'analyze_quiz',
171
+ description: 'Analyze quiz answers and provide feedback',
172
+ inputSchema: {
173
+ type: 'object',
174
+ properties: {
175
+ passkey: { type: 'string', description: 'Passkey' },
176
+ isPublic: { type: 'boolean', description: 'Quiz files in public folder' },
177
+ },
178
+ required: ['passkey'],
179
+ },
180
+ },
181
+ ],
182
+ }));
183
+
184
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
185
+ const { name, arguments: args } = request.params as { name: string, arguments: any };
186
+
187
+ try {
188
+ switch (name) {
189
+ case 'save_file': {
190
+ const { fileName, content, passkey, isPublic } = args;
191
+ if (!isPublic && !passkey) throw new Error('Passkey required');
192
+
193
+ const targetDir = isPublic ? PUBLIC_DIR : path.join(DATA_DIR, sanitizePasskey(passkey));
194
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
195
+
196
+ const filePath = path.join(targetDir, sanitizeFileName(fileName));
197
+ fs.writeFileSync(filePath, typeof content === 'string' ? content : JSON.stringify(content, null, 2));
198
+
199
+ return {
200
+ content: [{ type: 'text', text: `Saved ${fileName} to ${isPublic ? 'Public' : 'Secure'} storage.` }],
201
+ };
202
+ }
203
+ case 'list_files': {
204
+ const { passkey, isPublic } = args;
205
+ if (!isPublic && !passkey) throw new Error('Passkey required');
206
+
207
+ const targetDir = isPublic ? PUBLIC_DIR : path.join(DATA_DIR, sanitizePasskey(passkey));
208
+ if (!fs.existsSync(targetDir)) return { content: [{ type: 'text', text: 'No files found.' }] };
209
+
210
+ const files = fs.readdirSync(targetDir);
211
+ return {
212
+ content: [{ type: 'text', text: `Files:\n${files.join('\n')}` }],
213
+ };
214
+ }
215
+ case 'delete_file': {
216
+ const { fileName, passkey, isPublic } = args;
217
+ if (!isPublic && !passkey) throw new Error('Passkey required');
218
+
219
+ const targetDir = isPublic ? PUBLIC_DIR : path.join(DATA_DIR, sanitizePasskey(passkey));
220
+ const filePath = path.join(targetDir, sanitizeFileName(fileName));
221
+
222
+ if (fs.existsSync(filePath)) {
223
+ fs.unlinkSync(filePath);
224
+ return { content: [{ type: 'text', text: `Deleted ${fileName}` }] };
225
+ }
226
+ return { content: [{ type: 'text', text: 'File not found' }] };
227
+ }
228
+ case 'read_file': {
229
+ const { fileName, passkey, isPublic } = args;
230
+ if (!isPublic && !passkey) throw new Error('Passkey required');
231
+
232
+ const targetDir = isPublic ? PUBLIC_DIR : path.join(DATA_DIR, sanitizePasskey(passkey));
233
+ const filePath = path.join(targetDir, sanitizeFileName(fileName));
234
+
235
+ if (fs.existsSync(filePath)) {
236
+ const content = fs.readFileSync(filePath, 'utf8');
237
+ return { content: [{ type: 'text', text: content }] };
238
+ }
239
+ return { content: [{ type: 'text', text: 'File not found' }] };
240
+ }
241
+ case 'generate_song_audio': {
242
+ // Call internal API
243
+ const response = await fetch(`${BASE_URL}/api/voice/generate-song`, {
244
+ method: 'POST',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({ ...args, passkey: 'voice_default' }),
247
+ });
248
+ const data = await response.json() as any;
249
+
250
+ // Save result
251
+ if (data.success) {
252
+ await fetch(`${BASE_URL}/api/voice/save`, {
253
+ method: 'POST',
254
+ headers: { 'Content-Type': 'application/json' },
255
+ body: JSON.stringify({ passkey: 'voice_default', content: data.content }),
256
+ });
257
+ return { content: [{ type: 'text', text: 'Song generated and saved to Voice Studio.' }] };
258
+ }
259
+ throw new Error(data.error || 'Failed to generate song');
260
+ }
261
+ case 'generate_story_audio': {
262
+ const response = await fetch(`${BASE_URL}/api/voice/generate-story`, {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({ ...args, passkey: 'voice_default' }),
266
+ });
267
+ const data = await response.json() as any;
268
+
269
+ if (data.success) {
270
+ await fetch(`${BASE_URL}/api/voice/save`, {
271
+ method: 'POST',
272
+ headers: { 'Content-Type': 'application/json' },
273
+ body: JSON.stringify({ passkey: 'voice_default', content: data.content }),
274
+ });
275
+ return { content: [{ type: 'text', text: 'Story generated and saved to Voice Studio.' }] };
276
+ }
277
+ throw new Error(data.error || 'Failed to generate story');
278
+ }
279
+ case 'deploy_quiz': {
280
+ const { quizData, passkey, isPublic } = args;
281
+ if (!isPublic && !passkey) throw new Error('Passkey required');
282
+
283
+ const fullQuizData = {
284
+ ...quizData,
285
+ createdAt: new Date().toISOString(),
286
+ passkey: passkey || 'public',
287
+ version: '1.0',
288
+ };
289
+
290
+ const targetDir = isPublic ? PUBLIC_DIR : path.join(DATA_DIR, sanitizePasskey(passkey));
291
+ if (!fs.existsSync(targetDir)) fs.mkdirSync(targetDir, { recursive: true });
292
+
293
+ const filePath = path.join(targetDir, 'quiz.json');
294
+ fs.writeFileSync(filePath, JSON.stringify(fullQuizData, null, 2));
295
+
296
+ const totalPoints = quizData.questions.reduce((sum: number, q: any) => sum + (q.points || 1), 0);
297
+ return {
298
+ content: [{ type: 'text', text: `Quiz deployed: ${quizData.title}\n${quizData.questions.length} questions, ${totalPoints} points\nSaved to: ${isPublic ? 'Public' : 'Secure'} storage` }],
299
+ };
300
+ }
301
+ case 'analyze_quiz': {
302
+ const { passkey, isPublic } = args;
303
+ if (!passkey) throw new Error('Passkey required');
304
+
305
+ const targetDir = isPublic ? PUBLIC_DIR : path.join(DATA_DIR, sanitizePasskey(passkey));
306
+
307
+ const quizPath = path.join(targetDir, 'quiz.json');
308
+ const answersPath = path.join(targetDir, 'quiz_answers.json');
309
+
310
+ if (!fs.existsSync(quizPath)) return { content: [{ type: 'text', text: 'quiz.json not found' }] };
311
+ if (!fs.existsSync(answersPath)) return { content: [{ type: 'text', text: 'quiz_answers.json not found' }] };
312
+
313
+ const quiz = JSON.parse(fs.readFileSync(quizPath, 'utf8'));
314
+ const answers = JSON.parse(fs.readFileSync(answersPath, 'utf8'));
315
+
316
+ let correctCount = 0;
317
+ let totalPoints = 0;
318
+ let maxPoints = 0;
319
+ const feedback: string[] = [];
320
+
321
+ quiz.questions.forEach((question: any, index: number) => {
322
+ const userAnswer = answers.answers?.[question.id] || answers[question.id];
323
+ const points = question.points || 1;
324
+ maxPoints += points;
325
+
326
+ const correctAnswer = question.correctAnswer || question.correct;
327
+ const isCorrect = userAnswer === correctAnswer ||
328
+ (Array.isArray(correctAnswer) && correctAnswer.includes(userAnswer));
329
+
330
+ if (isCorrect) {
331
+ correctCount++;
332
+ totalPoints += points;
333
+ feedback.push(`✅ Question ${index + 1}: Correct! (+${points} points)`);
334
+ } else {
335
+ feedback.push(`❌ Question ${index + 1}: Incorrect. Your answer: "${userAnswer}"${question.explanation ? `. ${question.explanation}` : ''}`);
336
+ }
337
+ });
338
+
339
+ const percentage = ((totalPoints / maxPoints) * 100).toFixed(1);
340
+ const result = `Quiz Results:\nScore: ${totalPoints}/${maxPoints} (${percentage}%)\nCorrect: ${correctCount}/${quiz.questions.length}\n\n${feedback.join('\n')}`;
341
+
342
+ return { content: [{ type: 'text', text: result }] };
343
+ }
344
+ default:
345
+ throw new Error(`Unknown tool: ${name}`);
346
+ }
347
+ } catch (error: any) {
348
+ return {
349
+ content: [{ type: 'text', text: `Error: ${error.message}` }],
350
+ isError: true,
351
+ };
352
+ }
353
+ });
354
+
355
+ // Store transport in global map using sessionId
356
+ // We need to wait for the transport to be ready or just store it.
357
+ // SSEServerTransport generates a sessionId.
358
+ // We can access it via `transport.sessionId` AFTER `start()` or `connect()`?
359
+ // Actually, `SSEServerTransport` handles the request in `start()`.
360
+
361
+ await server.connect(transport);
362
+
363
+ // After connect, the transport has initialized the session.
364
+ // We need to store it so POST requests can find it.
365
+ // The `sessionId` is sent to the client in the `endpoint` event.
366
+ // The client will send POST requests to `/api/mcp?sessionId=...`
367
+
368
+ // We need to intercept the session ID.
369
+ // Since `SSEServerTransport` is designed for Express, it might not expose sessionId easily in this flow.
370
+ // But let's look at how we can map it.
371
+ // We can iterate over `global.mcpTransports` in POST?
372
+ // No, we need the ID.
373
+
374
+ // Hack: We can override `transport.handlePostMessage` or similar if needed,
375
+ // but usually we just need to store the transport.
376
+ // Let's assume `transport.sessionId` is available.
377
+
378
+ // For now, let's just store it in the map.
379
+ // Note: This relies on `transport.sessionId` being accessible.
380
+ // If not, we might have issues.
381
+ // But typically it is.
382
+
383
+ if (transport.sessionId) {
384
+ global.mcpTransports.set(transport.sessionId, transport);
385
+ }
386
+
387
+ // Clean up on close
388
+ transport.onclose = () => {
389
+ if (transport.sessionId) {
390
+ global.mcpTransports.delete(transport.sessionId);
391
+ }
392
+ };
393
+
394
+ } else if (req.method === 'POST') {
395
+ const sessionId = Array.isArray(req.query.sessionId) ? req.query.sessionId[0] : req.query.sessionId;
396
+ if (!sessionId) {
397
+ return res.status(400).send('Missing sessionId');
398
+ }
399
+
400
+ const transport = global.mcpTransports.get(sessionId);
401
+ if (!transport) {
402
+ return res.status(404).send('Session not found');
403
+ }
404
+
405
+ await transport.handlePostMessage(req, res);
406
+ } else {
407
+ res.status(405).send('Method not allowed');
408
+ }
409
+ }
public/data/public/test-download.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ This is a test file for download functionality.
2
+ If you can download and read this file, the download feature is working correctly.
3
+ Test date: 2025-11-23
public/data/public/test-folder/nested-file.pdf ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ %PDF-1.4
2
+ %Test PDF for download functionality
3
+ 1 0 obj
4
+ << /Type /Catalog /Pages 2 0 R >>
5
+ endobj
6
+ 2 0 obj
7
+ << /Type /Pages /Kids [3 0 R] /Count 1 >>
8
+ endobj
9
+ 3 0 obj
10
+ << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources 4 0 R /Contents 5 0 R >>
11
+ endobj
12
+ 4 0 obj
13
+ << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >>
14
+ endobj
15
+ 5 0 obj
16
+ << /Length 100 >>
17
+ stream
18
+ BT
19
+ /F1 12 Tf
20
+ 100 700 Td
21
+ (Test PDF - Download functionality test) Tj
22
+ ET
23
+ endstream
24
+ endobj
25
+ xref
26
+ 0 6
27
+ 0000000000 65535 f
28
+ 0000000015 00000 n
29
+ 0000000068 00000 n
30
+ 0000000125 00000 n
31
+ 0000000229 00000 n
32
+ 0000000328 00000 n
33
+ trailer
34
+ << /Size 6 /Root 1 0 R >>
35
+ startxref
36
+ 480
37
+ %%EOF
test-download.js ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ async function testDownload(url, expectedSize) {
5
+ try {
6
+ console.log(`Testing: ${url}`);
7
+ const response = await fetch(url);
8
+
9
+ if (!response.ok) {
10
+ const text = await response.text();
11
+ console.log(` ❌ Failed: ${response.status} - ${text}`);
12
+ return false;
13
+ }
14
+
15
+ const buffer = await response.arrayBuffer();
16
+ const contentType = response.headers.get('content-type');
17
+ const contentDisposition = response.headers.get('content-disposition');
18
+
19
+ console.log(` ✓ Status: ${response.status}`);
20
+ console.log(` ✓ Content-Type: ${contentType}`);
21
+ console.log(` ✓ Content-Disposition: ${contentDisposition}`);
22
+ console.log(` ✓ Size: ${buffer.byteLength} bytes`);
23
+
24
+ if (expectedSize && buffer.byteLength !== expectedSize) {
25
+ console.log(` ⚠ Warning: Expected ${expectedSize} bytes but got ${buffer.byteLength}`);
26
+ }
27
+
28
+ return true;
29
+ } catch (error) {
30
+ console.log(` ❌ Error: ${error.message}`);
31
+ return false;
32
+ }
33
+ }
34
+
35
+ async function runTests() {
36
+ console.log('🧪 Testing Reuben OS File Download Functionality\n');
37
+
38
+ const baseUrl = 'http://localhost:3000';
39
+
40
+ // Wait a moment for server to be fully ready
41
+ await new Promise(resolve => setTimeout(resolve, 2000));
42
+
43
+ console.log('1. Testing root-level file download (public):');
44
+ await testDownload(`${baseUrl}/api/sessions/download?file=test-download.txt&public=true`);
45
+
46
+ console.log('\n2. Testing nested file download (public):');
47
+ await testDownload(`${baseUrl}/api/sessions/download?file=test-folder/nested-file.pdf&public=true`);
48
+
49
+ console.log('\n3. Testing with URL encoding (public):');
50
+ await testDownload(`${baseUrl}/api/sessions/download?file=${encodeURIComponent('test-folder/nested-file.pdf')}&public=true`);
51
+
52
+ console.log('\n4. Testing error case - non-existent file:');
53
+ await testDownload(`${baseUrl}/api/sessions/download?file=non-existent.txt&public=true`);
54
+
55
+ console.log('\n5. Testing error case - missing filename:');
56
+ await testDownload(`${baseUrl}/api/sessions/download?public=true`);
57
+
58
+ console.log('\n✅ Download functionality tests completed!');
59
+ }
60
+
61
+ runTests().catch(console.error);