@charlie.act7/canvas-mcp-server 1.2.0 → 1.2.2

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.
@@ -4,11 +4,22 @@ export class CanvasClient {
4
4
  quizClient;
5
5
  token;
6
6
  domain;
7
- constructor(token, domain) {
7
+ autoRenewToken;
8
+ renewThresholdMs;
9
+ onTokenRenewed;
10
+ tokenMetaCacheMs = 15 * 60 * 1000;
11
+ tokenMeta = null;
12
+ tokenMetaCheckedAt = 0;
13
+ isCheckingToken = false;
14
+ constructor(token, domain, options = {}) {
8
15
  this.token = token;
9
16
  this.domain = domain;
17
+ this.autoRenewToken = options.autoRenewToken ?? true;
18
+ this.renewThresholdMs = (options.renewThresholdHours ?? 24) * 60 * 60 * 1000;
19
+ this.onTokenRenewed = options.onTokenRenewed;
10
20
  this.client = this.createAxiosInstance(token, domain);
11
21
  this.quizClient = this.createAxiosInstance(token, domain, 'api/quiz/v1');
22
+ this.attachAutoRenewInterceptor();
12
23
  }
13
24
  createAxiosInstance(token, domain, apiPath = 'api/v1') {
14
25
  const baseURL = `https://${domain}/${apiPath}`;
@@ -25,6 +36,71 @@ export class CanvasClient {
25
36
  this.domain = domain;
26
37
  this.client = this.createAxiosInstance(token, domain);
27
38
  this.quizClient = this.createAxiosInstance(token, domain, 'api/quiz/v1');
39
+ this.tokenMeta = null;
40
+ this.tokenMetaCheckedAt = 0;
41
+ this.attachAutoRenewInterceptor();
42
+ }
43
+ attachAutoRenewInterceptor() {
44
+ this.client.interceptors.request.use(async (config) => {
45
+ if (this.autoRenewToken && !this.isCheckingToken) {
46
+ await this.maybeRenewToken();
47
+ }
48
+ return config;
49
+ });
50
+ }
51
+ applyNewToken(newToken) {
52
+ this.token = newToken;
53
+ this.client.defaults.headers['Authorization'] = `Bearer ${newToken}`;
54
+ this.quizClient.defaults.headers['Authorization'] = `Bearer ${newToken}`;
55
+ }
56
+ // Canvas only returns the full token value on creation/regeneration, never on list/get.
57
+ // To identify which listed token corresponds to the one currently authenticating this
58
+ // client, we match its (short) token_hint as a substring of the known full token value.
59
+ async refreshTokenMeta() {
60
+ this.isCheckingToken = true;
61
+ try {
62
+ const tokens = await this.getAllPages('users/self/user_generated_tokens', { per_page: 100 });
63
+ const match = tokens.find(t => t.token_hint && this.token.includes(t.token_hint));
64
+ this.tokenMeta = match
65
+ ? { id: match.id, expiresAtMs: match.expires_at ? new Date(match.expires_at).getTime() : null }
66
+ : null;
67
+ }
68
+ catch {
69
+ // Don't block requests if we can't determine token metadata (e.g. insufficient permissions).
70
+ this.tokenMeta = null;
71
+ }
72
+ finally {
73
+ this.tokenMetaCheckedAt = Date.now();
74
+ this.isCheckingToken = false;
75
+ }
76
+ }
77
+ async maybeRenewToken() {
78
+ const now = Date.now();
79
+ if (now - this.tokenMetaCheckedAt >= this.tokenMetaCacheMs) {
80
+ await this.refreshTokenMeta();
81
+ }
82
+ if (!this.tokenMeta || this.tokenMeta.expiresAtMs === null)
83
+ return;
84
+ if (this.tokenMeta.expiresAtMs - now > this.renewThresholdMs)
85
+ return;
86
+ this.isCheckingToken = true;
87
+ try {
88
+ const response = await this.client.put(`users/self/tokens/${this.tokenMeta.id}`, {
89
+ token: { regenerate: true }
90
+ });
91
+ if (response.data.token) {
92
+ this.applyNewToken(response.data.token);
93
+ this.onTokenRenewed?.(response.data.token);
94
+ }
95
+ this.tokenMeta = {
96
+ id: this.tokenMeta.id,
97
+ expiresAtMs: response.data.expires_at ? new Date(response.data.expires_at).getTime() : null
98
+ };
99
+ this.tokenMetaCheckedAt = Date.now();
100
+ }
101
+ finally {
102
+ this.isCheckingToken = false;
103
+ }
28
104
  }
29
105
  parseLinkHeader(header) {
30
106
  if (!header)
@@ -273,9 +349,38 @@ export class CanvasClient {
273
349
  return response.data;
274
350
  }
275
351
  async updateModule(courseId, moduleId, data) {
276
- const response = await this.client.put(`courses/${courseId}/modules/${moduleId}`, {
277
- module: data
278
- });
352
+ // Canvas only accepts arrays (prerequisite_module_ids, completion_requirements)
353
+ // via form-encoding, not JSON.
354
+ const hasArrays = data.prerequisite_module_ids !== undefined || data.completion_requirements !== undefined;
355
+ if (hasArrays) {
356
+ const params = new URLSearchParams();
357
+ if (data.name !== undefined)
358
+ params.set('module[name]', data.name);
359
+ if (data.published !== undefined)
360
+ params.set('module[published]', String(data.published));
361
+ if (data.position !== undefined)
362
+ params.set('module[position]', String(data.position));
363
+ if (data.require_sequential_progress !== undefined) {
364
+ params.set('module[require_sequential_progress]', String(data.require_sequential_progress));
365
+ }
366
+ if (data.prerequisite_module_ids) {
367
+ for (const id of data.prerequisite_module_ids) {
368
+ params.append('module[prerequisite_module_ids][]', String(id));
369
+ }
370
+ }
371
+ if (data.completion_requirements) {
372
+ for (const req of data.completion_requirements) {
373
+ params.append('module[completion_requirements][][id]', String(req.id));
374
+ params.append('module[completion_requirements][][type]', req.type);
375
+ if (req.min_score !== undefined) {
376
+ params.append('module[completion_requirements][][min_score]', String(req.min_score));
377
+ }
378
+ }
379
+ }
380
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}`, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
381
+ return response.data;
382
+ }
383
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}`, { module: data });
279
384
  return response.data;
280
385
  }
281
386
  async deleteModule(courseId, moduleId) {
@@ -290,9 +395,20 @@ export class CanvasClient {
290
395
  return response.data;
291
396
  }
292
397
  async updateModuleItem(courseId, moduleId, itemId, data) {
293
- const response = await this.client.put(`courses/${courseId}/modules/${moduleId}/items/${itemId}`, {
294
- module_item: data
295
- });
398
+ // Canvas only accepts completion_requirement via form-encoding, not JSON.
399
+ // Use URLSearchParams when completion_requirement is present.
400
+ if (data.completion_requirement) {
401
+ const params = new URLSearchParams();
402
+ params.set('module_item[completion_requirement][type]', data.completion_requirement.type);
403
+ const { completion_requirement, ...rest } = data;
404
+ for (const [key, value] of Object.entries(rest)) {
405
+ if (value !== undefined)
406
+ params.set(`module_item[${key}]`, String(value));
407
+ }
408
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}/items/${itemId}`, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
409
+ return response.data;
410
+ }
411
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}/items/${itemId}`, { module_item: data });
296
412
  return response.data;
297
413
  }
298
414
  async deleteModuleItem(courseId, moduleId, itemId) {
@@ -333,6 +449,39 @@ export class CanvasClient {
333
449
  }
334
450
  return uploadResponse.data;
335
451
  }
452
+ async getQuizSubmissions(courseId, quizId) {
453
+ const response = await this.client.get(`courses/${courseId}/quizzes/${quizId}/submissions`, {
454
+ params: { include: ['user'], per_page: 100 }
455
+ });
456
+ return response.data.quiz_submissions ?? [];
457
+ }
458
+ async deleteQuiz(courseId, quizId) {
459
+ await this.client.delete(`courses/${courseId}/quizzes/${quizId}`);
460
+ return { deleted: true };
461
+ }
462
+ async checkQuizPending(courseId, quizId) {
463
+ const [students, submissionsResp] = await Promise.all([
464
+ this.getEnrollments(courseId),
465
+ this.client.get(`courses/${courseId}/quizzes/${quizId}/submissions`, {
466
+ params: { per_page: 100 }
467
+ })
468
+ ]);
469
+ const realStudents = students.filter(u => u.name !== 'Estudiante de prueba' && u.sis_user_id !== null);
470
+ const submittedIds = new Set((submissionsResp.data.quiz_submissions ?? []).map((s) => s.user_id));
471
+ const pending = realStudents
472
+ .filter(u => !submittedIds.has(u.id))
473
+ .map(u => ({
474
+ id: u.id,
475
+ name: u.name ?? 'Desconocido',
476
+ email: u.login_id ?? ''
477
+ }));
478
+ return {
479
+ quiz_id: quizId,
480
+ total_students: realStudents.length,
481
+ submitted: submittedIds.size,
482
+ pending
483
+ };
484
+ }
336
485
  async createQuiz(courseId, quiz) {
337
486
  const response = await this.client.post(`courses/${courseId}/quizzes`, {
338
487
  quiz: quiz
@@ -699,4 +848,41 @@ export class CanvasClient {
699
848
  await this.quizClient.delete(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`);
700
849
  return { deleted: true };
701
850
  }
851
+ // --- Access Tokens ---
852
+ async listAccessTokens(userId = 'self') {
853
+ return this.getAllPages(`users/${userId}/user_generated_tokens`, { per_page: 100 });
854
+ }
855
+ async getAccessToken(userId = 'self', tokenId) {
856
+ const response = await this.client.get(`users/${userId}/tokens/${tokenId}`);
857
+ return response.data;
858
+ }
859
+ async createAccessToken(userId = 'self', data) {
860
+ const response = await this.client.post(`users/${userId}/tokens`, { token: data });
861
+ return response.data;
862
+ }
863
+ async updateAccessToken(userId = 'self', tokenId, data) {
864
+ const response = await this.client.put(`users/${userId}/tokens/${tokenId}`, { token: data });
865
+ return response.data;
866
+ }
867
+ async deleteAccessToken(userId = 'self', tokenId) {
868
+ await this.client.delete(`users/${userId}/tokens/${tokenId}`);
869
+ return { deleted: true };
870
+ }
871
+ // Convenience wrapper around updateAccessToken({ regenerate: true }) that, when the
872
+ // regenerated token is the one currently authenticating this client, also hot-swaps
873
+ // and persists it so the MCP server keeps working without a manual reconfiguration.
874
+ async regenerateAccessToken(userId = 'self', tokenId) {
875
+ const result = await this.updateAccessToken(userId, tokenId, { regenerate: true });
876
+ const isActiveToken = userId === 'self' && this.tokenMeta?.id === Number(tokenId);
877
+ if (isActiveToken && result.token) {
878
+ this.applyNewToken(result.token);
879
+ this.onTokenRenewed?.(result.token);
880
+ this.tokenMeta = {
881
+ id: this.tokenMeta.id,
882
+ expiresAtMs: result.expires_at ? new Date(result.expires_at).getTime() : null
883
+ };
884
+ this.tokenMetaCheckedAt = Date.now();
885
+ }
886
+ return result;
887
+ }
702
888
  }
@@ -0,0 +1,183 @@
1
+ import { z } from "zod";
2
+ const userIdSchema = z.union([z.number(), z.string()]).optional();
3
+ export const accessTokenTools = [
4
+ {
5
+ name: "canvas_list_access_tokens",
6
+ tool: {
7
+ name: "canvas_list_access_tokens",
8
+ description: "List manually-generated Canvas API access tokens for a user (defaults to the authenticated user)",
9
+ inputSchema: {
10
+ type: "object",
11
+ properties: {
12
+ user_id: {
13
+ anyOf: [{ type: "number" }, { type: "string" }],
14
+ description: "User ID, or 'self' for the authenticated user (default)"
15
+ }
16
+ }
17
+ }
18
+ },
19
+ handler: async (client, args) => {
20
+ const input = z.object({ user_id: userIdSchema }).parse(args ?? {});
21
+ const result = await client.listAccessTokens(input.user_id ?? 'self');
22
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
23
+ }
24
+ },
25
+ {
26
+ name: "canvas_get_access_token",
27
+ tool: {
28
+ name: "canvas_get_access_token",
29
+ description: "Get details of a specific Canvas access token by its database ID or token_hint",
30
+ inputSchema: {
31
+ type: "object",
32
+ properties: {
33
+ user_id: {
34
+ anyOf: [{ type: "number" }, { type: "string" }],
35
+ description: "User ID, or 'self' for the authenticated user (default)"
36
+ },
37
+ token_id: {
38
+ anyOf: [{ type: "number" }, { type: "string" }],
39
+ description: "The token's database ID or its token_hint"
40
+ }
41
+ },
42
+ required: ["token_id"]
43
+ }
44
+ },
45
+ handler: async (client, args) => {
46
+ const input = z.object({
47
+ user_id: userIdSchema,
48
+ token_id: z.union([z.number(), z.string()])
49
+ }).parse(args);
50
+ const result = await client.getAccessToken(input.user_id ?? 'self', input.token_id);
51
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
52
+ }
53
+ },
54
+ {
55
+ name: "canvas_create_access_token",
56
+ tool: {
57
+ name: "canvas_create_access_token",
58
+ description: "Create a new Canvas API access token. The full token value is returned only in this response — save it, it cannot be retrieved again later.",
59
+ inputSchema: {
60
+ type: "object",
61
+ properties: {
62
+ user_id: {
63
+ anyOf: [{ type: "number" }, { type: "string" }],
64
+ description: "User ID, or 'self' for the authenticated user (default)"
65
+ },
66
+ purpose: { type: "string", description: "Description of what the token is for" },
67
+ expires_at: { type: "string", description: "ISO 8601 datetime when the token should expire (omit for no expiration)" },
68
+ scopes: {
69
+ type: "array",
70
+ items: { type: "string" },
71
+ description: "API scopes to restrict the token to (ignored unless the account has scoped tokens enabled)"
72
+ }
73
+ },
74
+ required: ["purpose"]
75
+ }
76
+ },
77
+ handler: async (client, args) => {
78
+ const input = z.object({
79
+ user_id: userIdSchema,
80
+ purpose: z.string(),
81
+ expires_at: z.string().optional(),
82
+ scopes: z.array(z.string()).optional()
83
+ }).parse(args);
84
+ const { user_id, ...data } = input;
85
+ const result = await client.createAccessToken(user_id ?? 'self', data);
86
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
87
+ }
88
+ },
89
+ {
90
+ name: "canvas_update_access_token",
91
+ tool: {
92
+ name: "canvas_update_access_token",
93
+ description: "Update a Canvas access token's purpose, expiration, or scopes",
94
+ inputSchema: {
95
+ type: "object",
96
+ properties: {
97
+ user_id: {
98
+ anyOf: [{ type: "number" }, { type: "string" }],
99
+ description: "User ID, or 'self' for the authenticated user (default)"
100
+ },
101
+ token_id: {
102
+ anyOf: [{ type: "number" }, { type: "string" }],
103
+ description: "The token's database ID or its token_hint"
104
+ },
105
+ purpose: { type: "string", description: "New description of what the token is for" },
106
+ expires_at: { type: "string", description: "New ISO 8601 expiration datetime, or null to remove expiration" },
107
+ scopes: { type: "array", items: { type: "string" }, description: "New API scopes for the token" }
108
+ },
109
+ required: ["token_id"]
110
+ }
111
+ },
112
+ handler: async (client, args) => {
113
+ const input = z.object({
114
+ user_id: userIdSchema,
115
+ token_id: z.union([z.number(), z.string()]),
116
+ purpose: z.string().optional(),
117
+ expires_at: z.string().nullable().optional(),
118
+ scopes: z.array(z.string()).optional()
119
+ }).parse(args);
120
+ const { user_id, token_id, ...data } = input;
121
+ const result = await client.updateAccessToken(user_id ?? 'self', token_id, data);
122
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
123
+ }
124
+ },
125
+ {
126
+ name: "canvas_regenerate_access_token",
127
+ tool: {
128
+ name: "canvas_regenerate_access_token",
129
+ description: "Regenerate a Canvas access token, issuing a new token value while keeping its purpose/scopes. If the regenerated token is the one currently authenticating this MCP server, it is automatically applied and persisted so the server keeps working without reconfiguration.",
130
+ inputSchema: {
131
+ type: "object",
132
+ properties: {
133
+ user_id: {
134
+ anyOf: [{ type: "number" }, { type: "string" }],
135
+ description: "User ID, or 'self' for the authenticated user (default)"
136
+ },
137
+ token_id: {
138
+ anyOf: [{ type: "number" }, { type: "string" }],
139
+ description: "The token's database ID or its token_hint"
140
+ }
141
+ },
142
+ required: ["token_id"]
143
+ }
144
+ },
145
+ handler: async (client, args) => {
146
+ const input = z.object({
147
+ user_id: userIdSchema,
148
+ token_id: z.union([z.number(), z.string()])
149
+ }).parse(args);
150
+ const result = await client.regenerateAccessToken(input.user_id ?? 'self', input.token_id);
151
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
152
+ }
153
+ },
154
+ {
155
+ name: "canvas_delete_access_token",
156
+ tool: {
157
+ name: "canvas_delete_access_token",
158
+ description: "Permanently delete a Canvas access token",
159
+ inputSchema: {
160
+ type: "object",
161
+ properties: {
162
+ user_id: {
163
+ anyOf: [{ type: "number" }, { type: "string" }],
164
+ description: "User ID, or 'self' for the authenticated user (default)"
165
+ },
166
+ token_id: {
167
+ anyOf: [{ type: "number" }, { type: "string" }],
168
+ description: "The token's database ID or its token_hint"
169
+ }
170
+ },
171
+ required: ["token_id"]
172
+ }
173
+ },
174
+ handler: async (client, args) => {
175
+ const input = z.object({
176
+ user_id: userIdSchema,
177
+ token_id: z.union([z.number(), z.string()])
178
+ }).parse(args);
179
+ const result = await client.deleteAccessToken(input.user_id ?? 'self', input.token_id);
180
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
181
+ }
182
+ }
183
+ ];
@@ -63,7 +63,7 @@ export const moduleTools = [
63
63
  name: "canvas_update_module",
64
64
  tool: {
65
65
  name: "canvas_update_module",
66
- description: "Update an existing module",
66
+ description: "Update an existing module (name, published, position, prerequisites, completion requirements)",
67
67
  inputSchema: {
68
68
  type: "object",
69
69
  properties: {
@@ -74,7 +74,32 @@ export const moduleTools = [
74
74
  module_id: { type: "number", description: "The ID of the module" },
75
75
  name: { type: "string", description: "The new name of the module" },
76
76
  published: { type: "boolean", description: "Whether the module is published" },
77
- position: { type: "number", description: "The new position of the module" }
77
+ position: { type: "number", description: "The new position of the module" },
78
+ prerequisite_module_ids: {
79
+ type: "array",
80
+ items: { type: "number" },
81
+ description: "IDs of modules that must be completed before this one unlocks"
82
+ },
83
+ require_sequential_progress: {
84
+ type: "boolean",
85
+ description: "Whether items in the module must be completed in order"
86
+ },
87
+ completion_requirements: {
88
+ type: "array",
89
+ description: "Completion requirements for module items. Replaces ALL existing requirements — include every item that needs one.",
90
+ items: {
91
+ type: "object",
92
+ properties: {
93
+ id: { type: "number", description: "The module item ID" },
94
+ type: {
95
+ type: "string",
96
+ description: "must_view, must_submit, must_contribute, min_score, must_mark_done"
97
+ },
98
+ min_score: { type: "number", description: "Required when type is min_score" }
99
+ },
100
+ required: ["id", "type"]
101
+ }
102
+ }
78
103
  },
79
104
  required: ["course_id", "module_id"],
80
105
  },
@@ -85,13 +110,23 @@ export const moduleTools = [
85
110
  module_id: z.number(),
86
111
  name: z.string().optional(),
87
112
  published: z.boolean().optional(),
88
- position: z.number().optional()
113
+ position: z.number().optional(),
114
+ prerequisite_module_ids: z.array(z.number()).optional(),
115
+ require_sequential_progress: z.boolean().optional(),
116
+ completion_requirements: z.array(z.object({
117
+ id: z.number(),
118
+ type: z.string(),
119
+ min_score: z.number().optional()
120
+ })).optional()
89
121
  }).parse(args);
90
122
  const courseId = await resolveCourseId(client, input.course_id);
91
123
  const module = await client.updateModule(courseId, input.module_id, {
92
124
  name: input.name,
93
125
  published: input.published,
94
- position: input.position
126
+ position: input.position,
127
+ prerequisite_module_ids: input.prerequisite_module_ids,
128
+ require_sequential_progress: input.require_sequential_progress,
129
+ completion_requirements: input.completion_requirements
95
130
  });
96
131
  return {
97
132
  content: [{ type: "text", text: JSON.stringify(module, null, 2) }],
@@ -206,7 +241,11 @@ export const moduleTools = [
206
241
  position: { type: "number", description: "The new position of the item" },
207
242
  indent: { type: "number", description: "The new level of indentation (0-3)" },
208
243
  published: { type: "boolean", description: "Whether the item is published" },
209
- new_module_id: { type: "number", description: "Move the item to a new module" }
244
+ new_module_id: { type: "number", description: "Move the item to a new module" },
245
+ completion_requirement_type: {
246
+ type: "string",
247
+ description: "Completion requirement type: must_view, must_submit, must_contribute, min_score, must_mark_done"
248
+ }
210
249
  },
211
250
  required: ["course_id", "module_id", "item_id"],
212
251
  },
@@ -220,7 +259,8 @@ export const moduleTools = [
220
259
  position: z.number().optional(),
221
260
  indent: z.number().optional(),
222
261
  published: z.boolean().optional(),
223
- new_module_id: z.number().optional()
262
+ new_module_id: z.number().optional(),
263
+ completion_requirement_type: z.string().optional()
224
264
  }).parse(args);
225
265
  const courseId = await resolveCourseId(client, input.course_id);
226
266
  const result = await client.updateModuleItem(courseId, input.module_id, input.item_id, {
@@ -228,7 +268,10 @@ export const moduleTools = [
228
268
  position: input.position,
229
269
  indent: input.indent,
230
270
  published: input.published,
231
- module_id: input.new_module_id
271
+ module_id: input.new_module_id,
272
+ ...(input.completion_requirement_type && {
273
+ completion_requirement: { type: input.completion_requirement_type }
274
+ })
232
275
  });
233
276
  return {
234
277
  content: [{ type: "text", text: JSON.stringify(result, null, 2) }],