Spaces:
Running
Running
| // mcp-server.js - MCP Server for Reuben OS with Passkey System | |
| import { Server } from '@modelcontextprotocol/sdk/server/index.js'; | |
| import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; | |
| import { | |
| CallToolRequestSchema, | |
| ListToolsRequestSchema, | |
| } from '@modelcontextprotocol/sdk/types.js'; | |
| import fetch from 'node-fetch'; | |
| const BASE_URL = 'https://mcp-1st-birthday-reuben-os.hf.space'; | |
| const API_ENDPOINT = `${BASE_URL}/api/mcp-handler`; | |
| class ReubenOSMCPServer { | |
| constructor() { | |
| this.server = new Server( | |
| { | |
| name: 'reubenos-mcp-server', | |
| version: '3.0.0', | |
| description: 'MCP Server for Reuben OS with secure passkey-based file storage', | |
| icon: 'π₯οΈ', | |
| }, | |
| { | |
| capabilities: { | |
| tools: {}, | |
| }, | |
| } | |
| ); | |
| this.setupToolHandlers(); | |
| // Error handling | |
| this.server.onerror = (error) => console.error('[MCP Error]', error); | |
| process.on('SIGINT', async () => { | |
| await this.server.close(); | |
| process.exit(0); | |
| }); | |
| } | |
| setupToolHandlers() { | |
| this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ | |
| tools: [ | |
| { | |
| name: 'save_file', | |
| description: 'Save a file to Reuben OS. Passkey is OPTIONAL - provide for secure storage or leave empty for public folder.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| fileName: { | |
| type: 'string', | |
| description: 'File name (e.g., main.dart, document.tex, report.pdf)', | |
| }, | |
| content: { | |
| type: 'string', | |
| description: 'File content to save', | |
| }, | |
| passkey: { | |
| type: 'string', | |
| description: 'OPTIONAL: Your passkey for secure storage (min 4 characters). Leave empty to save to public folder.', | |
| }, | |
| isPublic: { | |
| type: 'boolean', | |
| description: 'Set to true to save to public folder (accessible to everyone). Default: false', | |
| }, | |
| }, | |
| required: ['fileName', 'content'], | |
| }, | |
| }, | |
| { | |
| name: 'list_files', | |
| description: 'List all files in your secure storage or public folder. Passkey is OPTIONAL.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| passkey: { | |
| type: 'string', | |
| description: 'OPTIONAL: Your passkey to list secure files. Leave empty to list public files.', | |
| }, | |
| isPublic: { | |
| type: 'boolean', | |
| description: 'Set to true to list public files. Default: false', | |
| }, | |
| }, | |
| }, | |
| }, | |
| { | |
| name: 'delete_file', | |
| description: 'Delete a specific file from your storage. Passkey is OPTIONAL.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| fileName: { | |
| type: 'string', | |
| description: 'Name of the file to delete', | |
| }, | |
| passkey: { | |
| type: 'string', | |
| description: 'OPTIONAL: Your passkey (required for secure files). Leave empty for public files.', | |
| }, | |
| isPublic: { | |
| type: 'boolean', | |
| description: 'Set to true if deleting from public folder. Default: false', | |
| }, | |
| }, | |
| required: ['fileName'], | |
| }, | |
| }, | |
| { | |
| name: 'deploy_quiz', | |
| description: 'Deploy an interactive quiz to Reuben OS Quiz App. Passkey is REQUIRED for all quizzes.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| passkey: { | |
| type: 'string', | |
| description: 'Your passkey for secure quiz storage (REQUIRED - min 4 characters)', | |
| }, | |
| quizData: { | |
| type: 'object', | |
| description: 'Quiz configuration', | |
| properties: { | |
| title: { type: 'string', description: 'Quiz title' }, | |
| description: { type: 'string', description: 'Quiz description' }, | |
| questions: { | |
| type: 'array', | |
| description: 'Array of questions', | |
| items: { | |
| type: 'object', | |
| properties: { | |
| id: { type: 'string' }, | |
| type: { type: 'string', enum: ['multiple_choice'] }, | |
| question: { type: 'string' }, | |
| options: { type: 'array', items: { type: 'string' } }, | |
| explanation: { type: 'string' }, | |
| points: { type: 'number' }, | |
| }, | |
| required: ['id', 'question', 'type'], | |
| }, | |
| }, | |
| timeLimit: { | |
| type: 'number', | |
| description: 'Time limit in minutes for the quiz (optional)', | |
| }, | |
| }, | |
| required: ['title', 'questions'], | |
| }, | |
| }, | |
| required: ['passkey', 'quizData'], | |
| }, | |
| }, | |
| { | |
| name: 'read_file', | |
| description: 'Read the content of a file from secure storage or public folder. Passkey is OPTIONAL. Useful for evaluating quiz answers.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| fileName: { | |
| type: 'string', | |
| description: 'Name of the file to read (e.g., quiz_answers.json)', | |
| }, | |
| passkey: { | |
| type: 'string', | |
| description: 'OPTIONAL: Your passkey (required for secure files). Leave empty for public files.', | |
| }, | |
| isPublic: { | |
| type: 'boolean', | |
| description: 'Set to true if reading from public folder. Default: false', | |
| }, | |
| }, | |
| required: ['fileName'], | |
| }, | |
| }, | |
| { | |
| name: 'generate_song_audio', | |
| description: 'Generate an AI song with audio using ElevenLabs Music API. NO passkey required - saves directly to Voice Studio app.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| title: { | |
| type: 'string', | |
| description: 'Title of the song', | |
| }, | |
| style: { | |
| type: 'string', | |
| description: 'Musical style/genre (e.g., "pop", "rock", "jazz", "ballad")', | |
| }, | |
| lyrics: { | |
| type: 'string', | |
| description: 'Song lyrics (will be used to generate music)', | |
| }, | |
| }, | |
| required: ['title', 'style', 'lyrics'], | |
| }, | |
| }, | |
| { | |
| name: 'generate_story_audio', | |
| description: 'Generate audio narration for a story using ElevenLabs Text-to-Speech API. NO passkey required - saves directly to Voice Studio app.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| title: { | |
| type: 'string', | |
| description: 'Title of the story', | |
| }, | |
| content: { | |
| type: 'string', | |
| description: 'Story content/text to be narrated (max 2000 characters for best performance)', | |
| }, | |
| }, | |
| required: ['title', 'content'], | |
| }, | |
| }, | |
| { | |
| name: 'analyze_quiz', | |
| description: 'Analyze quiz answers from quiz_answers.json against the quiz.json questions and provide feedback on correctness. Passkey is REQUIRED.', | |
| inputSchema: { | |
| type: 'object', | |
| properties: { | |
| passkey: { | |
| type: 'string', | |
| description: 'Your passkey for accessing the quiz files (REQUIRED)', | |
| }, | |
| }, | |
| required: ['passkey'], | |
| }, | |
| }, | |
| ], | |
| })); | |
| this.server.setRequestHandler(CallToolRequestSchema, async (request) => { | |
| const { name, arguments: args } = request.params; | |
| try { | |
| switch (name) { | |
| case 'save_file': | |
| return await this.saveFile(args); | |
| case 'list_files': | |
| return await this.listFiles(args); | |
| case 'delete_file': | |
| return await this.deleteFile(args); | |
| case 'deploy_quiz': | |
| return await this.deployQuiz(args); | |
| case 'read_file': | |
| return await this.readFile(args); | |
| case 'generate_song_audio': | |
| return await this.generateSongAudio(args); | |
| case 'generate_story_audio': | |
| return await this.generateStoryAudio(args); | |
| case 'analyze_quiz': | |
| return await this.analyzeQuiz(args); | |
| default: | |
| throw new Error(`Unknown tool: ${name}`); | |
| } | |
| } catch (error) { | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `Error: ${error.message}`, | |
| }, | |
| ], | |
| }; | |
| } | |
| }); | |
| } | |
| async readFile(args) { | |
| try { | |
| const { fileName, passkey, isPublic = false } = args; | |
| if (!fileName) { | |
| return { | |
| content: [{ type: 'text', text: 'β fileName is required' }], | |
| }; | |
| } | |
| if (!isPublic && !passkey) { | |
| return { | |
| content: [{ type: 'text', text: 'β Passkey is required (or set isPublic=true)' }], | |
| }; | |
| } | |
| const ext = fileName.split('.').pop().toLowerCase(); | |
| const isDocument = ['pdf', 'docx', 'xlsx', 'xls', 'pptx'].includes(ext); | |
| if (isDocument) { | |
| // Use document processing endpoint | |
| const processUrl = `${BASE_URL}/api/documents/process`; | |
| const response = await fetch(processUrl, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| fileName, | |
| key: passkey, | |
| isPublic, | |
| operation: 'read' | |
| }) | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| let textContent = ''; | |
| if (data.content.text) { | |
| textContent = data.content.text; | |
| } else if (data.content.sheets) { | |
| textContent = JSON.stringify(data.content.sheets, null, 2); | |
| } else { | |
| textContent = JSON.stringify(data.content, null, 2); | |
| } | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `π Content of ${fileName} (${data.content.type}):\n\n${textContent}`, | |
| }, | |
| ], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to process document: ${data.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| } | |
| const url = new URL(API_ENDPOINT); | |
| if (passkey) url.searchParams.set('passkey', passkey); | |
| if (isPublic) url.searchParams.set('isPublic', 'true'); | |
| const response = await fetch(url, { | |
| method: 'GET', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| const file = data.files.find(f => f.name === fileName); | |
| if (!file) { | |
| return { | |
| content: [{ type: 'text', text: `β File '${fileName}' not found in ${data.location}` }], | |
| }; | |
| } | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `π Content of ${fileName}:\n\n${file.content || '(Empty file)'}`, | |
| }, | |
| ], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed: ${data.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async saveFile(args) { | |
| try { | |
| const { fileName, content, passkey, isPublic = false } = args; | |
| if (!fileName || content === undefined) { | |
| return { | |
| content: [{ type: 'text', text: 'β fileName and content are required' }], | |
| }; | |
| } | |
| if (!isPublic && !passkey) { | |
| return { | |
| content: [{ type: 'text', text: 'β Passkey is required for secure storage (or set isPublic=true)' }], | |
| }; | |
| } | |
| // Ensure content is properly stringified if it's an object | |
| let fileContent = content; | |
| if (typeof content === 'object' && content !== null) { | |
| fileContent = JSON.stringify(content, null, 2); | |
| } | |
| // Handle special characters in content for code files | |
| const ext = fileName.split('.').pop().toLowerCase(); | |
| const isCodeFile = ['dart', 'js', 'ts', 'jsx', 'tsx', 'py', 'java', 'cpp', 'c', 'h', 'hpp', 'cs', 'swift', 'kt', 'go', 'rs', 'rb', 'php'].includes(ext); | |
| // Ensure content is a string | |
| fileContent = String(fileContent); | |
| const response = await fetch(API_ENDPOINT, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| action: 'save_file', | |
| fileName, | |
| content: fileContent, | |
| isPublic, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| const location = isPublic ? 'Public Folder' : `Secure Data (passkey: ${passkey})`; | |
| const fileType = isCodeFile ? `(${ext.toUpperCase()} code file)` : ''; | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `β File saved: ${fileName} ${fileType}\nπ Saved to: ${location}\nπ Size: ${(new TextEncoder().encode(fileContent).length / 1024).toFixed(2)} KB`, | |
| }, | |
| ], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to save: ${data.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async listFiles(args) { | |
| try { | |
| const { passkey, isPublic = false } = args; | |
| if (!isPublic && !passkey) { | |
| return { | |
| content: [{ type: 'text', text: 'β Passkey is required (or set isPublic=true)' }], | |
| }; | |
| } | |
| const url = new URL(API_ENDPOINT); | |
| if (passkey) url.searchParams.set('passkey', passkey); | |
| if (isPublic) url.searchParams.set('isPublic', 'true'); | |
| const response = await fetch(url, { | |
| method: 'GET', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| if (data.files.length === 0) { | |
| return { | |
| content: [{ type: 'text', text: `π No files found in ${data.location}` }], | |
| }; | |
| } | |
| const fileList = data.files | |
| .map(f => `π ${f.name} (${(f.size / 1024).toFixed(2)} KB)${f.isQuiz ? ' [QUIZ]' : ''}`) | |
| .join('\n'); | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `π Files in ${data.location}:\n\n${fileList}\n\nTotal: ${data.count} file(s)`, | |
| }, | |
| ], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed: ${data.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async deleteFile(args) { | |
| try { | |
| const { fileName, passkey, isPublic = false } = args; | |
| if (!fileName) { | |
| return { | |
| content: [{ type: 'text', text: 'β fileName is required' }], | |
| }; | |
| } | |
| if (!isPublic && !passkey) { | |
| return { | |
| content: [{ type: 'text', text: 'β Passkey is required (or set isPublic=true)' }], | |
| }; | |
| } | |
| const response = await fetch(API_ENDPOINT, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| action: 'delete_file', | |
| fileName, | |
| isPublic, | |
| content: '', // Required by API but not used | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| return { | |
| content: [{ type: 'text', text: `β File '${fileName}' deleted successfully` }], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed: ${data.error || 'File not found'}` }], | |
| }; | |
| } | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async deployQuiz(args) { | |
| try { | |
| const { quizData, passkey } = args; | |
| // Passkey is REQUIRED for quizzes | |
| if (!passkey || passkey.length < 4) { | |
| return { | |
| content: [{ type: 'text', text: 'β Passkey is REQUIRED for quizzes (minimum 4 characters)' }], | |
| }; | |
| } | |
| if (!quizData || !quizData.questions || quizData.questions.length === 0) { | |
| return { | |
| content: [{ type: 'text', text: 'β Quiz must have at least one question' }], | |
| }; | |
| } | |
| // Strip correct answers from questions before saving quiz.json | |
| // This prevents users from seeing the answers in the quiz file | |
| const questionsWithoutAnswers = quizData.questions.map(q => { | |
| const { correctAnswer, correct, answer, ...questionWithoutAnswer } = q; | |
| return questionWithoutAnswer; | |
| }); | |
| // Save quiz.json WITHOUT correct answers (for users to take) | |
| const quizForUsers = { | |
| title: quizData.title, | |
| description: quizData.description, | |
| timeLimit: quizData.timeLimit, | |
| questions: questionsWithoutAnswers, | |
| createdAt: new Date().toISOString(), | |
| version: '1.0', | |
| }; | |
| // Save quiz_key.json WITH correct answers (for grading) - hidden file | |
| const quizAnswerKey = { | |
| title: quizData.title, | |
| questions: quizData.questions.map(q => ({ | |
| id: q.id, | |
| correctAnswer: q.correctAnswer || q.correct || q.answer, | |
| explanation: q.explanation, | |
| points: q.points || 1, | |
| })), | |
| createdAt: new Date().toISOString(), | |
| }; | |
| // Save quiz.json (without answers) | |
| const quizResponse = await fetch(API_ENDPOINT, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| action: 'deploy_quiz', | |
| fileName: 'quiz.json', | |
| content: JSON.stringify(quizForUsers, null, 2), | |
| isPublic: false, | |
| }), | |
| }); | |
| const quizResult = await quizResponse.json(); | |
| if (!quizResponse.ok || !quizResult.success) { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to save quiz: ${quizResult.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| // Save quiz_key.json (with correct answers for grading) | |
| const keyResponse = await fetch(API_ENDPOINT, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| action: 'save_file', | |
| fileName: 'quiz_key.json', | |
| content: JSON.stringify(quizAnswerKey, null, 2), | |
| isPublic: false, | |
| }), | |
| }); | |
| const keyResult = await keyResponse.json(); | |
| if (!keyResponse.ok || !keyResult.success) { | |
| console.error('Failed to save quiz key:', keyResult.error); | |
| // Don't fail the whole operation, just log it | |
| } | |
| const totalPoints = quizData.questions.reduce((sum, q) => sum + (q.points || 1), 0); | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `β Quiz deployed: ${quizData.title}\nπ ${quizData.questions.length} questions, ${totalPoints} points\nπ Secured with passkey: ${passkey}\nπ Answer key saved separately for grading`, | |
| }, | |
| ], | |
| }; | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async generateSongAudio(args) { | |
| try { | |
| const { title, style, lyrics } = args; | |
| // Use a default passkey internally - users don't need to provide it | |
| const passkey = 'voice_default'; | |
| if (!title || !style || !lyrics) { | |
| return { | |
| content: [{ type: 'text', text: 'β title, style, and lyrics are required' }], | |
| }; | |
| } | |
| const response = await fetch(`${BASE_URL}/api/voice/generate-song`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ title, style, lyrics, passkey }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| // Save the content to server storage | |
| await fetch(`${BASE_URL}/api/voice/save`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| content: data.content, | |
| }), | |
| }); | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `π΅ Song "${title}" generated successfully!\\nπΈ Style: ${style}\\nπ± Open the Voice Studio app to listen to your song!`, | |
| }, | |
| ], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to generate song: ${data.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async generateStoryAudio(args) { | |
| try { | |
| const { title, content } = args; | |
| // Use a default passkey internally - users don't need to provide it | |
| const passkey = 'voice_default'; | |
| if (!title || !content) { | |
| return { | |
| content: [{ type: 'text', text: 'β title and content are required' }], | |
| }; | |
| } | |
| const response = await fetch(`${BASE_URL}/api/voice/generate-story`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ title, content, passkey }), | |
| }); | |
| const data = await response.json(); | |
| if (response.ok && data.success) { | |
| // Save the content to server storage | |
| await fetch(`${BASE_URL}/api/voice/save`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| passkey, | |
| content: data.content, | |
| }), | |
| }); | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `π Story "${title}" audio generated successfully!\\nπ± Open the Voice Studio app to listen to your story!`, | |
| }, | |
| ], | |
| }; | |
| } else { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to generate story audio: ${data.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| } catch (error) { | |
| return { | |
| content: [{ type: 'text', text: `β Error: ${error.message}` }], | |
| }; | |
| } | |
| } | |
| async analyzeQuiz(args) { | |
| try { | |
| const { passkey } = args; | |
| // Passkey is REQUIRED for quiz analysis | |
| if (!passkey || passkey.length < 4) { | |
| return { | |
| content: [{ type: 'text', text: 'β Passkey is REQUIRED to access quiz files (minimum 4 characters)' }], | |
| }; | |
| } | |
| // Read all files from secure storage | |
| const quizUrl = new URL(API_ENDPOINT); | |
| quizUrl.searchParams.set('passkey', passkey); | |
| console.log('Fetching quiz files from:', quizUrl.toString()); | |
| const quizResponse = await fetch(quizUrl, { | |
| method: 'GET', | |
| headers: { 'Content-Type': 'application/json' }, | |
| }); | |
| const quizData = await quizResponse.json(); | |
| console.log('API Response:', JSON.stringify(quizData, null, 2).substring(0, 500)); | |
| if (!quizResponse.ok || !quizData.success) { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to read files: ${quizData.error || 'Unknown error'}` }], | |
| }; | |
| } | |
| if (!quizData.files || quizData.files.length === 0) { | |
| return { | |
| content: [{ type: 'text', text: 'β No files found for this passkey. Make sure the quiz has been deployed and answered.' }], | |
| }; | |
| } | |
| // Find quiz.json (questions without answers) | |
| const quizFile = quizData.files.find(f => f.name === 'quiz.json'); | |
| if (!quizFile) { | |
| return { | |
| content: [{ type: 'text', text: `β quiz.json not found. Available files: ${quizData.files.map(f => f.name).join(', ')}` }], | |
| }; | |
| } | |
| if (!quizFile.content) { | |
| return { | |
| content: [{ type: 'text', text: 'β quiz.json exists but content is empty or could not be read' }], | |
| }; | |
| } | |
| // Find quiz_key.json (correct answers for grading) | |
| // For backward compatibility, if quiz_key.json doesn't exist, try to get answers from quiz.json | |
| const keyFile = quizData.files.find(f => f.name === 'quiz_key.json'); | |
| const hasAnswerKey = keyFile && keyFile.content; | |
| // For backward compatibility: if no quiz_key.json exists, we'll need to get answers from quiz.json (old format) | |
| // This happens for quizzes created before the answer-key separation was implemented | |
| // Find quiz_answers.json (user's answers) | |
| const answersFile = quizData.files.find(f => f.name === 'quiz_answers.json'); | |
| if (!answersFile) { | |
| return { | |
| content: [{ type: 'text', text: `β quiz_answers.json not found. The user needs to complete the quiz first. Available files: ${quizData.files.map(f => f.name).join(', ')}` }], | |
| }; | |
| } | |
| if (!answersFile.content) { | |
| return { | |
| content: [{ type: 'text', text: 'β quiz_answers.json exists but content is empty or could not be read' }], | |
| }; | |
| } | |
| console.log('Quiz file content length:', quizFile.content?.length); | |
| console.log('Key file found:', !!keyFile, 'content length:', keyFile?.content?.length); | |
| console.log('Answers file content length:', answersFile.content?.length); | |
| // Parse the quiz, answer key, and user answers | |
| let quiz, answerKey, userAnswersData; | |
| try { | |
| quiz = typeof quizFile.content === 'string' ? JSON.parse(quizFile.content) : quizFile.content; | |
| } catch (e) { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to parse quiz.json: ${e.message}` }], | |
| }; | |
| } | |
| // Parse answer key (from quiz_key.json if exists, or fall back to quiz.json for backward compatibility) | |
| if (hasAnswerKey) { | |
| try { | |
| answerKey = typeof keyFile.content === 'string' ? JSON.parse(keyFile.content) : keyFile.content; | |
| } catch (e) { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to parse quiz_key.json: ${e.message}` }], | |
| }; | |
| } | |
| } else { | |
| // Backward compatibility: create answer key from quiz.json (old format had answers in quiz.json) | |
| console.log('No quiz_key.json found - using backward compatibility mode with quiz.json'); | |
| answerKey = { | |
| title: quiz.title, | |
| questions: (quiz.questions || []).map(q => ({ | |
| id: q.id, | |
| correctAnswer: q.correctAnswer || q.correct || q.answer, | |
| explanation: q.explanation, | |
| points: q.points || 1, | |
| })), | |
| }; | |
| } | |
| try { | |
| userAnswersData = typeof answersFile.content === 'string' ? JSON.parse(answersFile.content) : answersFile.content; | |
| } catch (e) { | |
| return { | |
| content: [{ type: 'text', text: `β Failed to parse quiz_answers.json: ${e.message}` }], | |
| }; | |
| } | |
| console.log('Parsed quiz:', quiz.title, 'Questions:', quiz.questions?.length); | |
| console.log('Parsed answer key:', answerKey.questions?.length, 'answers'); | |
| console.log('Parsed user answers:', JSON.stringify(userAnswersData, null, 2).substring(0, 300)); | |
| // Convert user answers array to object for easier lookup | |
| const userAnswers = {}; | |
| if (userAnswersData.answers && Array.isArray(userAnswersData.answers)) { | |
| // QuizApp format: { answers: [{ questionId, answer }], metadata: {...} } | |
| userAnswersData.answers.forEach(item => { | |
| userAnswers[item.questionId] = item.answer; | |
| }); | |
| } else if (typeof userAnswersData === 'object') { | |
| // Direct object format | |
| Object.assign(userAnswers, userAnswersData); | |
| } | |
| // Convert answer key to object for easier lookup | |
| const correctAnswers = {}; | |
| const explanations = {}; | |
| const pointsMap = {}; | |
| if (answerKey.questions && Array.isArray(answerKey.questions)) { | |
| answerKey.questions.forEach(q => { | |
| correctAnswers[q.id] = q.correctAnswer; | |
| explanations[q.id] = q.explanation; | |
| pointsMap[q.id] = q.points || 1; | |
| }); | |
| } | |
| console.log('Processed user answers:', userAnswers); | |
| console.log('Processed correct answers:', correctAnswers); | |
| // Analyze the answers | |
| let correctCount = 0; | |
| let totalPoints = 0; | |
| let maxPoints = 0; | |
| const feedback = []; | |
| const questionsArray = quiz.questions || []; | |
| if (questionsArray.length === 0) { | |
| return { | |
| content: [{ type: 'text', text: 'β Quiz has no questions to analyze' }], | |
| }; | |
| } | |
| questionsArray.forEach((question, index) => { | |
| const questionId = question.id || `question_${index}`; | |
| const userAnswer = userAnswers[questionId]; | |
| const correctAnswer = correctAnswers[questionId]; | |
| const explanation = explanations[questionId]; | |
| const points = pointsMap[questionId] || 1; | |
| maxPoints += points; | |
| if (!userAnswer) { | |
| feedback.push(`β οΈ Q${index + 1}: "${question.question.substring(0, 50)}..." - Not answered`); | |
| return; | |
| } | |
| if (!correctAnswer) { | |
| // No correct answer in key - just show the user's answer for manual review | |
| feedback.push(`π Q${index + 1}: "${question.question.substring(0, 50)}..." \n User answered: "${userAnswer}" \n (No correct answer in key - manual review needed)`); | |
| return; | |
| } | |
| // Check if answer is correct | |
| let isCorrect = false; | |
| if (Array.isArray(correctAnswer)) { | |
| isCorrect = correctAnswer.some(ca => | |
| String(ca).toLowerCase().trim() === String(userAnswer).toLowerCase().trim() | |
| ); | |
| } else { | |
| isCorrect = String(correctAnswer).toLowerCase().trim() === String(userAnswer).toLowerCase().trim(); | |
| } | |
| if (isCorrect) { | |
| correctCount++; | |
| totalPoints += points; | |
| feedback.push(`β Q${index + 1}: Correct! (+${points} pts)`); | |
| } else { | |
| feedback.push(`β Q${index + 1}: "${question.question.substring(0, 40)}..."\n Your answer: "${userAnswer}"\n Correct: "${correctAnswer}"${explanation ? `\n π‘ ${explanation}` : ''}`); | |
| } | |
| }); | |
| const percentage = maxPoints > 0 ? Math.round((totalPoints / maxPoints) * 100) : 0; | |
| const grade = percentage >= 90 ? 'A' : | |
| percentage >= 80 ? 'B' : | |
| percentage >= 70 ? 'C' : | |
| percentage >= 60 ? 'D' : 'F'; | |
| // Include metadata if available | |
| let metadataInfo = ''; | |
| if (userAnswersData.metadata) { | |
| const meta = userAnswersData.metadata; | |
| metadataInfo = `\nβ±οΈ Time taken: ${Math.floor((meta.timeTakenSeconds || 0) / 60)}m ${(meta.timeTakenSeconds || 0) % 60}s`; | |
| if (meta.timeExceeded) { | |
| metadataInfo += ' (Time limit exceeded!)'; | |
| } | |
| metadataInfo += `\nπ Completed: ${meta.completedAt || 'Unknown'}`; | |
| } | |
| return { | |
| content: [ | |
| { | |
| type: 'text', | |
| text: `π Quiz Analysis Results for "${quiz.title || 'Untitled Quiz'}" | |
| π Score: ${totalPoints}/${maxPoints} points (${percentage}%) | |
| β Correct: ${correctCount}/${questionsArray.length} questions | |
| π― Grade: ${grade}${metadataInfo} | |
| π Detailed Feedback: | |
| ${feedback.join('\n\n')} | |
| ${percentage >= 70 ? 'π Great job!' : 'π Keep studying and try again!'}`, | |
| }, | |
| ], | |
| }; | |
| } catch (error) { | |
| console.error('analyzeQuiz error:', error); | |
| return { | |
| content: [{ type: 'text', text: `β Error analyzing quiz: ${error.message}\n\nStack: ${error.stack}` }], | |
| }; | |
| } | |
| } | |
| async run() { | |
| const transport = new StdioServerTransport(); | |
| await this.server.connect(transport); | |
| console.error('ReubenOS MCP Server running with passkey authentication...'); | |
| } | |
| } | |
| const server = new ReubenOSMCPServer(); | |
| server.run(); |