@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.
- package/README.md +262 -113
- package/dist/index.js +29 -7
- package/dist/services/canvas-client.js +193 -7
- package/dist/tools/access-token-tools.js +183 -0
- package/dist/tools/module-tools.js +50 -7
- package/dist/tools/question-bank-tools.js +238 -0
- package/dist/tools/quiz-tools.js +84 -12
- package/package.json +76 -70
- package/.claude-plugin/plugin.json +0 -11
- package/.mcp.json +0 -12
|
@@ -4,11 +4,22 @@ export class CanvasClient {
|
|
|
4
4
|
quizClient;
|
|
5
5
|
token;
|
|
6
6
|
domain;
|
|
7
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
294
|
-
|
|
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) }],
|