canvas-lms-mcp 1.0.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/build/canvas-client.js +188 -0
- package/build/index.js +37 -0
- package/build/tools/download-materials.js +34 -0
- package/build/tools/get-announcements.js +38 -0
- package/build/tools/get-assignments.js +57 -0
- package/build/tools/get-course-materials.js +147 -0
- package/build/tools/get-deadlines.js +44 -0
- package/build/tools/get-grades.js +31 -0
- package/build/tools/list-courses.js +13 -0
- package/build/tools/read-material.js +51 -0
- package/package.json +36 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
const DEFAULT_CANVAS_URL = "https://learning.unisg.ch";
|
|
2
|
+
const CANVAS_URL = process.env.CANVAS_URL || DEFAULT_CANVAS_URL;
|
|
3
|
+
const CANVAS_TOKEN = process.env.CANVAS_TOKEN;
|
|
4
|
+
export function getFileContentType(file) {
|
|
5
|
+
return file["content-type"] || file.content_type || "application/octet-stream";
|
|
6
|
+
}
|
|
7
|
+
function isCanvasError(data) {
|
|
8
|
+
if (typeof data !== "object" || data === null)
|
|
9
|
+
return false;
|
|
10
|
+
return "errors" in data || ("message" in data && !Array.isArray(data));
|
|
11
|
+
}
|
|
12
|
+
function formatError(data) {
|
|
13
|
+
if (data.errors) {
|
|
14
|
+
if (Array.isArray(data.errors)) {
|
|
15
|
+
return data.errors.map((e) => e.message).join(", ");
|
|
16
|
+
}
|
|
17
|
+
return Object.entries(data.errors)
|
|
18
|
+
.map(([k, v]) => `${k}: ${v.map((e) => e.message).join(", ")}`)
|
|
19
|
+
.join("; ");
|
|
20
|
+
}
|
|
21
|
+
return data.message || "Unknown Canvas API error";
|
|
22
|
+
}
|
|
23
|
+
async function canvasCall(path, params = {}) {
|
|
24
|
+
const url = new URL(`/api/v1/${path}`, CANVAS_URL);
|
|
25
|
+
for (const [key, value] of Object.entries(params)) {
|
|
26
|
+
url.searchParams.set(key, value);
|
|
27
|
+
}
|
|
28
|
+
const response = await fetch(url.toString(), {
|
|
29
|
+
headers: { Authorization: `Bearer ${CANVAS_TOKEN}` },
|
|
30
|
+
});
|
|
31
|
+
if (!response.ok) {
|
|
32
|
+
const text = await response.text();
|
|
33
|
+
try {
|
|
34
|
+
const data = JSON.parse(text);
|
|
35
|
+
if (isCanvasError(data)) {
|
|
36
|
+
throw new Error(`Canvas API error (${response.status}): ${formatError(data)}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
if (e instanceof Error && e.message.startsWith("Canvas API"))
|
|
41
|
+
throw e;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Canvas API HTTP error: ${response.status} ${response.statusText}`);
|
|
44
|
+
}
|
|
45
|
+
const data = await response.json();
|
|
46
|
+
if (isCanvasError(data)) {
|
|
47
|
+
throw new Error(`Canvas API error: ${formatError(data)}`);
|
|
48
|
+
}
|
|
49
|
+
return data;
|
|
50
|
+
}
|
|
51
|
+
async function canvasPaginatedCall(path, params = {}) {
|
|
52
|
+
const allResults = [];
|
|
53
|
+
const url = new URL(`/api/v1/${path}`, CANVAS_URL);
|
|
54
|
+
url.searchParams.set("per_page", "100");
|
|
55
|
+
for (const [key, value] of Object.entries(params)) {
|
|
56
|
+
url.searchParams.set(key, value);
|
|
57
|
+
}
|
|
58
|
+
let nextUrl = url.toString();
|
|
59
|
+
while (nextUrl) {
|
|
60
|
+
const response = await fetch(nextUrl, {
|
|
61
|
+
headers: { Authorization: `Bearer ${CANVAS_TOKEN}` },
|
|
62
|
+
});
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const text = await response.text();
|
|
65
|
+
try {
|
|
66
|
+
const data = JSON.parse(text);
|
|
67
|
+
if (isCanvasError(data)) {
|
|
68
|
+
throw new Error(`Canvas API error (${response.status}): ${formatError(data)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
catch (e) {
|
|
72
|
+
if (e instanceof Error && e.message.startsWith("Canvas API"))
|
|
73
|
+
throw e;
|
|
74
|
+
}
|
|
75
|
+
throw new Error(`Canvas API HTTP error: ${response.status} ${response.statusText}`);
|
|
76
|
+
}
|
|
77
|
+
const data = await response.json();
|
|
78
|
+
if (Array.isArray(data)) {
|
|
79
|
+
allResults.push(...data);
|
|
80
|
+
}
|
|
81
|
+
// Parse Link header for pagination
|
|
82
|
+
const linkHeader = response.headers.get("link");
|
|
83
|
+
nextUrl = null;
|
|
84
|
+
if (linkHeader) {
|
|
85
|
+
const nextMatch = linkHeader.match(/<([^>]+)>;\s*rel="next"/);
|
|
86
|
+
if (nextMatch) {
|
|
87
|
+
nextUrl = nextMatch[1];
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return allResults;
|
|
92
|
+
}
|
|
93
|
+
export async function getUserCourses() {
|
|
94
|
+
return canvasPaginatedCall("courses", {
|
|
95
|
+
enrollment_state: "active",
|
|
96
|
+
"include[]": "term",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
export async function getCourseModules(courseId) {
|
|
100
|
+
return canvasPaginatedCall(`courses/${courseId}/modules`, {
|
|
101
|
+
"include[]": "items",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
export async function getModuleItemFile(courseId, fileId) {
|
|
105
|
+
return canvasCall(`courses/${courseId}/files/${fileId}`);
|
|
106
|
+
}
|
|
107
|
+
export async function getCourseAssignments(courseId, params = {}) {
|
|
108
|
+
return canvasPaginatedCall(`courses/${courseId}/assignments`, {
|
|
109
|
+
order_by: "due_at",
|
|
110
|
+
...params,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
export async function getAnnouncements(courseId, params = {}) {
|
|
114
|
+
return canvasPaginatedCall("announcements", {
|
|
115
|
+
"context_codes[]": `course_${courseId}`,
|
|
116
|
+
...params,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
export async function getCourseFolders(courseId) {
|
|
120
|
+
return canvasPaginatedCall(`courses/${courseId}/folders`);
|
|
121
|
+
}
|
|
122
|
+
export async function getFolderFiles(folderId) {
|
|
123
|
+
return canvasPaginatedCall(`folders/${folderId}/files`);
|
|
124
|
+
}
|
|
125
|
+
export async function getCourseFiles(courseId, params = {}) {
|
|
126
|
+
return canvasPaginatedCall(`courses/${courseId}/files`, params);
|
|
127
|
+
}
|
|
128
|
+
export async function getPageByUrl(pageApiUrl) {
|
|
129
|
+
const response = await fetch(pageApiUrl, {
|
|
130
|
+
headers: { Authorization: `Bearer ${CANVAS_TOKEN}` },
|
|
131
|
+
});
|
|
132
|
+
if (!response.ok) {
|
|
133
|
+
throw new Error(`Failed to fetch page: ${response.status}`);
|
|
134
|
+
}
|
|
135
|
+
return response.json();
|
|
136
|
+
}
|
|
137
|
+
export function extractFileIdsFromHtml(html) {
|
|
138
|
+
const fileIds = [];
|
|
139
|
+
// Match data-api-endpoint URLs like /api/v1/courses/123/files/456
|
|
140
|
+
const pattern = /\/api\/v1\/courses\/\d+\/files\/(\d+)/g;
|
|
141
|
+
let match;
|
|
142
|
+
while ((match = pattern.exec(html)) !== null) {
|
|
143
|
+
fileIds.push(Number(match[1]));
|
|
144
|
+
}
|
|
145
|
+
// Also match direct file links like /courses/123/files/456
|
|
146
|
+
const directPattern = /\/courses\/\d+\/files\/(\d+)/g;
|
|
147
|
+
while ((match = directPattern.exec(html)) !== null) {
|
|
148
|
+
const id = Number(match[1]);
|
|
149
|
+
if (!fileIds.includes(id))
|
|
150
|
+
fileIds.push(id);
|
|
151
|
+
}
|
|
152
|
+
return fileIds;
|
|
153
|
+
}
|
|
154
|
+
export async function downloadFile(fileurl) {
|
|
155
|
+
const response = await fetch(fileurl, {
|
|
156
|
+
headers: { Authorization: `Bearer ${CANVAS_TOKEN}` },
|
|
157
|
+
});
|
|
158
|
+
if (!response.ok) {
|
|
159
|
+
throw new Error(`Failed to download file: ${response.status} ${response.statusText}`);
|
|
160
|
+
}
|
|
161
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
162
|
+
return {
|
|
163
|
+
data: Buffer.from(arrayBuffer),
|
|
164
|
+
contentType: response.headers.get("content-type") || "application/octet-stream",
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
export function findCourse(courses, search) {
|
|
168
|
+
// Try numeric ID first
|
|
169
|
+
const asId = Number(search);
|
|
170
|
+
if (!isNaN(asId)) {
|
|
171
|
+
const byId = courses.find((c) => c.id === asId);
|
|
172
|
+
if (byId)
|
|
173
|
+
return { course: byId };
|
|
174
|
+
return { error: `No course found with ID ${asId}. Use list_courses to see available courses.` };
|
|
175
|
+
}
|
|
176
|
+
const lower = search.toLowerCase();
|
|
177
|
+
const matches = courses.filter((c) => c.name.toLowerCase().includes(lower) ||
|
|
178
|
+
c.course_code.toLowerCase().includes(lower));
|
|
179
|
+
if (matches.length === 0) {
|
|
180
|
+
const available = courses.map((c) => `${c.name} (id: ${c.id})`).join("\n - ");
|
|
181
|
+
return { error: `No course found matching "${search}". Available courses:\n - ${available}` };
|
|
182
|
+
}
|
|
183
|
+
if (matches.length > 1) {
|
|
184
|
+
const names = matches.map((c) => `${c.name} (id: ${c.id})`).join("\n - ");
|
|
185
|
+
return { error: `Multiple courses match "${search}":\n - ${names}\n\nPlease be more specific.` };
|
|
186
|
+
}
|
|
187
|
+
return { course: matches[0] };
|
|
188
|
+
}
|
package/build/index.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { listCoursesSchema, listCourses } from "./tools/list-courses.js";
|
|
5
|
+
import { getCourseMaterialsSchema, getCourseMaterials } from "./tools/get-course-materials.js";
|
|
6
|
+
import { downloadMaterialsSchema, downloadMaterials } from "./tools/download-materials.js";
|
|
7
|
+
import { getAssignmentsSchema, getAssignments } from "./tools/get-assignments.js";
|
|
8
|
+
import { getAnnouncementsSchema, getAnnouncementsHandler } from "./tools/get-announcements.js";
|
|
9
|
+
import { getDeadlinesSchema, getDeadlines } from "./tools/get-deadlines.js";
|
|
10
|
+
import { getGradesSchema, getGrades } from "./tools/get-grades.js";
|
|
11
|
+
import { readMaterialSchema, readMaterial } from "./tools/read-material.js";
|
|
12
|
+
if (!process.env.CANVAS_TOKEN) {
|
|
13
|
+
console.error(`Error: CANVAS_TOKEN environment variable is required.
|
|
14
|
+
|
|
15
|
+
Get your token:
|
|
16
|
+
1. Log in to Canvas (${process.env.CANVAS_URL || "https://learning.unisg.ch"})
|
|
17
|
+
2. Go to Account > Settings
|
|
18
|
+
3. Scroll to "Approved Integrations" and click "+ New Access Token"
|
|
19
|
+
4. Copy the token
|
|
20
|
+
5. Set it: CANVAS_TOKEN=<token> npx canvas-mcp`);
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
23
|
+
const server = new McpServer({
|
|
24
|
+
name: "canvas-lms",
|
|
25
|
+
version: "1.0.0",
|
|
26
|
+
});
|
|
27
|
+
server.tool("list_courses", "List all Canvas courses the student is currently enrolled in.", listCoursesSchema.shape, async () => listCourses());
|
|
28
|
+
server.tool("get_course_materials", "Get files from Canvas course modules, optionally filtered by recency and file type.", getCourseMaterialsSchema.shape, async (args) => getCourseMaterials(getCourseMaterialsSchema.parse(args)));
|
|
29
|
+
server.tool("download_materials", "Download specific files from Canvas to local disk. Returns local file paths.", downloadMaterialsSchema.shape, async (args) => downloadMaterials(downloadMaterialsSchema.parse(args)));
|
|
30
|
+
server.tool("get_assignments", "List assignments for a course with due dates, descriptions, and attachments.", getAssignmentsSchema.shape, async (args) => getAssignments(getAssignmentsSchema.parse(args)));
|
|
31
|
+
server.tool("get_announcements", "Get recent announcements for a course with attachments.", getAnnouncementsSchema.shape, async (args) => getAnnouncementsHandler(getAnnouncementsSchema.parse(args)));
|
|
32
|
+
server.tool("get_deadlines", "Get upcoming assignment deadlines across all enrolled courses, sorted by due date.", getDeadlinesSchema.shape, async (args) => getDeadlines(getDeadlinesSchema.parse(args)));
|
|
33
|
+
server.tool("get_grades", "View assignment grades and scores for a course.", getGradesSchema.shape, async (args) => getGrades(getGradesSchema.parse(args)));
|
|
34
|
+
server.tool("read_material", "Read and extract text content from a Canvas file (PDF or text). Returns the full text so you can summarize, answer questions, or help the student study. Use the file URL from get_course_materials output.", readMaterialSchema.shape, async (args) => readMaterial(readMaterialSchema.parse(args)));
|
|
35
|
+
const transport = new StdioServerTransport();
|
|
36
|
+
await server.connect(transport);
|
|
37
|
+
console.error("Canvas LMS MCP server running on stdio");
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { downloadFile } from "../canvas-client.js";
|
|
5
|
+
const DEFAULT_DOWNLOAD_DIR = process.env.DOWNLOAD_DIR || "./canvas-downloads";
|
|
6
|
+
export const downloadMaterialsSchema = z.object({
|
|
7
|
+
fileurls: z.array(z.string()).describe("Array of Canvas file URLs from get_course_materials output"),
|
|
8
|
+
output_dir: z
|
|
9
|
+
.string()
|
|
10
|
+
.optional()
|
|
11
|
+
.default(DEFAULT_DOWNLOAD_DIR)
|
|
12
|
+
.describe(`Output directory (default: ${DEFAULT_DOWNLOAD_DIR})`),
|
|
13
|
+
});
|
|
14
|
+
export async function downloadMaterials(args) {
|
|
15
|
+
const outputDir = args.output_dir;
|
|
16
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
17
|
+
const results = [];
|
|
18
|
+
for (const fileurl of args.fileurls) {
|
|
19
|
+
const urlPath = new URL(fileurl).pathname;
|
|
20
|
+
const filename = decodeURIComponent(urlPath.split("/").pop() || "unknown");
|
|
21
|
+
const localPath = path.join(outputDir, filename);
|
|
22
|
+
const { data } = await downloadFile(fileurl);
|
|
23
|
+
if (fs.existsSync(localPath)) {
|
|
24
|
+
const stat = fs.statSync(localPath);
|
|
25
|
+
if (stat.size === data.length) {
|
|
26
|
+
results.push({ filename, local_path: path.resolve(localPath), size_bytes: data.length });
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
fs.writeFileSync(localPath, data);
|
|
31
|
+
results.push({ filename, local_path: path.resolve(localPath), size_bytes: data.length });
|
|
32
|
+
}
|
|
33
|
+
return { content: [{ type: "text", text: JSON.stringify(results, null, 2) }] };
|
|
34
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getUserCourses, getAnnouncements, findCourse } from "../canvas-client.js";
|
|
3
|
+
export const getAnnouncementsSchema = z.object({
|
|
4
|
+
course_name: z.string().describe("Course name (fuzzy match) or numeric course ID"),
|
|
5
|
+
since_days_ago: z
|
|
6
|
+
.number()
|
|
7
|
+
.optional()
|
|
8
|
+
.default(14)
|
|
9
|
+
.describe("Only return announcements from the last N days (default 14)"),
|
|
10
|
+
});
|
|
11
|
+
export async function getAnnouncementsHandler(args) {
|
|
12
|
+
const courses = await getUserCourses();
|
|
13
|
+
const { course, error } = findCourse(courses, args.course_name);
|
|
14
|
+
if (error) {
|
|
15
|
+
return { content: [{ type: "text", text: error }] };
|
|
16
|
+
}
|
|
17
|
+
const startDate = new Date(Date.now() - args.since_days_ago * 86400 * 1000).toISOString();
|
|
18
|
+
const announcements = await getAnnouncements(course.id, { start_date: startDate });
|
|
19
|
+
const result = {
|
|
20
|
+
course: course.name,
|
|
21
|
+
course_id: course.id,
|
|
22
|
+
total_announcements: announcements.length,
|
|
23
|
+
announcements: announcements.map((a) => ({
|
|
24
|
+
title: a.title,
|
|
25
|
+
posted_at: a.posted_at,
|
|
26
|
+
author: a.author.display_name,
|
|
27
|
+
message_snippet: a.message
|
|
28
|
+
? a.message.replace(/<[^>]*>/g, "").slice(0, 300)
|
|
29
|
+
: null,
|
|
30
|
+
attachments: a.attachments?.map((att) => ({
|
|
31
|
+
filename: att.display_name,
|
|
32
|
+
url: att.url,
|
|
33
|
+
size: att.size,
|
|
34
|
+
})) || [],
|
|
35
|
+
})),
|
|
36
|
+
};
|
|
37
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
38
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getUserCourses, getCourseAssignments, findCourse } from "../canvas-client.js";
|
|
3
|
+
export const getAssignmentsSchema = z.object({
|
|
4
|
+
course_name: z.string().describe("Course name (fuzzy match) or numeric course ID"),
|
|
5
|
+
since_days_ago: z
|
|
6
|
+
.number()
|
|
7
|
+
.optional()
|
|
8
|
+
.default(30)
|
|
9
|
+
.describe("Only return assignments due within this many days from now (default 30)"),
|
|
10
|
+
include_past: z
|
|
11
|
+
.boolean()
|
|
12
|
+
.optional()
|
|
13
|
+
.default(false)
|
|
14
|
+
.describe("Include assignments with past due dates (default false)"),
|
|
15
|
+
});
|
|
16
|
+
export async function getAssignments(args) {
|
|
17
|
+
const courses = await getUserCourses();
|
|
18
|
+
const { course, error } = findCourse(courses, args.course_name);
|
|
19
|
+
if (error) {
|
|
20
|
+
return { content: [{ type: "text", text: error }] };
|
|
21
|
+
}
|
|
22
|
+
const assignments = await getCourseAssignments(course.id);
|
|
23
|
+
const now = new Date();
|
|
24
|
+
const futureLimit = new Date(now.getTime() + args.since_days_ago * 86400 * 1000);
|
|
25
|
+
const filtered = assignments.filter((a) => {
|
|
26
|
+
if (!a.due_at)
|
|
27
|
+
return true; // No due date — always include
|
|
28
|
+
const due = new Date(a.due_at);
|
|
29
|
+
if (!args.include_past && due < now)
|
|
30
|
+
return false;
|
|
31
|
+
if (due > futureLimit)
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
const result = {
|
|
36
|
+
course: course.name,
|
|
37
|
+
course_id: course.id,
|
|
38
|
+
total_assignments: filtered.length,
|
|
39
|
+
assignments: filtered.map((a) => ({
|
|
40
|
+
name: a.name,
|
|
41
|
+
due_at: a.due_at,
|
|
42
|
+
points_possible: a.points_possible,
|
|
43
|
+
submission_types: a.submission_types,
|
|
44
|
+
description_snippet: a.description
|
|
45
|
+
? a.description.replace(/<[^>]*>/g, "").slice(0, 200)
|
|
46
|
+
: null,
|
|
47
|
+
url: a.html_url,
|
|
48
|
+
attachments: a.attachments?.map((att) => ({
|
|
49
|
+
filename: att.display_name,
|
|
50
|
+
url: att.url,
|
|
51
|
+
size: att.size,
|
|
52
|
+
content_type: att.content_type,
|
|
53
|
+
})) || [],
|
|
54
|
+
})),
|
|
55
|
+
};
|
|
56
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
57
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getUserCourses, getCourseModules, getModuleItemFile, getCourseFolders, getFolderFiles, getPageByUrl, extractFileIdsFromHtml, findCourse, getFileContentType } from "../canvas-client.js";
|
|
3
|
+
export const getCourseMaterialsSchema = z.object({
|
|
4
|
+
course_name: z.string().describe("Course name (fuzzy match) or numeric course ID"),
|
|
5
|
+
since_days_ago: z
|
|
6
|
+
.number()
|
|
7
|
+
.optional()
|
|
8
|
+
.default(7)
|
|
9
|
+
.describe("Only return files modified within this many days (default 7)"),
|
|
10
|
+
file_type: z
|
|
11
|
+
.string()
|
|
12
|
+
.optional()
|
|
13
|
+
.default("application/pdf")
|
|
14
|
+
.describe("MIME content-type filter (default application/pdf)"),
|
|
15
|
+
});
|
|
16
|
+
export async function getCourseMaterials(args) {
|
|
17
|
+
const courses = await getUserCourses();
|
|
18
|
+
const { course, error } = findCourse(courses, args.course_name);
|
|
19
|
+
if (error) {
|
|
20
|
+
return { content: [{ type: "text", text: error }] };
|
|
21
|
+
}
|
|
22
|
+
const cutoff = new Date(Date.now() - args.since_days_ago * 86400 * 1000).toISOString();
|
|
23
|
+
const materials = [];
|
|
24
|
+
const pages = [];
|
|
25
|
+
const seenFileIds = new Set();
|
|
26
|
+
// 1. Search module items (Files + Pages with embedded files)
|
|
27
|
+
try {
|
|
28
|
+
const modules = await getCourseModules(course.id);
|
|
29
|
+
for (const mod of modules) {
|
|
30
|
+
if (!mod.items)
|
|
31
|
+
continue;
|
|
32
|
+
for (const item of mod.items) {
|
|
33
|
+
// Direct file items
|
|
34
|
+
if (item.type === "File" && item.content_id) {
|
|
35
|
+
try {
|
|
36
|
+
const file = await getModuleItemFile(course.id, item.content_id);
|
|
37
|
+
if (getFileContentType(file) !== args.file_type)
|
|
38
|
+
continue;
|
|
39
|
+
if (file.updated_at < cutoff)
|
|
40
|
+
continue;
|
|
41
|
+
seenFileIds.add(file.id);
|
|
42
|
+
materials.push({
|
|
43
|
+
filename: file.display_name,
|
|
44
|
+
url: file.url,
|
|
45
|
+
filesize: file.size,
|
|
46
|
+
updated_at: file.updated_at,
|
|
47
|
+
location: `Module: ${mod.name}`,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Skip files we can't access
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// Page items — extract embedded file links + record page info
|
|
55
|
+
if (item.type === "Page" && item.url) {
|
|
56
|
+
try {
|
|
57
|
+
const page = await getPageByUrl(item.url);
|
|
58
|
+
if (!page.body)
|
|
59
|
+
continue;
|
|
60
|
+
const fileIds = extractFileIdsFromHtml(page.body);
|
|
61
|
+
const textContent = page.body.replace(/<[^>]*>/g, " ").replace(/ /g, " ").replace(/&/g, "&").replace(/\s+/g, " ").trim();
|
|
62
|
+
// Record page info regardless of whether it has files
|
|
63
|
+
pages.push({
|
|
64
|
+
title: page.title,
|
|
65
|
+
module_name: mod.name,
|
|
66
|
+
updated_at: page.updated_at,
|
|
67
|
+
content_snippet: textContent.slice(0, 300),
|
|
68
|
+
has_files: fileIds.length > 0,
|
|
69
|
+
html_url: item.url.replace("/api/v1/courses/", "https://learning.unisg.ch/courses/").replace(/\/pages\//, "/pages/"),
|
|
70
|
+
});
|
|
71
|
+
for (const fileId of fileIds) {
|
|
72
|
+
if (seenFileIds.has(fileId))
|
|
73
|
+
continue;
|
|
74
|
+
try {
|
|
75
|
+
const file = await getModuleItemFile(course.id, fileId);
|
|
76
|
+
if (getFileContentType(file) !== args.file_type)
|
|
77
|
+
continue;
|
|
78
|
+
if (file.updated_at < cutoff)
|
|
79
|
+
continue;
|
|
80
|
+
seenFileIds.add(file.id);
|
|
81
|
+
materials.push({
|
|
82
|
+
filename: file.display_name,
|
|
83
|
+
url: file.url,
|
|
84
|
+
filesize: file.size,
|
|
85
|
+
updated_at: file.updated_at,
|
|
86
|
+
location: `Page: ${page.title} (${mod.name})`,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// Skip files we can't access
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Skip pages we can't access
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Modules may not be enabled for this course
|
|
103
|
+
}
|
|
104
|
+
// 2. Search course file tree (folders)
|
|
105
|
+
try {
|
|
106
|
+
const folders = await getCourseFolders(course.id);
|
|
107
|
+
for (const folder of folders) {
|
|
108
|
+
if (folder.files_count === 0)
|
|
109
|
+
continue;
|
|
110
|
+
try {
|
|
111
|
+
const files = await getFolderFiles(folder.id);
|
|
112
|
+
for (const file of files) {
|
|
113
|
+
if (seenFileIds.has(file.id))
|
|
114
|
+
continue;
|
|
115
|
+
if (getFileContentType(file) !== args.file_type)
|
|
116
|
+
continue;
|
|
117
|
+
if (file.updated_at < cutoff)
|
|
118
|
+
continue;
|
|
119
|
+
seenFileIds.add(file.id);
|
|
120
|
+
materials.push({
|
|
121
|
+
filename: file.display_name,
|
|
122
|
+
url: file.url,
|
|
123
|
+
filesize: file.size,
|
|
124
|
+
updated_at: file.updated_at,
|
|
125
|
+
location: `Files: ${folder.full_name}`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// Skip folders we can't access
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Files section may not be accessible
|
|
136
|
+
}
|
|
137
|
+
const result = {
|
|
138
|
+
course: course.name,
|
|
139
|
+
course_id: course.id,
|
|
140
|
+
filter: { since_days_ago: args.since_days_ago, file_type: args.file_type },
|
|
141
|
+
total_files: materials.length,
|
|
142
|
+
total_pages: pages.length,
|
|
143
|
+
materials,
|
|
144
|
+
pages,
|
|
145
|
+
};
|
|
146
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
147
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getUserCourses, getCourseAssignments } from "../canvas-client.js";
|
|
3
|
+
export const getDeadlinesSchema = z.object({
|
|
4
|
+
days_ahead: z
|
|
5
|
+
.number()
|
|
6
|
+
.optional()
|
|
7
|
+
.default(14)
|
|
8
|
+
.describe("Show deadlines within the next N days (default 14)"),
|
|
9
|
+
});
|
|
10
|
+
export async function getDeadlines(args) {
|
|
11
|
+
const courses = await getUserCourses();
|
|
12
|
+
const now = new Date();
|
|
13
|
+
const limit = new Date(now.getTime() + args.days_ahead * 86400 * 1000);
|
|
14
|
+
const allDeadlines = [];
|
|
15
|
+
for (const course of courses) {
|
|
16
|
+
try {
|
|
17
|
+
const assignments = await getCourseAssignments(course.id);
|
|
18
|
+
for (const a of assignments) {
|
|
19
|
+
if (!a.due_at)
|
|
20
|
+
continue;
|
|
21
|
+
const due = new Date(a.due_at);
|
|
22
|
+
if (due < now || due > limit)
|
|
23
|
+
continue;
|
|
24
|
+
allDeadlines.push({
|
|
25
|
+
course_name: course.name,
|
|
26
|
+
assignment_name: a.name,
|
|
27
|
+
due_at: a.due_at,
|
|
28
|
+
points_possible: a.points_possible,
|
|
29
|
+
url: a.html_url,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Skip courses we can't access assignments for
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
allDeadlines.sort((a, b) => new Date(a.due_at).getTime() - new Date(b.due_at).getTime());
|
|
38
|
+
const result = {
|
|
39
|
+
days_ahead: args.days_ahead,
|
|
40
|
+
total_deadlines: allDeadlines.length,
|
|
41
|
+
deadlines: allDeadlines,
|
|
42
|
+
};
|
|
43
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
44
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getUserCourses, getCourseAssignments, findCourse } from "../canvas-client.js";
|
|
3
|
+
export const getGradesSchema = z.object({
|
|
4
|
+
course_name: z.string().describe("Fuzzy match against course name/code"),
|
|
5
|
+
});
|
|
6
|
+
export async function getGrades(args) {
|
|
7
|
+
const courses = await getUserCourses();
|
|
8
|
+
const { course, error } = findCourse(courses, args.course_name);
|
|
9
|
+
if (error) {
|
|
10
|
+
return { content: [{ type: "text", text: error }] };
|
|
11
|
+
}
|
|
12
|
+
const assignments = await getCourseAssignments(course.id, {
|
|
13
|
+
"include[]": "submission",
|
|
14
|
+
});
|
|
15
|
+
const graded = assignments
|
|
16
|
+
.filter((a) => a.submission && a.submission.workflow_state !== "unsubmitted")
|
|
17
|
+
.map((a) => ({
|
|
18
|
+
assignment_name: a.name,
|
|
19
|
+
score: a.submission.score,
|
|
20
|
+
points_possible: a.points_possible,
|
|
21
|
+
grade: a.submission.grade,
|
|
22
|
+
submitted_at: a.submission.submitted_at,
|
|
23
|
+
}));
|
|
24
|
+
const result = {
|
|
25
|
+
course: course.name,
|
|
26
|
+
course_id: course.id,
|
|
27
|
+
total_graded: graded.length,
|
|
28
|
+
grades: graded,
|
|
29
|
+
};
|
|
30
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
31
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getUserCourses } from "../canvas-client.js";
|
|
3
|
+
export const listCoursesSchema = z.object({});
|
|
4
|
+
export async function listCourses() {
|
|
5
|
+
const courses = await getUserCourses();
|
|
6
|
+
const result = courses.map((c) => ({
|
|
7
|
+
id: c.id,
|
|
8
|
+
name: c.name,
|
|
9
|
+
course_code: c.course_code,
|
|
10
|
+
term: c.term?.name || null,
|
|
11
|
+
}));
|
|
12
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
13
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { downloadFile } from "../canvas-client.js";
|
|
3
|
+
import { PDFParse } from "pdf-parse";
|
|
4
|
+
export const readMaterialSchema = z.object({
|
|
5
|
+
file_url: z.string().describe("Canvas file download URL (from get_course_materials output)"),
|
|
6
|
+
});
|
|
7
|
+
export async function readMaterial(args) {
|
|
8
|
+
const { data, contentType } = await downloadFile(args.file_url);
|
|
9
|
+
if (contentType.includes("pdf")) {
|
|
10
|
+
try {
|
|
11
|
+
const parser = new PDFParse({ data });
|
|
12
|
+
const result = await parser.getText();
|
|
13
|
+
await parser.destroy();
|
|
14
|
+
return {
|
|
15
|
+
content: [
|
|
16
|
+
{
|
|
17
|
+
type: "text",
|
|
18
|
+
text: JSON.stringify({
|
|
19
|
+
pages: result.total,
|
|
20
|
+
text: result.text,
|
|
21
|
+
}),
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
catch (e) {
|
|
27
|
+
return {
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: "text",
|
|
31
|
+
text: `Failed to parse PDF: ${e instanceof Error ? e.message : String(e)}`,
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// For non-PDF text files, return as-is
|
|
38
|
+
if (contentType.includes("text") || contentType.includes("json") || contentType.includes("xml")) {
|
|
39
|
+
return {
|
|
40
|
+
content: [{ type: "text", text: data.toString("utf-8") }],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text",
|
|
47
|
+
text: `Unsupported content type: ${contentType}. This tool supports PDF and text files.`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "canvas-lms-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "MCP server to access Canvas LMS course materials, assignments, announcements, deadlines, and grades",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"canvas-lms-mcp": "./build/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc && chmod 755 build/index.js",
|
|
11
|
+
"prepare": "npm run build"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"build"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=18.0.0"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"mcp",
|
|
21
|
+
"canvas",
|
|
22
|
+
"lms",
|
|
23
|
+
"unisg",
|
|
24
|
+
"lectures"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@modelcontextprotocol/sdk": "latest",
|
|
29
|
+
"pdf-parse": "^2.4.5",
|
|
30
|
+
"zod": "^3.23.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^20.0.0",
|
|
34
|
+
"typescript": "^5.5.0"
|
|
35
|
+
}
|
|
36
|
+
}
|