# Directory Structure ``` ├── .gitignore ├── LICENSE ├── package-lock.json ├── package.json ├── README.md ├── src │ └── index.ts └── tsconfig.json ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` node_modules/ build/ *.log .env* ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown # Moodle MCP Server An MCP (Model Context Protocol) server that enables LLMs to interact with the Moodle platform to manage courses, students, assignments, and quizzes. ## Features ### Student Management Tools - `list_students` - Retrieves the list of students enrolled in the course - Displays ID, name, email, and last access time for each student ### Assignment Management Tools - `get_assignments` - Retrieves all available assignments in the course - Includes information such as ID, name, description, due date, and maximum grade - `get_student_submissions` - Examines a student's submissions for a specific assignment - Requires the assignment ID and optionally the student ID - `provide_assignment_feedback` - Provides grades and comments for a student's submission - Requires student ID, assignment ID, grade, and feedback comment ### Quiz Management Tools - `get_quizzes` - Retrieves all available quizzes in the course - Includes information such as ID, name, description, opening/closing dates, and maximum grade - `get_quiz_attempts` - Examines a student's attempts on a specific quiz - Requires the quiz ID and optionally the student ID - `provide_quiz_feedback` - Provides comments for a quiz attempt - Requires the attempt ID and feedback comment ## Requirements - Node.js (v14 or higher) - Moodle API token with appropriate permissions - Moodle course ID ## Installation 1. Clone this repository: ```bash git clone https://github.com/your-username/moodle-mcp-server.git cd moodle-mcp-server ``` 2. Install dependencies: ```bash npm install ``` 3. Create a `.env` file with the following configuration: ``` MOODLE_API_URL=https://your-moodle.com/webservice/rest/server.php MOODLE_API_TOKEN=your_api_token MOODLE_COURSE_ID=1 # Replace with your course ID ``` 4. Build the server: ```bash npm run build ``` ## Usage with Claude To use with Claude Desktop, add the server configuration: On MacOS: `~/Library/Application Support/Claude/claude_desktop_config.json` On Windows: `%APPDATA%/Claude/claude_desktop_config.json` ```json { "mcpServers": { "moodle-mcp-server": { "command": "/path/to/node", "args": [ "/path/to/moodle-mcp-server/build/index.js" ], "env": { "MOODLE_API_URL": "https://your-moodle.com/webservice/rest/server.php", "MOODLE_API_TOKEN": "your_moodle_api_token", "MOODLE_COURSE_ID": "your_course_id" }, "disabled": false, "autoApprove": [] } } } ``` For Windows users, the paths would use backslashes: ```json { "mcpServers": { "moodle-mcp-server": { "command": "C:\\path\\to\\node.exe", "args": [ "C:\\path\\to\\moodle-mcp-server\\build\\index.js" ], "env": { "MOODLE_API_URL": "https://your-moodle.com/webservice/rest/server.php", "MOODLE_API_TOKEN": "your_moodle_api_token", "MOODLE_COURSE_ID": "your_course_id" }, "disabled": false, "autoApprove": [] } } } ``` Once configured, Claude will be able to interact with your Moodle course to: - View the list of students and their submissions - Provide comments and grades for assignments - Examine quiz attempts and offer feedback ## Development For development with auto-rebuild: ```bash npm run watch ``` ### Debugging MCP servers communicate through stdio, which can make debugging challenging. We recommend using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector): ```bash npm run inspector ``` The Inspector will provide a URL to access debugging tools in your browser. ## Obtaining a Moodle API Token 1. Log in to your Moodle site as an administrator 2. Go to Site Administration > Plugins > Web Services > Manage tokens 3. Create a new token with the necessary permissions to manage courses 4. Copy the generated token and add it to your `.env` file ## Security - Never share your `.env` file or Moodle API token - Ensure the MCP server only has access to the courses it needs to manage - Use a token with the minimum necessary permissions ## License [MIT](LICENSE) ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json { "compilerOptions": { "target": "ES2022", "module": "Node16", "moduleResolution": "Node16", "outDir": "./build", "rootDir": "./src", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true }, "include": ["src/**/*"], "exclude": ["node_modules"] } ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json { "name": "moodle-mcp-server", "version": "0.1.0", "description": "Moddle MCP server", "private": true, "type": "module", "bin": { "moodle-mcp-server": "./build/index.js" }, "files": [ "build" ], "scripts": { "build": "tsc && node -e \"require('fs').chmodSync('build/index.js', '755')\"", "prepare": "npm run build", "watch": "tsc --watch", "inspector": "npx @modelcontextprotocol/inspector build/index.js" }, "dependencies": { "@modelcontextprotocol/sdk": "0.6.0", "axios": "^1.8.2" }, "devDependencies": { "@types/node": "^20.11.24", "typescript": "^5.3.3" } } ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript #!/usr/bin/env node import { Server } from '@modelcontextprotocol/sdk/server/index.js'; import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError, } from '@modelcontextprotocol/sdk/types.js'; import axios from 'axios'; // Configuración de variables de entorno const MOODLE_API_URL = process.env.MOODLE_API_URL; const MOODLE_API_TOKEN = process.env.MOODLE_API_TOKEN; const MOODLE_COURSE_ID = process.env.MOODLE_COURSE_ID; // Verificar que las variables de entorno estén definidas if (!MOODLE_API_URL) { throw new Error('MOODLE_API_URL environment variable is required'); } if (!MOODLE_API_TOKEN) { throw new Error('MOODLE_API_TOKEN environment variable is required'); } if (!MOODLE_COURSE_ID) { throw new Error('MOODLE_COURSE_ID environment variable is required'); } // Interfaces para los tipos de datos interface Student { id: number; username: string; firstname: string; lastname: string; email: string; } interface Assignment { id: number; name: string; duedate: number; allowsubmissionsfromdate: number; grade: number; timemodified: number; cutoffdate: number; } interface Quiz { id: number; name: string; timeopen: number; timeclose: number; grade: number; timemodified: number; } interface Submission { id: number; userid: number; status: string; timemodified: number; gradingstatus: string; gradefordisplay?: string; } interface SubmissionContent { assignment: number; userid: number; status: string; submissiontext?: string; plugins?: Array<{ type: string; content?: string; files?: Array<{ filename: string; fileurl: string; filesize: number; filetype: string; }>; }>; timemodified: number; } interface QuizGradeResponse { hasgrade: boolean; grade?: string; // Este campo solo está presente si hasgrade es true } class MoodleMcpServer { private server: Server; private axiosInstance; constructor() { this.server = new Server( { name: 'moodle-mcp-server', version: '0.1.0', }, { capabilities: { tools: {}, }, } ); this.axiosInstance = axios.create({ baseURL: MOODLE_API_URL, params: { wstoken: MOODLE_API_TOKEN, moodlewsrestformat: 'json', }, }); this.setupToolHandlers(); // Error handling this.server.onerror = (error) => console.error('[MCP Error]', error); process.on('SIGINT', async () => { await this.server.close(); process.exit(0); }); } private setupToolHandlers() { this.server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'get_students', description: 'Obtiene la lista de estudiantes inscritos en el curso configurado', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_assignments', description: 'Obtiene la lista de tareas asignadas en el curso configurado', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_quizzes', description: 'Obtiene la lista de quizzes en el curso configurado', inputSchema: { type: 'object', properties: {}, required: [], }, }, { name: 'get_submissions', description: 'Obtiene las entregas de tareas en el curso configurado', inputSchema: { type: 'object', properties: { studentId: { type: 'number', description: 'ID opcional del estudiante. Si no se proporciona, se devolverán entregas de todos los estudiantes', }, assignmentId: { type: 'number', description: 'ID opcional de la tarea. Si no se proporciona, se devolverán todas las entregas', }, }, required: [], }, }, { name: 'provide_feedback', description: 'Proporciona feedback sobre una tarea entregada por un estudiante', inputSchema: { type: 'object', properties: { studentId: { type: 'number', description: 'ID del estudiante', }, assignmentId: { type: 'number', description: 'ID de la tarea', }, grade: { type: 'number', description: 'Calificación numérica a asignar', }, feedback: { type: 'string', description: 'Texto del feedback a proporcionar', }, }, required: ['studentId', 'assignmentId', 'feedback'], }, }, { name: 'get_submission_content', description: 'Obtiene el contenido detallado de una entrega específica, incluyendo texto y archivos adjuntos', inputSchema: { type: 'object', properties: { studentId: { type: 'number', description: 'ID del estudiante', }, assignmentId: { type: 'number', description: 'ID de la tarea', }, }, required: ['studentId', 'assignmentId'], }, }, { name: 'get_quiz_grade', description: 'Obtiene la calificación de un estudiante en un quiz específico', inputSchema: { type: 'object', properties: { studentId: { type: 'number', description: 'ID del estudiante', }, quizId: { type: 'number', description: 'ID del quiz', }, }, required: ['studentId', 'quizId'], }, }, ], })); this.server.setRequestHandler(CallToolRequestSchema, async (request) => { console.error(`[Tool] Executing tool: ${request.params.name}`); try { switch (request.params.name) { case 'get_students': return await this.getStudents(); case 'get_assignments': return await this.getAssignments(); case 'get_quizzes': return await this.getQuizzes(); case 'get_submissions': return await this.getSubmissions(request.params.arguments); case 'provide_feedback': return await this.provideFeedback(request.params.arguments); case 'get_submission_content': return await this.getSubmissionContent(request.params.arguments); case 'get_quiz_grade': return await this.getQuizGrade(request.params.arguments); default: throw new McpError( ErrorCode.MethodNotFound, `Unknown tool: ${request.params.name}` ); } } catch (error) { console.error('[Error]', error); if (axios.isAxiosError(error)) { return { content: [ { type: 'text', text: `Moodle API error: ${ error.response?.data?.message || error.message }`, }, ], isError: true, }; } throw error; } }); } private async getStudents() { console.error('[API] Requesting enrolled users'); const response = await this.axiosInstance.get('', { params: { wsfunction: 'core_enrol_get_enrolled_users', courseid: MOODLE_COURSE_ID, }, }); const students = response.data .filter((user: any) => user.roles.some((role: any) => role.shortname === 'student')) .map((student: any) => ({ id: student.id, username: student.username, firstname: student.firstname, lastname: student.lastname, email: student.email, })); return { content: [ { type: 'text', text: JSON.stringify(students, null, 2), }, ], }; } private async getAssignments() { console.error('[API] Requesting assignments'); const response = await this.axiosInstance.get('', { params: { wsfunction: 'mod_assign_get_assignments', courseids: [MOODLE_COURSE_ID], }, }); const assignments = response.data.courses[0]?.assignments || []; return { content: [ { type: 'text', text: JSON.stringify(assignments, null, 2), }, ], }; } private async getQuizzes() { console.error('[API] Requesting quizzes'); const response = await this.axiosInstance.get('', { params: { wsfunction: 'mod_quiz_get_quizzes_by_courses', courseids: [MOODLE_COURSE_ID], }, }); const quizzes = response.data.quizzes || []; return { content: [ { type: 'text', text: JSON.stringify(quizzes, null, 2), }, ], }; } private async getSubmissions(args: any) { const studentId = args.studentId; const assignmentId = args.assignmentId; console.error(`[API] Requesting submissions${studentId ? ` for student ${studentId}` : ''}`); // Primero obtenemos todas las tareas const assignmentsResponse = await this.axiosInstance.get('', { params: { wsfunction: 'mod_assign_get_assignments', courseids: [MOODLE_COURSE_ID], }, }); const assignments = assignmentsResponse.data.courses[0]?.assignments || []; // Si se especificó un ID de tarea, filtramos solo esa tarea const targetAssignments = assignmentId ? assignments.filter((a: any) => a.id === assignmentId) : assignments; if (targetAssignments.length === 0) { return { content: [ { type: 'text', text: 'No se encontraron tareas para el criterio especificado.', }, ], }; } // Para cada tarea, obtenemos todas las entregas const submissionsPromises = targetAssignments.map(async (assignment: any) => { const submissionsResponse = await this.axiosInstance.get('', { params: { wsfunction: 'mod_assign_get_submissions', assignmentids: [assignment.id], }, }); const submissions = submissionsResponse.data.assignments[0]?.submissions || []; // Obtenemos las calificaciones para esta tarea const gradesResponse = await this.axiosInstance.get('', { params: { wsfunction: 'mod_assign_get_grades', assignmentids: [assignment.id], }, }); const grades = gradesResponse.data.assignments[0]?.grades || []; // Si se especificó un ID de estudiante, filtramos solo sus entregas const targetSubmissions = studentId ? submissions.filter((s: any) => s.userid === studentId) : submissions; // Procesamos cada entrega const processedSubmissions = targetSubmissions.map((submission: any) => { const studentGrade = grades.find((g: any) => g.userid === submission.userid); return { userid: submission.userid, status: submission.status, timemodified: new Date(submission.timemodified * 1000).toISOString(), grade: studentGrade ? studentGrade.grade : 'No calificado', }; }); return { assignment: assignment.name, assignmentId: assignment.id, submissions: processedSubmissions.length > 0 ? processedSubmissions : 'No hay entregas', }; }); const results = await Promise.all(submissionsPromises); return { content: [ { type: 'text', text: JSON.stringify(results, null, 2), }, ], }; } private async provideFeedback(args: any) { if (!args.studentId || !args.assignmentId || !args.feedback) { throw new McpError( ErrorCode.InvalidParams, 'Student ID, Assignment ID, and feedback are required' ); } console.error(`[API] Providing feedback for student ${args.studentId} on assignment ${args.assignmentId}`); const response = await this.axiosInstance.get('', { params: { wsfunction: 'mod_assign_save_grade', assignmentid: args.assignmentId, userid: args.studentId, grade: args.grade || 0, attemptnumber: -1, // Último intento addattempt: 0, workflowstate: 'released', applytoall: 0, plugindata: { assignfeedbackcomments_editor: { text: args.feedback, format: 1, // Formato HTML }, }, }, }); return { content: [ { type: 'text', text: `Feedback proporcionado correctamente para el estudiante ${args.studentId} en la tarea ${args.assignmentId}.`, }, ], }; } private async getSubmissionContent(args: any) { if (!args.studentId || !args.assignmentId) { throw new McpError( ErrorCode.InvalidParams, 'Student ID and Assignment ID are required' ); } console.error(`[API] Requesting submission content for student ${args.studentId} on assignment ${args.assignmentId}`); try { // Utilizamos la función mod_assign_get_submission_status para obtener el contenido detallado const response = await this.axiosInstance.get('', { params: { wsfunction: 'mod_assign_get_submission_status', assignid: args.assignmentId, userid: args.studentId, }, }); // Procesamos la respuesta para extraer el contenido relevante const submissionData = response.data.submission || {}; const plugins = response.data.lastattempt?.submission?.plugins || []; // Extraemos el texto de la entrega y los archivos adjuntos let submissionText = ''; const files = []; for (const plugin of plugins) { // Procesamos el plugin de texto en línea if (plugin.type === 'onlinetext') { const textField = plugin.editorfields?.find((field: any) => field.name === 'onlinetext'); if (textField) { submissionText = textField.text || ''; } } // Procesamos el plugin de archivos if (plugin.type === 'file') { const filesList = plugin.fileareas?.find((area: any) => area.area === 'submission_files'); if (filesList && filesList.files) { for (const file of filesList.files) { files.push({ filename: file.filename, fileurl: file.fileurl, filesize: file.filesize, filetype: file.mimetype, }); } } } } // Construimos el objeto de respuesta const submissionContent = { assignment: args.assignmentId, userid: args.studentId, status: submissionData.status || 'unknown', submissiontext: submissionText, plugins: [ { type: 'onlinetext', content: submissionText, }, { type: 'file', files: files, }, ], timemodified: submissionData.timemodified || 0, }; return { content: [ { type: 'text', text: JSON.stringify(submissionContent, null, 2), }, ], }; } catch (error) { console.error('[Error]', error); if (axios.isAxiosError(error)) { return { content: [ { type: 'text', text: `Error al obtener el contenido de la entrega: ${ error.response?.data?.message || error.message }`, }, ], isError: true, }; } throw error; } } private async getQuizGrade(args: any) { if (!args.studentId || !args.quizId) { throw new McpError( ErrorCode.InvalidParams, 'Student ID and Quiz ID are required' ); } console.error(`[API] Requesting quiz grade for student ${args.studentId} on quiz ${args.quizId}`); try { const response = await this.axiosInstance.get('', { params: { wsfunction: 'mod_quiz_get_user_best_grade', quizid: args.quizId, userid: args.studentId, }, }); // Procesamos la respuesta const result = { quizId: args.quizId, studentId: args.studentId, hasGrade: response.data.hasgrade, grade: response.data.hasgrade ? response.data.grade : 'No calificado', }; return { content: [ { type: 'text', text: JSON.stringify(result, null, 2), }, ], }; } catch (error) { console.error('[Error]', error); if (axios.isAxiosError(error)) { return { content: [ { type: 'text', text: `Error al obtener la calificación del quiz: ${ error.response?.data?.message || error.message }`, }, ], isError: true, }; } throw error; } } async run() { const transport = new StdioServerTransport(); await this.server.connect(transport); console.error('Moodle MCP server running on stdio'); } } const server = new MoodleMcpServer(); server.run().catch(console.error); ```