# 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);
```