canvas-agent 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/README.md +41 -0
- package/dist/canvas-client.d.ts +24 -0
- package/dist/canvas-client.js +90 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +10 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +41 -0
- package/dist/setup.d.ts +6 -0
- package/dist/setup.js +287 -0
- package/dist/tools/analytics.d.ts +2 -0
- package/dist/tools/analytics.js +69 -0
- package/dist/tools/assignments.d.ts +2 -0
- package/dist/tools/assignments.js +175 -0
- package/dist/tools/calendar.d.ts +2 -0
- package/dist/tools/calendar.js +119 -0
- package/dist/tools/courses.d.ts +2 -0
- package/dist/tools/courses.js +52 -0
- package/dist/tools/discussions.d.ts +2 -0
- package/dist/tools/discussions.js +134 -0
- package/dist/tools/enrollments.d.ts +2 -0
- package/dist/tools/enrollments.js +105 -0
- package/dist/tools/files.d.ts +2 -0
- package/dist/tools/files.js +148 -0
- package/dist/tools/grading.d.ts +2 -0
- package/dist/tools/grading.js +260 -0
- package/dist/tools/modules.d.ts +2 -0
- package/dist/tools/modules.js +215 -0
- package/dist/tools/new-quizzes.d.ts +2 -0
- package/dist/tools/new-quizzes.js +444 -0
- package/dist/tools/pages.d.ts +2 -0
- package/dist/tools/pages.js +150 -0
- package/dist/tools/quizzes.d.ts +2 -0
- package/dist/tools/quizzes.js +83 -0
- package/dist/tools/rubrics.d.ts +2 -0
- package/dist/tools/rubrics.js +298 -0
- package/dist/tools/scheduling.d.ts +2 -0
- package/dist/tools/scheduling.js +133 -0
- package/dist/tools/submissions.d.ts +2 -0
- package/dist/tools/submissions.js +150 -0
- package/package.json +43 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { canvas, canvasAll } from "../canvas-client.js";
|
|
3
|
+
export function registerQuizTools(server) {
|
|
4
|
+
server.tool("list_quizzes", "List quizzes in a course. Note: New Quizzes also appear as assignments (with submission_types=['external_tool'] and is_quiz_lti_assignment=true). This endpoint covers Classic Quizzes; use list_assignments to see New Quizzes.", {
|
|
5
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
6
|
+
search_term: z.string().optional().describe("Filter by title"),
|
|
7
|
+
}, async ({ course_id, search_term }) => {
|
|
8
|
+
const params = {};
|
|
9
|
+
if (search_term)
|
|
10
|
+
params.search_term = search_term;
|
|
11
|
+
const quizzes = await canvasAll(`/courses/${course_id}/quizzes`, params);
|
|
12
|
+
const summary = quizzes.map((q) => ({
|
|
13
|
+
id: q.id,
|
|
14
|
+
title: q.title,
|
|
15
|
+
quiz_type: q.quiz_type,
|
|
16
|
+
due_at: q.due_at,
|
|
17
|
+
unlock_at: q.unlock_at,
|
|
18
|
+
lock_at: q.lock_at,
|
|
19
|
+
points_possible: q.points_possible,
|
|
20
|
+
question_count: q.question_count,
|
|
21
|
+
time_limit: q.time_limit,
|
|
22
|
+
published: q.published,
|
|
23
|
+
assignment_id: q.assignment_id,
|
|
24
|
+
html_url: q.html_url,
|
|
25
|
+
}));
|
|
26
|
+
return {
|
|
27
|
+
content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
server.tool("get_quiz", "Get full details of a Classic Quiz. For New Quizzes (Quizzes.Next), use get_new_quiz instead.", {
|
|
31
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
32
|
+
quiz_id: z.string().describe("Quiz ID"),
|
|
33
|
+
}, async ({ course_id, quiz_id }) => {
|
|
34
|
+
const quiz = await canvas(`/courses/${course_id}/quizzes/${quiz_id}`);
|
|
35
|
+
return {
|
|
36
|
+
content: [{ type: "text", text: JSON.stringify(quiz, null, 2) }],
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
server.tool("update_quiz", "Update a Classic Quiz. Only include fields you want to change. For New Quizzes, use update_new_quiz instead.", {
|
|
40
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
41
|
+
quiz_id: z.string().describe("Quiz ID"),
|
|
42
|
+
title: z.string().optional(),
|
|
43
|
+
description: z.string().optional().describe("HTML instructions"),
|
|
44
|
+
due_at: z.string().optional().describe("ISO 8601"),
|
|
45
|
+
unlock_at: z.string().optional().describe("Available from (ISO 8601)"),
|
|
46
|
+
lock_at: z.string().optional().describe("Available until (ISO 8601)"),
|
|
47
|
+
points_possible: z.number().optional(),
|
|
48
|
+
time_limit: z.number().optional().describe("Time limit in minutes (Classic Quizzes use minutes; New Quizzes use seconds)"),
|
|
49
|
+
published: z.boolean().optional(),
|
|
50
|
+
}, async ({ course_id, quiz_id, ...params }) => {
|
|
51
|
+
const result = await canvas(`/courses/${course_id}/quizzes/${quiz_id}`, { method: "PUT", body: JSON.stringify({ quiz: params }) });
|
|
52
|
+
return {
|
|
53
|
+
content: [
|
|
54
|
+
{
|
|
55
|
+
type: "text",
|
|
56
|
+
text: `Updated quiz "${result.title}" (ID: ${result.id})`,
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
});
|
|
61
|
+
server.tool("list_new_quizzes", "List all New Quizzes (Quizzes.Next) in a course by filtering assignments. Returns a summary of each. For full details/settings of a single New Quiz, use get_new_quiz. For question management, use list_quiz_items, create_quiz_item, etc.", {
|
|
62
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
63
|
+
}, async ({ course_id }) => {
|
|
64
|
+
const assignments = await canvasAll(`/courses/${course_id}/assignments`);
|
|
65
|
+
const newQuizzes = assignments
|
|
66
|
+
.filter((a) => a.is_quiz_lti_assignment)
|
|
67
|
+
.map((a) => ({
|
|
68
|
+
id: a.id,
|
|
69
|
+
name: a.name,
|
|
70
|
+
due_at: a.due_at,
|
|
71
|
+
unlock_at: a.unlock_at,
|
|
72
|
+
lock_at: a.lock_at,
|
|
73
|
+
points_possible: a.points_possible,
|
|
74
|
+
published: a.published,
|
|
75
|
+
html_url: a.html_url,
|
|
76
|
+
}));
|
|
77
|
+
return {
|
|
78
|
+
content: [
|
|
79
|
+
{ type: "text", text: JSON.stringify(newQuizzes, null, 2) },
|
|
80
|
+
],
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { canvas, canvasAll } from "../canvas-client.js";
|
|
3
|
+
export function registerRubricTools(server) {
|
|
4
|
+
server.tool("list_rubrics", "List all rubrics in a course. Returns rubric ID, title, point total, and number of criteria.", {
|
|
5
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
6
|
+
}, async ({ course_id }) => {
|
|
7
|
+
const rubrics = await canvasAll(`/courses/${course_id}/rubrics`);
|
|
8
|
+
const summary = rubrics.map((r) => ({
|
|
9
|
+
id: r.id,
|
|
10
|
+
title: r.title,
|
|
11
|
+
points_possible: r.points_possible,
|
|
12
|
+
criteria_count: r.data?.length ?? 0,
|
|
13
|
+
associations_count: r.association_count,
|
|
14
|
+
}));
|
|
15
|
+
return {
|
|
16
|
+
content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
|
|
17
|
+
};
|
|
18
|
+
});
|
|
19
|
+
server.tool("get_rubric", "Get full details of a rubric, including all criteria and their rating scales. The returned criterion IDs (e.g. '_7998') are needed by grade_with_rubric.", {
|
|
20
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
21
|
+
rubric_id: z.string().describe("Rubric ID"),
|
|
22
|
+
}, async ({ course_id, rubric_id }) => {
|
|
23
|
+
const rubric = await canvas(`/courses/${course_id}/rubrics/${rubric_id}?include[]=assignment_associations`);
|
|
24
|
+
return {
|
|
25
|
+
content: [{ type: "text", text: JSON.stringify(rubric, null, 2) }],
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
server.tool("create_rubric", "Create a new rubric in a course. Provide criteria as an array of objects, each with a description and an array of ratings (description + points). Optionally associate with an assignment immediately.", {
|
|
29
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
30
|
+
title: z.string().describe("Rubric title"),
|
|
31
|
+
criteria: z
|
|
32
|
+
.array(z.object({
|
|
33
|
+
description: z.string().describe("Criterion name/description"),
|
|
34
|
+
long_description: z
|
|
35
|
+
.string()
|
|
36
|
+
.optional()
|
|
37
|
+
.describe("Detailed criterion description"),
|
|
38
|
+
points: z.number().describe("Maximum points for this criterion"),
|
|
39
|
+
ratings: z.array(z.object({
|
|
40
|
+
description: z.string().describe("Rating level description"),
|
|
41
|
+
points: z.number().describe("Point value for this rating"),
|
|
42
|
+
})),
|
|
43
|
+
}))
|
|
44
|
+
.describe("Array of rubric criteria with their rating scales"),
|
|
45
|
+
assignment_id: z
|
|
46
|
+
.string()
|
|
47
|
+
.optional()
|
|
48
|
+
.describe("If provided, associate this rubric with the assignment and use it for grading"),
|
|
49
|
+
}, async ({ course_id, title, criteria, assignment_id }) => {
|
|
50
|
+
const criteriaObj = {};
|
|
51
|
+
criteria.forEach((c, i) => {
|
|
52
|
+
const ratingsObj = {};
|
|
53
|
+
c.ratings.forEach((r, j) => {
|
|
54
|
+
ratingsObj[String(j)] = {
|
|
55
|
+
description: r.description,
|
|
56
|
+
points: r.points,
|
|
57
|
+
};
|
|
58
|
+
});
|
|
59
|
+
criteriaObj[String(i)] = {
|
|
60
|
+
description: c.description,
|
|
61
|
+
long_description: c.long_description ?? "",
|
|
62
|
+
points: c.points,
|
|
63
|
+
ratings: ratingsObj,
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
const body = {
|
|
67
|
+
rubric: { title, criteria: criteriaObj },
|
|
68
|
+
};
|
|
69
|
+
if (assignment_id) {
|
|
70
|
+
body.rubric_association = {
|
|
71
|
+
association_id: Number(assignment_id),
|
|
72
|
+
association_type: "Assignment",
|
|
73
|
+
use_for_grading: true,
|
|
74
|
+
purpose: "grading",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
body.rubric_association = {
|
|
79
|
+
association_id: Number(course_id),
|
|
80
|
+
association_type: "Course",
|
|
81
|
+
use_for_grading: true,
|
|
82
|
+
purpose: "grading",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const result = await canvas(`/courses/${course_id}/rubrics`, {
|
|
86
|
+
method: "POST",
|
|
87
|
+
body: JSON.stringify(body),
|
|
88
|
+
});
|
|
89
|
+
const rubric = result.rubric ?? result;
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text",
|
|
94
|
+
text: `Created rubric "${rubric.title}" (ID: ${rubric.id}), ${criteria.length} criteria, ${rubric.points_possible} points total${assignment_id ? `, associated with assignment ${assignment_id}` : ""}`,
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
server.tool("update_rubric", "Update an existing rubric's title or criteria. Changes apply to all assignments using this rubric.", {
|
|
100
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
101
|
+
rubric_id: z.string().describe("Rubric ID to update"),
|
|
102
|
+
title: z.string().optional().describe("New rubric title"),
|
|
103
|
+
criteria: z
|
|
104
|
+
.array(z.object({
|
|
105
|
+
description: z.string(),
|
|
106
|
+
long_description: z.string().optional(),
|
|
107
|
+
points: z.number(),
|
|
108
|
+
ratings: z.array(z.object({
|
|
109
|
+
description: z.string(),
|
|
110
|
+
points: z.number(),
|
|
111
|
+
})),
|
|
112
|
+
}))
|
|
113
|
+
.optional()
|
|
114
|
+
.describe("Replacement criteria (replaces all existing criteria)"),
|
|
115
|
+
}, async ({ course_id, rubric_id, title, criteria }) => {
|
|
116
|
+
const body = { rubric: {} };
|
|
117
|
+
if (title)
|
|
118
|
+
body.rubric.title = title;
|
|
119
|
+
if (criteria) {
|
|
120
|
+
const criteriaObj = {};
|
|
121
|
+
criteria.forEach((c, i) => {
|
|
122
|
+
const ratingsObj = {};
|
|
123
|
+
c.ratings.forEach((r, j) => {
|
|
124
|
+
ratingsObj[String(j)] = {
|
|
125
|
+
description: r.description,
|
|
126
|
+
points: r.points,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
criteriaObj[String(i)] = {
|
|
130
|
+
description: c.description,
|
|
131
|
+
long_description: c.long_description ?? "",
|
|
132
|
+
points: c.points,
|
|
133
|
+
ratings: ratingsObj,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
body.rubric.criteria = criteriaObj;
|
|
137
|
+
}
|
|
138
|
+
const result = await canvas(`/courses/${course_id}/rubrics/${rubric_id}`, { method: "PUT", body: JSON.stringify(body) });
|
|
139
|
+
return {
|
|
140
|
+
content: [
|
|
141
|
+
{
|
|
142
|
+
type: "text",
|
|
143
|
+
text: `Updated rubric "${result.title}" (ID: ${result.id})`,
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
server.tool("delete_rubric", "Delete a rubric from a course. This removes it from all associated assignments.", {
|
|
149
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
150
|
+
rubric_id: z.string().describe("Rubric ID to delete"),
|
|
151
|
+
}, async ({ course_id, rubric_id }) => {
|
|
152
|
+
await canvas(`/courses/${course_id}/rubrics/${rubric_id}`, {
|
|
153
|
+
method: "DELETE",
|
|
154
|
+
});
|
|
155
|
+
return {
|
|
156
|
+
content: [
|
|
157
|
+
{ type: "text", text: `Deleted rubric ${rubric_id}` },
|
|
158
|
+
],
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
server.tool("associate_rubric", "Associate an existing rubric with one or more assignments. This is how you reuse a rubric across multiple assignments. Use list_rubrics to find the rubric_id.", {
|
|
162
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
163
|
+
rubric_id: z.string().describe("Rubric ID to associate"),
|
|
164
|
+
assignment_ids: z
|
|
165
|
+
.array(z.string())
|
|
166
|
+
.describe("List of assignment IDs to associate the rubric with"),
|
|
167
|
+
use_for_grading: z
|
|
168
|
+
.boolean()
|
|
169
|
+
.default(true)
|
|
170
|
+
.describe("Whether to use this rubric for grading (default: true)"),
|
|
171
|
+
}, async ({ course_id, rubric_id, assignment_ids, use_for_grading }) => {
|
|
172
|
+
const results = [];
|
|
173
|
+
for (const aid of assignment_ids) {
|
|
174
|
+
try {
|
|
175
|
+
await canvas(`/courses/${course_id}/rubric_associations`, {
|
|
176
|
+
method: "POST",
|
|
177
|
+
body: JSON.stringify({
|
|
178
|
+
rubric_association: {
|
|
179
|
+
rubric_id: Number(rubric_id),
|
|
180
|
+
association_type: "Assignment",
|
|
181
|
+
association_id: Number(aid),
|
|
182
|
+
use_for_grading,
|
|
183
|
+
purpose: "grading",
|
|
184
|
+
},
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
results.push(` OK: assignment ${aid}`);
|
|
188
|
+
}
|
|
189
|
+
catch (e) {
|
|
190
|
+
results.push(` FAILED: assignment ${aid} — ${e.message}`);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
content: [
|
|
195
|
+
{
|
|
196
|
+
type: "text",
|
|
197
|
+
text: `Rubric ${rubric_id} association results (${assignment_ids.length} assignments):\n${results.join("\n")}`,
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
};
|
|
201
|
+
});
|
|
202
|
+
server.tool("remove_rubric_from_assignment", "Remove a rubric association from a specific assignment without deleting the rubric itself.", {
|
|
203
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
204
|
+
assignment_id: z.string().describe("Assignment ID to remove rubric from"),
|
|
205
|
+
}, async ({ course_id, assignment_id }) => {
|
|
206
|
+
// Get the assignment to find its rubric association
|
|
207
|
+
const assignment = await canvas(`/courses/${course_id}/assignments/${assignment_id}`);
|
|
208
|
+
const rubricSettings = assignment.rubric_settings;
|
|
209
|
+
if (!rubricSettings) {
|
|
210
|
+
return {
|
|
211
|
+
content: [
|
|
212
|
+
{
|
|
213
|
+
type: "text",
|
|
214
|
+
text: `Assignment ${assignment_id} has no rubric associated.`,
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
// Remove by updating the assignment to clear rubric
|
|
220
|
+
await canvas(`/courses/${course_id}/assignments/${assignment_id}`, {
|
|
221
|
+
method: "PUT",
|
|
222
|
+
body: JSON.stringify({
|
|
223
|
+
assignment: { rubric_settings: { id: null } },
|
|
224
|
+
}),
|
|
225
|
+
});
|
|
226
|
+
return {
|
|
227
|
+
content: [
|
|
228
|
+
{
|
|
229
|
+
type: "text",
|
|
230
|
+
text: `Removed rubric from assignment ${assignment_id} ("${assignment.name}")`,
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
server.tool("grade_with_rubric", "Grade a student's submission using a rubric. Provide scores for each criterion. For simple score-based grading without a rubric, use grade_submission instead.", {
|
|
236
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
237
|
+
assignment_id: z.string().describe("Assignment ID"),
|
|
238
|
+
student_id: z.string().describe("Student's user ID"),
|
|
239
|
+
criterion_scores: z
|
|
240
|
+
.array(z.object({
|
|
241
|
+
criterion_id: z
|
|
242
|
+
.string()
|
|
243
|
+
.describe("Criterion ID from the rubric (e.g., '_7998'). Use get_rubric to find these."),
|
|
244
|
+
points: z.number().describe("Points to award for this criterion"),
|
|
245
|
+
comments: z
|
|
246
|
+
.string()
|
|
247
|
+
.optional()
|
|
248
|
+
.describe("Feedback comment for this criterion"),
|
|
249
|
+
}))
|
|
250
|
+
.describe("Scores for each rubric criterion"),
|
|
251
|
+
}, async ({ course_id, assignment_id, student_id, criterion_scores }) => {
|
|
252
|
+
const rubricAssessment = {};
|
|
253
|
+
for (const cs of criterion_scores) {
|
|
254
|
+
rubricAssessment[cs.criterion_id] = {
|
|
255
|
+
points: cs.points,
|
|
256
|
+
...(cs.comments ? { comments: cs.comments } : {}),
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const result = await canvas(`/courses/${course_id}/assignments/${assignment_id}/submissions/${student_id}`, {
|
|
260
|
+
method: "PUT",
|
|
261
|
+
body: JSON.stringify({
|
|
262
|
+
rubric_assessment: rubricAssessment,
|
|
263
|
+
}),
|
|
264
|
+
});
|
|
265
|
+
return {
|
|
266
|
+
content: [
|
|
267
|
+
{
|
|
268
|
+
type: "text",
|
|
269
|
+
text: `Graded submission for student ${student_id} on assignment ${assignment_id}: score ${result.score}/${result.assignment?.points_possible ?? "?"}`,
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
};
|
|
273
|
+
});
|
|
274
|
+
server.tool("get_rubric_assessments", "Get all rubric assessments (grades) for an assignment. Shows how each student was scored on each criterion.", {
|
|
275
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
276
|
+
assignment_id: z.string().describe("Assignment ID"),
|
|
277
|
+
}, async ({ course_id, assignment_id }) => {
|
|
278
|
+
const submissions = await canvasAll(`/courses/${course_id}/assignments/${assignment_id}/submissions`, { include: "rubric_assessment,user" });
|
|
279
|
+
const assessed = submissions
|
|
280
|
+
.filter((s) => s.rubric_assessment)
|
|
281
|
+
.map((s) => ({
|
|
282
|
+
student_id: s.user_id,
|
|
283
|
+
student_name: s.user?.name ?? s.user?.sortable_name ?? "unknown",
|
|
284
|
+
score: s.score,
|
|
285
|
+
rubric_assessment: s.rubric_assessment,
|
|
286
|
+
}));
|
|
287
|
+
return {
|
|
288
|
+
content: [
|
|
289
|
+
{
|
|
290
|
+
type: "text",
|
|
291
|
+
text: assessed.length > 0
|
|
292
|
+
? JSON.stringify(assessed, null, 2)
|
|
293
|
+
: "No rubric assessments found for this assignment.",
|
|
294
|
+
},
|
|
295
|
+
],
|
|
296
|
+
};
|
|
297
|
+
});
|
|
298
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { canvas, canvasAll } from "../canvas-client.js";
|
|
3
|
+
export function registerSchedulingTools(server) {
|
|
4
|
+
server.tool("update_assignment_dates", "Update due_at, unlock_at, and/or lock_at for a single assignment. Also works for graded discussions and New Quizzes (which are assignments under the hood). Prefer this over update_assignment when you only need to change dates.", {
|
|
5
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
6
|
+
assignment_id: z.string().describe("Assignment ID"),
|
|
7
|
+
due_at: z
|
|
8
|
+
.string()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe("Due date (ISO 8601). Empty string to clear."),
|
|
11
|
+
unlock_at: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe("Available from (ISO 8601). Empty string to clear."),
|
|
15
|
+
lock_at: z
|
|
16
|
+
.string()
|
|
17
|
+
.optional()
|
|
18
|
+
.describe("Available until (ISO 8601). Empty string to clear."),
|
|
19
|
+
}, async ({ course_id, assignment_id, ...dates }) => {
|
|
20
|
+
const result = await canvas(`/courses/${course_id}/assignments/${assignment_id}`, {
|
|
21
|
+
method: "PUT",
|
|
22
|
+
body: JSON.stringify({ assignment: dates }),
|
|
23
|
+
});
|
|
24
|
+
return {
|
|
25
|
+
content: [
|
|
26
|
+
{
|
|
27
|
+
type: "text",
|
|
28
|
+
text: `Updated dates for "${result.name}":\n due_at: ${result.due_at}\n unlock_at: ${result.unlock_at}\n lock_at: ${result.lock_at}`,
|
|
29
|
+
},
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
server.tool("batch_update_dates", "Update dates for multiple assignments at once. Works for regular assignments, graded discussions, and New Quizzes. Use get_course_schedule_overview first to see current dates. Provide an array of {assignment_id, due_at, unlock_at, lock_at} objects.", {
|
|
34
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
35
|
+
date_updates: z
|
|
36
|
+
.array(z.object({
|
|
37
|
+
assignment_id: z.string(),
|
|
38
|
+
due_at: z.string().optional(),
|
|
39
|
+
unlock_at: z.string().optional(),
|
|
40
|
+
lock_at: z.string().optional(),
|
|
41
|
+
}))
|
|
42
|
+
.describe("Array of assignment ID + date updates"),
|
|
43
|
+
}, async ({ course_id, date_updates }) => {
|
|
44
|
+
const results = [];
|
|
45
|
+
for (const { assignment_id, ...dates } of date_updates) {
|
|
46
|
+
try {
|
|
47
|
+
const result = await canvas(`/courses/${course_id}/assignments/${assignment_id}`, {
|
|
48
|
+
method: "PUT",
|
|
49
|
+
body: JSON.stringify({ assignment: dates }),
|
|
50
|
+
});
|
|
51
|
+
results.push(` OK: "${result.name}" → due ${result.due_at ?? "none"}`);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
results.push(` FAILED: ${assignment_id} — ${e.message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
content: [
|
|
59
|
+
{
|
|
60
|
+
type: "text",
|
|
61
|
+
text: `Batch date update (${date_updates.length} assignments):\n${results.join("\n")}`,
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
};
|
|
65
|
+
});
|
|
66
|
+
server.tool("update_quiz_dates", "Update dates for a classic quiz (not New Quizzes — those are assignments).", {
|
|
67
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
68
|
+
quiz_id: z.string().describe("Classic Quiz ID (not assignment ID)"),
|
|
69
|
+
due_at: z.string().optional().describe("Due date (ISO 8601)"),
|
|
70
|
+
unlock_at: z.string().optional().describe("Available from (ISO 8601)"),
|
|
71
|
+
lock_at: z.string().optional().describe("Available until (ISO 8601)"),
|
|
72
|
+
}, async ({ course_id, quiz_id, ...dates }) => {
|
|
73
|
+
const result = await canvas(`/courses/${course_id}/quizzes/${quiz_id}`, {
|
|
74
|
+
method: "PUT",
|
|
75
|
+
body: JSON.stringify({ quiz: dates }),
|
|
76
|
+
});
|
|
77
|
+
return {
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: `Updated dates for quiz "${result.title}":\n due_at: ${result.due_at}\n unlock_at: ${result.unlock_at}\n lock_at: ${result.lock_at}`,
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
server.tool("get_course_schedule_overview", "Get a chronological overview of all dated assignments, discussions, and quizzes in a course. Useful for understanding the current schedule before making changes.", {
|
|
87
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
88
|
+
}, async ({ course_id }) => {
|
|
89
|
+
const [assignments, quizzes] = await Promise.all([
|
|
90
|
+
canvasAll(`/courses/${course_id}/assignments`),
|
|
91
|
+
canvasAll(`/courses/${course_id}/quizzes`),
|
|
92
|
+
]);
|
|
93
|
+
const items = [
|
|
94
|
+
...assignments.map((a) => ({
|
|
95
|
+
type: a.is_quiz_lti_assignment
|
|
96
|
+
? "new_quiz"
|
|
97
|
+
: a.submission_types?.includes("discussion_topic")
|
|
98
|
+
? "discussion"
|
|
99
|
+
: "assignment",
|
|
100
|
+
id: a.id,
|
|
101
|
+
name: a.name,
|
|
102
|
+
due_at: a.due_at,
|
|
103
|
+
unlock_at: a.unlock_at,
|
|
104
|
+
lock_at: a.lock_at,
|
|
105
|
+
points: a.points_possible,
|
|
106
|
+
published: a.published,
|
|
107
|
+
})),
|
|
108
|
+
...quizzes.map((q) => ({
|
|
109
|
+
type: "classic_quiz",
|
|
110
|
+
id: q.id,
|
|
111
|
+
name: q.title,
|
|
112
|
+
due_at: q.due_at,
|
|
113
|
+
unlock_at: q.unlock_at,
|
|
114
|
+
lock_at: q.lock_at,
|
|
115
|
+
points: q.points_possible,
|
|
116
|
+
published: q.published,
|
|
117
|
+
})),
|
|
118
|
+
];
|
|
119
|
+
// Sort by due date (undated items at the end)
|
|
120
|
+
items.sort((a, b) => {
|
|
121
|
+
if (!a.due_at && !b.due_at)
|
|
122
|
+
return 0;
|
|
123
|
+
if (!a.due_at)
|
|
124
|
+
return 1;
|
|
125
|
+
if (!b.due_at)
|
|
126
|
+
return -1;
|
|
127
|
+
return new Date(a.due_at).getTime() - new Date(b.due_at).getTime();
|
|
128
|
+
});
|
|
129
|
+
return {
|
|
130
|
+
content: [{ type: "text", text: JSON.stringify(items, null, 2) }],
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { canvas, canvasAll } from "../canvas-client.js";
|
|
3
|
+
import { writeFile, mkdir } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export function registerSubmissionTools(server) {
|
|
6
|
+
server.tool("list_submissions", "List all submissions for an assignment (including graded discussions and New Quizzes). Shows student name, score, submission status, and submitted_at timestamp.", {
|
|
7
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
8
|
+
assignment_id: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe("Assignment ID (also works for the linked assignment of graded discussions and New Quizzes)"),
|
|
11
|
+
include_comments: z
|
|
12
|
+
.boolean()
|
|
13
|
+
.default(false)
|
|
14
|
+
.describe("Include submission comments"),
|
|
15
|
+
}, async ({ course_id, assignment_id, include_comments }) => {
|
|
16
|
+
const params = {
|
|
17
|
+
include: include_comments ? "user,submission_comments" : "user",
|
|
18
|
+
};
|
|
19
|
+
// Use the "grouped" parameter to avoid duplicate entries
|
|
20
|
+
const submissions = await canvasAll(`/courses/${course_id}/assignments/${assignment_id}/submissions`, params);
|
|
21
|
+
const summary = submissions.map((s) => ({
|
|
22
|
+
user_id: s.user_id,
|
|
23
|
+
user_name: s.user?.name ?? s.user?.sortable_name ?? "unknown",
|
|
24
|
+
workflow_state: s.workflow_state,
|
|
25
|
+
submitted_at: s.submitted_at,
|
|
26
|
+
score: s.score,
|
|
27
|
+
grade: s.grade,
|
|
28
|
+
late: s.late,
|
|
29
|
+
missing: s.missing,
|
|
30
|
+
attempt: s.attempt,
|
|
31
|
+
submission_type: s.submission_type,
|
|
32
|
+
preview_url: s.preview_url,
|
|
33
|
+
...(include_comments && s.submission_comments
|
|
34
|
+
? {
|
|
35
|
+
comments: s.submission_comments.map((c) => ({
|
|
36
|
+
author: c.author_name,
|
|
37
|
+
comment: c.comment,
|
|
38
|
+
created_at: c.created_at,
|
|
39
|
+
})),
|
|
40
|
+
}
|
|
41
|
+
: {}),
|
|
42
|
+
}));
|
|
43
|
+
return {
|
|
44
|
+
content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
server.tool("download_submissions", "Download all submitted files for an assignment to a local directory. Creates a folder per student. Returns the path where files were saved.", {
|
|
48
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
49
|
+
assignment_id: z.string().describe("Assignment ID"),
|
|
50
|
+
output_dir: z
|
|
51
|
+
.string()
|
|
52
|
+
.default("./downloads")
|
|
53
|
+
.describe("Local directory to save files to"),
|
|
54
|
+
}, async ({ course_id, assignment_id, output_dir }) => {
|
|
55
|
+
const submissions = await canvasAll(`/courses/${course_id}/assignments/${assignment_id}/submissions`, { include: "user" });
|
|
56
|
+
// Get assignment name for the folder
|
|
57
|
+
const assignment = await canvas(`/courses/${course_id}/assignments/${assignment_id}`);
|
|
58
|
+
const safeName = assignment.name.replace(/[^a-zA-Z0-9_\- ]/g, "");
|
|
59
|
+
const baseDir = join(output_dir, safeName);
|
|
60
|
+
let downloaded = 0;
|
|
61
|
+
let skipped = 0;
|
|
62
|
+
const errors = [];
|
|
63
|
+
for (const sub of submissions) {
|
|
64
|
+
if (!sub.attachments || sub.attachments.length === 0) {
|
|
65
|
+
skipped++;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
const studentName = (sub.user?.sortable_name ??
|
|
69
|
+
sub.user?.name ??
|
|
70
|
+
`user_${sub.user_id}`).replace(/[^a-zA-Z0-9_\- ]/g, "");
|
|
71
|
+
const studentDir = join(baseDir, studentName);
|
|
72
|
+
await mkdir(studentDir, { recursive: true });
|
|
73
|
+
for (const attachment of sub.attachments) {
|
|
74
|
+
try {
|
|
75
|
+
let res = null;
|
|
76
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
77
|
+
res = await fetch(attachment.url, {
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Bearer ${process.env.CANVAS_API_TOKEN}`,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
if (res.ok || res.status !== 429)
|
|
83
|
+
break;
|
|
84
|
+
const delay = 1000 * (attempt + 1);
|
|
85
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok)
|
|
88
|
+
throw new Error(`HTTP ${res.status} for ${attachment.filename}`);
|
|
89
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
90
|
+
await writeFile(join(studentDir, attachment.filename), buffer);
|
|
91
|
+
downloaded++;
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
errors.push(` ${studentName}/${attachment.filename}: ${e.message}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
let report = `Downloaded submissions for "${assignment.name}" to ${baseDir}\n`;
|
|
99
|
+
report += ` Files downloaded: ${downloaded}\n`;
|
|
100
|
+
report += ` Students with no submission: ${skipped}\n`;
|
|
101
|
+
if (errors.length > 0) {
|
|
102
|
+
report += ` Errors:\n${errors.join("\n")}`;
|
|
103
|
+
}
|
|
104
|
+
return { content: [{ type: "text", text: report }] };
|
|
105
|
+
});
|
|
106
|
+
server.tool("download_discussion_entries", "Download all entries/replies for a discussion topic as a JSON file — the actual student posts and replies. Unlike list_submissions (which shows grading metadata for graded discussions), this downloads the discussion content itself.", {
|
|
107
|
+
course_id: z.string().describe("Canvas course ID"),
|
|
108
|
+
discussion_id: z.string().describe("Discussion topic ID"),
|
|
109
|
+
output_dir: z.string().default("./downloads"),
|
|
110
|
+
}, async ({ course_id, discussion_id, output_dir }) => {
|
|
111
|
+
const [discussion, entries] = await Promise.all([
|
|
112
|
+
canvas(`/courses/${course_id}/discussion_topics/${discussion_id}`),
|
|
113
|
+
canvasAll(`/courses/${course_id}/discussion_topics/${discussion_id}/entries`),
|
|
114
|
+
]);
|
|
115
|
+
// Also fetch replies for each top-level entry
|
|
116
|
+
const fullEntries = await Promise.all(entries.map(async (entry) => {
|
|
117
|
+
let replies = [];
|
|
118
|
+
try {
|
|
119
|
+
replies = await canvasAll(`/courses/${course_id}/discussion_topics/${discussion_id}/entries/${entry.id}/replies`);
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
// Some entries may not have replies endpoint
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
id: entry.id,
|
|
126
|
+
user_name: entry.user_name,
|
|
127
|
+
message: entry.message,
|
|
128
|
+
created_at: entry.created_at,
|
|
129
|
+
replies: replies.map((r) => ({
|
|
130
|
+
id: r.id,
|
|
131
|
+
user_name: r.user_name,
|
|
132
|
+
message: r.message,
|
|
133
|
+
created_at: r.created_at,
|
|
134
|
+
})),
|
|
135
|
+
};
|
|
136
|
+
}));
|
|
137
|
+
const safeName = discussion.title.replace(/[^a-zA-Z0-9_\- ]/g, "");
|
|
138
|
+
await mkdir(output_dir, { recursive: true });
|
|
139
|
+
const filePath = join(output_dir, `${safeName}.json`);
|
|
140
|
+
await writeFile(filePath, JSON.stringify(fullEntries, null, 2));
|
|
141
|
+
return {
|
|
142
|
+
content: [
|
|
143
|
+
{
|
|
144
|
+
type: "text",
|
|
145
|
+
text: `Saved ${fullEntries.length} entries (with replies) to ${filePath}`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
});
|
|
150
|
+
}
|