@charlie.act7/canvas-mcp-server 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mcp.json +8 -6
- package/LICENSE +21 -0
- package/README.md +49 -22
- package/dist/http-server.js +139 -5
- package/dist/index.js +35 -4
- package/dist/services/agent-runner.js +214 -0
- package/dist/services/canvas-client.js +248 -5
- package/dist/services/gemini-runner.js +124 -0
- package/dist/tools/analytics-tools.js +118 -0
- package/dist/tools/assignment-tools.js +77 -1
- package/dist/tools/communication-tools.js +101 -0
- package/dist/tools/conversation-tools.js +130 -0
- package/dist/tools/course-tools.js +142 -0
- package/dist/tools/enrollment-tools.js +202 -0
- package/dist/tools/new-quiz-tools.js +357 -0
- package/dist/tools/peer-review-tools.js +130 -0
- package/dist/tools/quiz-tools.js +312 -3
- package/dist/tools/rubric-tools.js +159 -0
- package/package.json +70 -68
- package/dist/tools/question-bank-tools.js +0 -238
package/.mcp.json
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
{
|
|
2
|
-
"
|
|
3
|
-
"
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
"
|
|
7
|
-
|
|
2
|
+
"mcpServers": {
|
|
3
|
+
"canvas": {
|
|
4
|
+
"command": "npx",
|
|
5
|
+
"args": ["-y", "@charlie.act7/canvas-mcp-server"],
|
|
6
|
+
"env": {
|
|
7
|
+
"CANVAS_API_TOKEN": "${CANVAS_API_TOKEN}",
|
|
8
|
+
"CANVAS_API_DOMAIN": "${CANVAS_API_DOMAIN}"
|
|
9
|
+
}
|
|
8
10
|
}
|
|
9
11
|
}
|
|
10
12
|
}
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Charlie Cárdenas Toledo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# 🎓 Canvas LMS MCP Server
|
|
2
2
|
|
|
3
|
+
[](https://github.com/CharlieCardenasToledo/mcp-canvas-server/actions/workflows/ci.yml)
|
|
3
4
|
[](https://www.npmjs.com/package/@charlie.act7/canvas-mcp-server)
|
|
5
|
+
[](https://www.npmjs.com/package/@charlie.act7/canvas-mcp-server)
|
|
4
6
|
[](https://opensource.org/licenses/MIT)
|
|
5
7
|
|
|
6
8
|
Bring AI to your Canvas Virtual Classroom! 🚀
|
|
@@ -56,16 +58,37 @@ Here are some realistic, everyday prompts you can use with your AI assistant:
|
|
|
56
58
|
* 💬 *"Show me all ungraded submissions for 'Essay 1: Introduction to Sociology' in Sociology 101."*
|
|
57
59
|
* 💬 *"Who is in Student Group A for the Chemistry class?"*
|
|
58
60
|
* 💬 *"Does the assignment 'Project Proposal' have an active rubric associated? If so, retrieve its criteria."*
|
|
61
|
+
* 💬 *"Search for everything related to 'photosynthesis' across my Biology course — assignments, pages, and discussions."*
|
|
59
62
|
|
|
60
63
|
### ✍️ Creating & Organizing Course Content
|
|
61
64
|
* 💬 *"Create a new module named 'Week 1: Foundations' in my course."*
|
|
62
65
|
* 💬 *"Add a SubHeader 'REQUIRED READINGS' inside the 'Week 1' module, and link the syllabus page to it."*
|
|
63
66
|
* 💬 *"In my Business course, create an assignment called 'Case Study 1: Market Analysis'. Add an instructions table with columns for Criteria, Requirements, and Points."*
|
|
67
|
+
* 💬 *"Create a threaded discussion topic in my course titled 'Weekly Reflection' and pin it to the top."*
|
|
64
68
|
|
|
65
69
|
### 💯 Grading & Absence Management
|
|
66
70
|
* 💬 *"For assignment 'Case Study 1', find all students who haven't submitted their work. Assign them a grade of 0 and add the comment: 'Activity not submitted. Please contact the instructor if you have a valid excuse.'"*
|
|
67
71
|
* 💬 *"Grade John's submission for 'Essay 1' with a 90 based on the rubric, and add a comment: 'Great job! The analysis is well-structured, though you could expand more on the conclusion. Keep it up!'"*
|
|
68
72
|
|
|
73
|
+
### 📊 Student Engagement & Analytics
|
|
74
|
+
* 💬 *"Show me the activity analytics for my Calculus course — how active have students been this week?"*
|
|
75
|
+
* 💬 *"Which students haven't been active in course 12345 in the last few days? I want to reach out to them."*
|
|
76
|
+
* 💬 *"Get the analytics for student [ID] in my Biology course — how many page views and participations do they have?"*
|
|
77
|
+
|
|
78
|
+
### 💬 Messaging & Communication
|
|
79
|
+
* 💬 *"Send a private message to student [ID] reminding them their 'Project Proposal' is due tomorrow."*
|
|
80
|
+
* 💬 *"How many unread messages do I have in my Canvas inbox?"*
|
|
81
|
+
* 💬 *"Show me my last 10 inbox conversations."*
|
|
82
|
+
|
|
83
|
+
### 👥 Enrollment & Student Management
|
|
84
|
+
* 💬 *"Who is enrolled in my course? Show me students and TAs separately."*
|
|
85
|
+
* 💬 *"Search for a student named 'Maria Gonzalez' in account 1."*
|
|
86
|
+
* 💬 *"Enroll user [ID] as a TA in my Physics course."*
|
|
87
|
+
|
|
88
|
+
### 🔄 Peer Reviews
|
|
89
|
+
* 💬 *"List all peer review assignments for 'Research Paper' in course 12345."*
|
|
90
|
+
* 💬 *"Manually assign student [ID] to review [other student ID]'s submission for 'Essay 2'."*
|
|
91
|
+
|
|
69
92
|
---
|
|
70
93
|
|
|
71
94
|
## Setup Guide
|
|
@@ -133,28 +156,32 @@ This will guide you step-by-step to input your domain and API token, storing the
|
|
|
133
156
|
## Supported Tools & Resources
|
|
134
157
|
|
|
135
158
|
<details>
|
|
136
|
-
<summary><b>View
|
|
137
|
-
|
|
138
|
-
###
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
|
143
|
-
|
|
144
|
-
| **
|
|
145
|
-
| **
|
|
146
|
-
| **
|
|
147
|
-
| **
|
|
148
|
-
| **
|
|
149
|
-
| **
|
|
150
|
-
| **
|
|
151
|
-
| **
|
|
152
|
-
| **
|
|
153
|
-
| **
|
|
154
|
-
| **Groups** |
|
|
155
|
-
| **
|
|
156
|
-
| **
|
|
157
|
-
| **
|
|
159
|
+
<summary><b>View all 111 tools organized by category</b></summary>
|
|
160
|
+
|
|
161
|
+
### Tool Inventory
|
|
162
|
+
|
|
163
|
+
| Category | Tools | Description |
|
|
164
|
+
|---|---|---|
|
|
165
|
+
| **Courses** | `list_courses` · `create_course` · `update_course` · `get_syllabus` | Manage and configure courses |
|
|
166
|
+
| **Modules** | `list_modules` · `create_module` · `update_module` · `delete_module` · `create_module_item` · `update_module_item` · `delete_module_item` | Full CRUD for modules and their items |
|
|
167
|
+
| **Pages** | `list_pages` · `get_page_content` · `create_page` · `update_page` · `delete_page` | Manage wiki pages |
|
|
168
|
+
| **Files & Folders** | `list_files` · `upload_file` · `update_file` · `delete_file` · `list_folders` · `create_folder` · `update_folder` · `delete_folder` | File management with folder support |
|
|
169
|
+
| **Assignments** | `get_assignments` · `get_assignment` · `create_assignment` · `update_assignment` · `delete_assignment` · `update_assignment_dates` · `bulk_update_due_dates` · `list_assignment_groups` | Full assignment lifecycle |
|
|
170
|
+
| **Submissions** | `get_submissions` · `get_submission` · `get_submission_comments` · `delete_submission_comment` · `submit_assignment` | View and manage student submissions |
|
|
171
|
+
| **Grading** | `grade_submission` · `grade_multiple_submissions` · `audit_course` | Grade individually or in bulk |
|
|
172
|
+
| **Rubrics** | `list_rubrics` · `get_rubric` · `create_rubric` · `update_rubric` · `create_rubric_association` | Build and attach grading rubrics |
|
|
173
|
+
| **Quizzes (Classic)** | `list_quizzes` · `get_quiz` · `create_quiz` · `update_quiz` · `update_quiz_dates` · `list_quiz_questions` · `get_quiz_question` · `create_quiz_question` · `update_quiz_question` · `delete_quiz_question` · `create_quiz_group` | Classic Canvas quiz engine |
|
|
174
|
+
| **New Quizzes (LTI)** | `create_new_quiz` · `update_new_quiz` · `delete_new_quiz` · `list_new_quiz_items` · `get_new_quiz_item` · `create_new_quiz_item` · `update_new_quiz_item` · `delete_new_quiz_item` | Modern LTI quiz engine (`/api/quiz/v1`) |
|
|
175
|
+
| **Students** | `list_students` · `list_students_with_grades` · `get_student_grades` · `get_student_assignments` · `list_assignment_due_dates` | Roster and progress tracking |
|
|
176
|
+
| **Enrollments** | `list_course_enrollments` · `enroll_user` · `remove_enrollment` · `get_user` · `get_profile` · `search_users` | Manage who is in your course |
|
|
177
|
+
| **Groups** | `list_group_categories` · `create_group_category` · `list_groups_in_category` · `create_group` · `assign_unassigned_members` · `add_group_member` | Student group management |
|
|
178
|
+
| **Discussions** | `list_discussions` · `get_discussion_entries` · `create_discussion` · `delete_discussion` · `post_discussion_reply` | Discussion boards |
|
|
179
|
+
| **Announcements** | `list_announcements` · `post_announcement` · `update_announcement` | Course announcements |
|
|
180
|
+
| **Conversations** | `list_conversations` · `get_conversation` · `get_conversation_unread_count` · `send_conversation` · `reply_to_conversation` | Private inbox messaging |
|
|
181
|
+
| **Calendar** | `list_appointment_groups` · `get_appointment_group` · `create_appointment_group` · `update_appointment_group` · `delete_appointment_group` · `list_appointment_group_users` · `list_appointment_group_groups` · `get_next_appointment` | Scheduling and appointments |
|
|
182
|
+
| **Analytics** | `get_course_analytics` · `get_student_analytics` · `get_course_activity_stream` · `search_course_content` | Engagement data and content search |
|
|
183
|
+
| **Peer Reviews** | `list_peer_reviews` · `get_submission_peer_reviews` · `create_peer_review` · `delete_peer_review` | Configure and manage peer assessments |
|
|
184
|
+
| **Health & Config** | `health_check` · `set_canvas_config` | Verify connection and update credentials at runtime |
|
|
158
185
|
|
|
159
186
|
### Supported MCP Resources
|
|
160
187
|
For clients supporting direct resources:
|
package/dist/http-server.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import Fastify from "fastify";
|
|
2
2
|
import swagger from "@fastify/swagger";
|
|
3
3
|
import swaggerUi from "@fastify/swagger-ui";
|
|
4
|
+
import * as fs from "node:fs/promises";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
import { AgentRunner } from "./services/agent-runner.js";
|
|
7
|
+
import { GeminiRunner } from "./services/gemini-runner.js";
|
|
4
8
|
function buildOpenApiSpec() {
|
|
5
9
|
return {
|
|
6
10
|
openapi: "3.1.0",
|
|
@@ -21,7 +25,12 @@ function buildOpenApiSpec() {
|
|
|
21
25
|
summary: "Health check",
|
|
22
26
|
responses: {
|
|
23
27
|
"200": {
|
|
24
|
-
description: "Server is healthy"
|
|
28
|
+
description: "Server is healthy",
|
|
29
|
+
content: {
|
|
30
|
+
"application/json": {
|
|
31
|
+
schema: { type: "object", properties: { ok: { type: "boolean" } } }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
25
34
|
}
|
|
26
35
|
}
|
|
27
36
|
}
|
|
@@ -32,7 +41,20 @@ function buildOpenApiSpec() {
|
|
|
32
41
|
summary: "Privacy policy",
|
|
33
42
|
responses: {
|
|
34
43
|
"200": {
|
|
35
|
-
description: "Privacy policy text"
|
|
44
|
+
description: "Privacy policy text",
|
|
45
|
+
content: {
|
|
46
|
+
"application/json": {
|
|
47
|
+
schema: {
|
|
48
|
+
type: "object",
|
|
49
|
+
properties: {
|
|
50
|
+
service: { type: "string" },
|
|
51
|
+
effective_date: { type: "string" },
|
|
52
|
+
summary: { type: "array", items: { type: "string" } },
|
|
53
|
+
contact: { type: "string" }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
36
58
|
}
|
|
37
59
|
}
|
|
38
60
|
}
|
|
@@ -43,7 +65,24 @@ function buildOpenApiSpec() {
|
|
|
43
65
|
summary: "List active Canvas courses",
|
|
44
66
|
responses: {
|
|
45
67
|
"200": {
|
|
46
|
-
description: "Courses list"
|
|
68
|
+
description: "Courses list",
|
|
69
|
+
content: {
|
|
70
|
+
"application/json": {
|
|
71
|
+
schema: {
|
|
72
|
+
type: "array",
|
|
73
|
+
items: {
|
|
74
|
+
type: "object",
|
|
75
|
+
properties: {
|
|
76
|
+
id: { type: "integer" },
|
|
77
|
+
name: { type: "string" },
|
|
78
|
+
course_code: { type: "string" },
|
|
79
|
+
workflow_state: { type: "string" },
|
|
80
|
+
enrollment_term_id: { type: "integer" }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
47
86
|
}
|
|
48
87
|
}
|
|
49
88
|
}
|
|
@@ -86,7 +125,26 @@ function buildOpenApiSpec() {
|
|
|
86
125
|
],
|
|
87
126
|
responses: {
|
|
88
127
|
"200": {
|
|
89
|
-
description: "Assignments list"
|
|
128
|
+
description: "Assignments list",
|
|
129
|
+
content: {
|
|
130
|
+
"application/json": {
|
|
131
|
+
schema: {
|
|
132
|
+
type: "array",
|
|
133
|
+
items: {
|
|
134
|
+
type: "object",
|
|
135
|
+
properties: {
|
|
136
|
+
id: { type: "integer" },
|
|
137
|
+
name: { type: "string" },
|
|
138
|
+
due_at: { type: "string", nullable: true },
|
|
139
|
+
unlock_at: { type: "string", nullable: true },
|
|
140
|
+
lock_at: { type: "string", nullable: true },
|
|
141
|
+
points_possible: { type: "number", nullable: true },
|
|
142
|
+
published: { type: "boolean", nullable: true }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
90
148
|
}
|
|
91
149
|
}
|
|
92
150
|
}
|
|
@@ -527,7 +585,7 @@ function buildOpenApiSpec() {
|
|
|
527
585
|
}
|
|
528
586
|
};
|
|
529
587
|
}
|
|
530
|
-
export async function startHttpServer(client, host = "0.0.0.0", port = 3000) {
|
|
588
|
+
export async function startHttpServer(client, host = "0.0.0.0", port = 3000, tools = []) {
|
|
531
589
|
const app = Fastify({ logger: true });
|
|
532
590
|
await app.register(swagger, {
|
|
533
591
|
openapi: buildOpenApiSpec()
|
|
@@ -535,6 +593,11 @@ export async function startHttpServer(client, host = "0.0.0.0", port = 3000) {
|
|
|
535
593
|
await app.register(swaggerUi, {
|
|
536
594
|
routePrefix: "/docs"
|
|
537
595
|
});
|
|
596
|
+
app.setErrorHandler((error, _request, reply) => {
|
|
597
|
+
const status = error.statusCode ?? 500;
|
|
598
|
+
const message = status >= 500 ? "Internal server error" : error.message;
|
|
599
|
+
reply.code(status).send({ error: message });
|
|
600
|
+
});
|
|
538
601
|
app.get("/health", async () => ({ ok: true }));
|
|
539
602
|
app.get("/privacy", async () => ({
|
|
540
603
|
service: "Canvas MCP HTTP API",
|
|
@@ -756,5 +819,76 @@ export async function startHttpServer(client, host = "0.0.0.0", port = 3000) {
|
|
|
756
819
|
results
|
|
757
820
|
};
|
|
758
821
|
});
|
|
822
|
+
// --- POST /chat ---
|
|
823
|
+
const ollamaRunner = new AgentRunner(client, tools, process.env.OLLAMA_HOST ?? "http://localhost:11434");
|
|
824
|
+
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? "";
|
|
825
|
+
const geminiRunner = GEMINI_API_KEY
|
|
826
|
+
? new GeminiRunner(GEMINI_API_KEY, client, tools)
|
|
827
|
+
: null;
|
|
828
|
+
app.post("/chat", async (request, reply) => {
|
|
829
|
+
const { message, model, mode, provider = "ollama", gemini_key } = request.body ?? {};
|
|
830
|
+
if (!message || typeof message !== "string" || !message.trim()) {
|
|
831
|
+
return reply.code(400).send({ error: "El campo 'message' es requerido." });
|
|
832
|
+
}
|
|
833
|
+
if (tools.length === 0) {
|
|
834
|
+
return reply.code(503).send({ error: "El servidor no tiene herramientas cargadas. Inicia con 'serve-http'." });
|
|
835
|
+
}
|
|
836
|
+
if (provider === "gemini") {
|
|
837
|
+
const apiKey = gemini_key || GEMINI_API_KEY;
|
|
838
|
+
if (!apiKey) {
|
|
839
|
+
return reply.code(400).send({ error: "Se necesita gemini_key en el body o GEMINI_API_KEY en el entorno." });
|
|
840
|
+
}
|
|
841
|
+
const runner = gemini_key
|
|
842
|
+
? new GeminiRunner(gemini_key, client, tools)
|
|
843
|
+
: geminiRunner;
|
|
844
|
+
return runner.run(message.trim(), { model });
|
|
845
|
+
}
|
|
846
|
+
return ollamaRunner.run(message.trim(), { model, mode });
|
|
847
|
+
});
|
|
848
|
+
// --- POST /audit ---
|
|
849
|
+
const SKILL_PATH = process.env.AUDIT_SKILL_PATH ?? path.resolve(process.env.USERPROFILE ?? process.env.HOME ?? ".", "Proyectos Personales", "material-docente - UIDE", ".claude", "commands", "canvas-module-auditor-uide-v2.md");
|
|
850
|
+
app.post("/audit", async (request, reply) => {
|
|
851
|
+
const { course_id, week, course_name, provider = "gemini", model, gemini_key } = request.body ?? {};
|
|
852
|
+
if (!course_id || !week) {
|
|
853
|
+
return reply.code(400).send({ error: "course_id y week son requeridos." });
|
|
854
|
+
}
|
|
855
|
+
if (tools.length === 0) {
|
|
856
|
+
return reply.code(503).send({ error: "El servidor no tiene herramientas cargadas." });
|
|
857
|
+
}
|
|
858
|
+
// Leer el skill de auditoría
|
|
859
|
+
let skillContent;
|
|
860
|
+
try {
|
|
861
|
+
skillContent = await fs.readFile(SKILL_PATH, "utf-8");
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return reply.code(500).send({
|
|
865
|
+
error: `No se encontró el archivo de skill en: ${SKILL_PATH}. Define AUDIT_SKILL_PATH como variable de entorno.`
|
|
866
|
+
});
|
|
867
|
+
}
|
|
868
|
+
// Construir el mensaje de auditoría
|
|
869
|
+
const courseLabel = course_name ? `${course_name} (course_id: ${course_id})` : `course_id: ${course_id}`;
|
|
870
|
+
const auditMessage = `Ejecuta una auditoría instruccional completa de la Semana ${week} del curso ${courseLabel}. ` +
|
|
871
|
+
`Sigue exactamente el flujo de los pasos 1 al 4 definidos en las instrucciones. ` +
|
|
872
|
+
`Genera el reporte markdown completo con todas las secciones y la calificación global.`;
|
|
873
|
+
if (provider === "gemini") {
|
|
874
|
+
const apiKey = gemini_key || GEMINI_API_KEY;
|
|
875
|
+
if (!apiKey) {
|
|
876
|
+
return reply.code(400).send({ error: "Se necesita gemini_key en el body o GEMINI_API_KEY en el entorno." });
|
|
877
|
+
}
|
|
878
|
+
const runner = gemini_key
|
|
879
|
+
? new GeminiRunner(gemini_key, client, tools)
|
|
880
|
+
: geminiRunner;
|
|
881
|
+
const result = await runner.run(auditMessage, {
|
|
882
|
+
model: model ?? "gemini-2.5-flash",
|
|
883
|
+
systemPrompt: skillContent
|
|
884
|
+
});
|
|
885
|
+
return { ...result, course_id, week };
|
|
886
|
+
}
|
|
887
|
+
const result = await ollamaRunner.run(auditMessage, {
|
|
888
|
+
model,
|
|
889
|
+
systemPrompt: skillContent
|
|
890
|
+
});
|
|
891
|
+
return { ...result, course_id, week };
|
|
892
|
+
});
|
|
759
893
|
await app.listen({ host, port });
|
|
760
894
|
}
|
package/dist/index.js
CHANGED
|
@@ -23,6 +23,11 @@ import { configTools } from "./tools/config-tools.js";
|
|
|
23
23
|
import { rubricTools } from "./tools/rubric-tools.js";
|
|
24
24
|
import { calendarTools } from "./tools/calendar-tools.js";
|
|
25
25
|
import { groupTools } from "./tools/group-tools.js";
|
|
26
|
+
import { enrollmentTools } from "./tools/enrollment-tools.js";
|
|
27
|
+
import { conversationTools } from "./tools/conversation-tools.js";
|
|
28
|
+
import { newQuizTools } from "./tools/new-quiz-tools.js";
|
|
29
|
+
import { analyticsTools } from "./tools/analytics-tools.js";
|
|
30
|
+
import { peerReviewTools } from "./tools/peer-review-tools.js";
|
|
26
31
|
import { canvasResources } from "./resources/canvas-resources.js";
|
|
27
32
|
import { canvasPrompts } from "./prompts/canvas-prompts.js";
|
|
28
33
|
import { startHttpServer } from "./http-server.js";
|
|
@@ -33,7 +38,7 @@ const program = new Command();
|
|
|
33
38
|
program
|
|
34
39
|
.name("canvas-mcp")
|
|
35
40
|
.description("MCP Server for Canvas LMS (Refactored)")
|
|
36
|
-
.version("1.
|
|
41
|
+
.version("1.2.0");
|
|
37
42
|
// --- CLI Commands ---
|
|
38
43
|
program
|
|
39
44
|
.command("config")
|
|
@@ -82,7 +87,7 @@ program
|
|
|
82
87
|
const client = getClient();
|
|
83
88
|
const server = new Server({
|
|
84
89
|
name: "canvas-lms-server",
|
|
85
|
-
version: "1.
|
|
90
|
+
version: "1.2.0",
|
|
86
91
|
}, {
|
|
87
92
|
capabilities: {
|
|
88
93
|
tools: {},
|
|
@@ -105,7 +110,12 @@ program
|
|
|
105
110
|
...configTools,
|
|
106
111
|
...rubricTools,
|
|
107
112
|
...calendarTools,
|
|
108
|
-
...groupTools
|
|
113
|
+
...groupTools,
|
|
114
|
+
...enrollmentTools,
|
|
115
|
+
...conversationTools,
|
|
116
|
+
...newQuizTools,
|
|
117
|
+
...analyticsTools,
|
|
118
|
+
...peerReviewTools
|
|
109
119
|
];
|
|
110
120
|
// --- Tool Handlers ---
|
|
111
121
|
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
@@ -163,6 +173,27 @@ program
|
|
|
163
173
|
.action(async (options) => {
|
|
164
174
|
const client = getClient();
|
|
165
175
|
const port = Number.parseInt(options.port, 10);
|
|
166
|
-
|
|
176
|
+
const allTools = [
|
|
177
|
+
...courseTools,
|
|
178
|
+
...assignmentTools,
|
|
179
|
+
...quizTools,
|
|
180
|
+
...gradingTools,
|
|
181
|
+
...communicationTools,
|
|
182
|
+
...studentTools,
|
|
183
|
+
...quizQuestionTools,
|
|
184
|
+
...createTools,
|
|
185
|
+
...moduleTools,
|
|
186
|
+
...fileTools,
|
|
187
|
+
...configTools,
|
|
188
|
+
...rubricTools,
|
|
189
|
+
...calendarTools,
|
|
190
|
+
...groupTools,
|
|
191
|
+
...enrollmentTools,
|
|
192
|
+
...conversationTools,
|
|
193
|
+
...newQuizTools,
|
|
194
|
+
...analyticsTools,
|
|
195
|
+
...peerReviewTools
|
|
196
|
+
];
|
|
197
|
+
await startHttpServer(client, options.host, port, allTools);
|
|
167
198
|
});
|
|
168
199
|
program.parse(process.argv);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { Ollama } from "ollama";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// Detección de modo
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
async function detectMode(ollama, model, sampleTool) {
|
|
6
|
+
try {
|
|
7
|
+
await ollama.chat({
|
|
8
|
+
model,
|
|
9
|
+
messages: [{ role: "user", content: "ping" }],
|
|
10
|
+
tools: [sampleTool],
|
|
11
|
+
stream: false
|
|
12
|
+
});
|
|
13
|
+
return "native";
|
|
14
|
+
}
|
|
15
|
+
catch (err) {
|
|
16
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
17
|
+
if (msg.includes("does not support tools"))
|
|
18
|
+
return "prompt";
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Parseo de tool calls en texto plano (modo prompt)
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
function parseTextToolCalls(content) {
|
|
26
|
+
const results = [];
|
|
27
|
+
const tagPattern = /<tool_call>\s*([\s\S]*?)\s*<\/tool_call>/g;
|
|
28
|
+
let match;
|
|
29
|
+
while ((match = tagPattern.exec(content)) !== null) {
|
|
30
|
+
try {
|
|
31
|
+
const parsed = JSON.parse(match[1]);
|
|
32
|
+
if (parsed.name && parsed.arguments) {
|
|
33
|
+
results.push({ name: parsed.name, arguments: parsed.arguments });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch { /* ignorar */ }
|
|
37
|
+
}
|
|
38
|
+
if (results.length > 0)
|
|
39
|
+
return results;
|
|
40
|
+
// Fallback: JSON suelto con "name" + "arguments"
|
|
41
|
+
const jsonPattern = /\{[\s\S]*?"name"\s*:\s*"([^"]+)"[\s\S]*?"arguments"\s*:\s*(\{[\s\S]*?\})\s*\}/g;
|
|
42
|
+
while ((match = jsonPattern.exec(content)) !== null) {
|
|
43
|
+
try {
|
|
44
|
+
results.push({ name: match[1], arguments: JSON.parse(match[2]) });
|
|
45
|
+
}
|
|
46
|
+
catch { /* ignorar */ }
|
|
47
|
+
}
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// System prompt para modo prompt-engineering
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
const PROMPT_MODE_TOOLS = [
|
|
54
|
+
"canvas_list_courses",
|
|
55
|
+
"canvas_get_assignments",
|
|
56
|
+
"canvas_get_assignment",
|
|
57
|
+
"canvas_list_students",
|
|
58
|
+
"canvas_list_students_with_grades",
|
|
59
|
+
"canvas_get_student_grades",
|
|
60
|
+
"canvas_list_modules",
|
|
61
|
+
"canvas_list_quizzes",
|
|
62
|
+
"canvas_get_submissions",
|
|
63
|
+
"canvas_get_submission",
|
|
64
|
+
"canvas_grade_submission",
|
|
65
|
+
"canvas_list_announcements",
|
|
66
|
+
"canvas_list_discussions",
|
|
67
|
+
"canvas_audit_course",
|
|
68
|
+
"canvas_list_assignment_groups",
|
|
69
|
+
"canvas_list_assignment_due_dates",
|
|
70
|
+
"canvas_grade_multiple_submissions",
|
|
71
|
+
"canvas_get_submission_comments",
|
|
72
|
+
];
|
|
73
|
+
function buildPromptSystemPrompt(tools) {
|
|
74
|
+
const selected = tools
|
|
75
|
+
.filter(t => PROMPT_MODE_TOOLS.includes(t.name))
|
|
76
|
+
.map(t => ({
|
|
77
|
+
name: t.tool.name,
|
|
78
|
+
description: t.tool.description,
|
|
79
|
+
parameters: t.tool.inputSchema
|
|
80
|
+
}));
|
|
81
|
+
return `Eres un asistente educativo con acceso a Canvas LMS.
|
|
82
|
+
Responde SIEMPRE en español. Sé conciso y claro.
|
|
83
|
+
|
|
84
|
+
Herramientas disponibles:
|
|
85
|
+
${JSON.stringify(selected, null, 2)}
|
|
86
|
+
|
|
87
|
+
REGLAS:
|
|
88
|
+
1. Para consultar Canvas, responde SOLO con un bloque <tool_call>:
|
|
89
|
+
<tool_call>
|
|
90
|
+
{"name": "nombre_herramienta", "arguments": {"param": "valor"}}
|
|
91
|
+
</tool_call>
|
|
92
|
+
2. No escribas nada más cuando hagas una tool call.
|
|
93
|
+
3. Después de recibir el resultado, responde al usuario en español.
|
|
94
|
+
4. Una herramienta a la vez.`;
|
|
95
|
+
}
|
|
96
|
+
const NATIVE_SYSTEM = "Eres un asistente educativo con acceso a Canvas LMS. " +
|
|
97
|
+
"Usa las herramientas disponibles para responder sobre cursos, tareas, estudiantes y calificaciones. " +
|
|
98
|
+
"Responde SIEMPRE en español. Sé conciso y claro.";
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// AgentRunner
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
export class AgentRunner {
|
|
103
|
+
ollama;
|
|
104
|
+
tools;
|
|
105
|
+
client;
|
|
106
|
+
ollamaTools;
|
|
107
|
+
constructor(client, tools, ollamaHost = "http://localhost:11434") {
|
|
108
|
+
this.client = client;
|
|
109
|
+
this.tools = tools;
|
|
110
|
+
this.ollama = new Ollama({ host: ollamaHost });
|
|
111
|
+
this.ollamaTools = tools.map(t => ({
|
|
112
|
+
type: "function",
|
|
113
|
+
function: {
|
|
114
|
+
name: t.tool.name,
|
|
115
|
+
description: t.tool.description ?? "",
|
|
116
|
+
parameters: t.tool.inputSchema
|
|
117
|
+
}
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
async run(userMessage, options = {}) {
|
|
121
|
+
const modelRaw = options.model ?? "qwen2.5";
|
|
122
|
+
const modeArg = options.mode ?? "auto";
|
|
123
|
+
// Resolver nombre del modelo (fallback a :latest)
|
|
124
|
+
let model = modelRaw;
|
|
125
|
+
try {
|
|
126
|
+
await this.ollama.show({ model });
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
const withLatest = model.includes(":") ? model : `${model}:latest`;
|
|
130
|
+
try {
|
|
131
|
+
await this.ollama.show({ model: withLatest });
|
|
132
|
+
model = withLatest;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`Modelo '${modelRaw}' no encontrado en Ollama. Descárgalo con: ollama pull ${modelRaw}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// Detectar modo
|
|
139
|
+
let mode;
|
|
140
|
+
if (modeArg === "auto") {
|
|
141
|
+
mode = await detectMode(this.ollama, model, this.ollamaTools[0]);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
mode = modeArg;
|
|
145
|
+
}
|
|
146
|
+
const defaultSystem = mode === "prompt"
|
|
147
|
+
? buildPromptSystemPrompt(this.tools)
|
|
148
|
+
: NATIVE_SYSTEM;
|
|
149
|
+
const messages = [{
|
|
150
|
+
role: "system",
|
|
151
|
+
content: options.systemPrompt ?? defaultSystem
|
|
152
|
+
}];
|
|
153
|
+
messages.push({ role: "user", content: userMessage });
|
|
154
|
+
const toolsUsed = [];
|
|
155
|
+
// Agentic loop (máx. 10 iteraciones para evitar loops infinitos)
|
|
156
|
+
for (let i = 0; i < 10; i++) {
|
|
157
|
+
const response = await this.ollama.chat({
|
|
158
|
+
model,
|
|
159
|
+
messages,
|
|
160
|
+
...(mode === "native" ? { tools: this.ollamaTools } : {}),
|
|
161
|
+
stream: false
|
|
162
|
+
});
|
|
163
|
+
const assistantMessage = response.message;
|
|
164
|
+
messages.push(assistantMessage);
|
|
165
|
+
// Extraer tool calls
|
|
166
|
+
let calls = [];
|
|
167
|
+
if (mode === "native" && assistantMessage.tool_calls?.length) {
|
|
168
|
+
calls = assistantMessage.tool_calls.map(tc => ({
|
|
169
|
+
name: tc.function.name,
|
|
170
|
+
arguments: (tc.function.arguments ?? {})
|
|
171
|
+
}));
|
|
172
|
+
}
|
|
173
|
+
else if (mode === "prompt" && assistantMessage.content) {
|
|
174
|
+
calls = parseTextToolCalls(assistantMessage.content);
|
|
175
|
+
}
|
|
176
|
+
// Sin tool calls → respuesta final
|
|
177
|
+
if (calls.length === 0) {
|
|
178
|
+
const answer = (assistantMessage.content ?? "")
|
|
179
|
+
.replace(/<tool_call>[\s\S]*?<\/tool_call>/g, "")
|
|
180
|
+
.trim();
|
|
181
|
+
return { answer, tools_used: toolsUsed, model, mode };
|
|
182
|
+
}
|
|
183
|
+
// Ejecutar cada herramienta
|
|
184
|
+
for (const call of calls) {
|
|
185
|
+
const toolDef = this.tools.find(t => t.name === call.name);
|
|
186
|
+
let resultText;
|
|
187
|
+
if (!toolDef) {
|
|
188
|
+
resultText = `Error: herramienta '${call.name}' no existe.`;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
try {
|
|
192
|
+
const result = await toolDef.handler(this.client, call.arguments);
|
|
193
|
+
const blocks = result?.content ?? [];
|
|
194
|
+
resultText = blocks
|
|
195
|
+
.filter((b) => b.type === "text" && b.text)
|
|
196
|
+
.map((b) => b.text)
|
|
197
|
+
.join("\n");
|
|
198
|
+
toolsUsed.push(call.name);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
resultText = `Error: ${err instanceof Error ? err.message : String(err)}`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
messages.push({
|
|
205
|
+
role: mode === "native" ? "tool" : "user",
|
|
206
|
+
content: mode === "native"
|
|
207
|
+
? `[${call.name}]\n${resultText}`
|
|
208
|
+
: `Resultado de ${call.name}:\n${resultText}`
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
throw new Error("El agente excedió el límite de iteraciones (10).");
|
|
213
|
+
}
|
|
214
|
+
}
|