@charlie.act7/canvas-mcp-server 1.1.8 → 1.2.1

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.
@@ -1,15 +1,17 @@
1
1
  import axios from 'axios';
2
2
  export class CanvasClient {
3
3
  client;
4
+ quizClient;
4
5
  token;
5
6
  domain;
6
7
  constructor(token, domain) {
7
8
  this.token = token;
8
9
  this.domain = domain;
9
10
  this.client = this.createAxiosInstance(token, domain);
11
+ this.quizClient = this.createAxiosInstance(token, domain, 'api/quiz/v1');
10
12
  }
11
- createAxiosInstance(token, domain) {
12
- const baseURL = `https://${domain}/api/v1`;
13
+ createAxiosInstance(token, domain, apiPath = 'api/v1') {
14
+ const baseURL = `https://${domain}/${apiPath}`;
13
15
  return axios.create({
14
16
  baseURL,
15
17
  headers: {
@@ -22,6 +24,7 @@ export class CanvasClient {
22
24
  this.token = token;
23
25
  this.domain = domain;
24
26
  this.client = this.createAxiosInstance(token, domain);
27
+ this.quizClient = this.createAxiosInstance(token, domain, 'api/quiz/v1');
25
28
  }
26
29
  parseLinkHeader(header) {
27
30
  if (!header)
@@ -189,9 +192,6 @@ export class CanvasClient {
189
192
  await this.client.delete(url);
190
193
  return { deleted: true, comment_id: commentId };
191
194
  }
192
- async getSubmissionComments(courseId, assignmentId, userId) {
193
- return this.getSingleSubmission(courseId, assignmentId, userId);
194
- }
195
195
  async getDiscussionTopics(courseId) {
196
196
  return this.getAllPages(`courses/${courseId}/discussion_topics`);
197
197
  }
@@ -213,6 +213,11 @@ export class CanvasClient {
213
213
  });
214
214
  return response.data;
215
215
  }
216
+ async updateAnnouncement(courseId, topicId, fields) {
217
+ const url = `courses/${courseId}/discussion_topics/${topicId}`;
218
+ const response = await this.client.put(url, fields);
219
+ return response.data;
220
+ }
216
221
  // --- Quiz Questions ---
217
222
  async listQuizQuestions(courseId, quizId) {
218
223
  return this.getAllPages(`courses/${courseId}/quizzes/${quizId}/questions`, {
@@ -268,9 +273,38 @@ export class CanvasClient {
268
273
  return response.data;
269
274
  }
270
275
  async updateModule(courseId, moduleId, data) {
271
- const response = await this.client.put(`courses/${courseId}/modules/${moduleId}`, {
272
- module: data
273
- });
276
+ // Canvas only accepts arrays (prerequisite_module_ids, completion_requirements)
277
+ // via form-encoding, not JSON.
278
+ const hasArrays = data.prerequisite_module_ids !== undefined || data.completion_requirements !== undefined;
279
+ if (hasArrays) {
280
+ const params = new URLSearchParams();
281
+ if (data.name !== undefined)
282
+ params.set('module[name]', data.name);
283
+ if (data.published !== undefined)
284
+ params.set('module[published]', String(data.published));
285
+ if (data.position !== undefined)
286
+ params.set('module[position]', String(data.position));
287
+ if (data.require_sequential_progress !== undefined) {
288
+ params.set('module[require_sequential_progress]', String(data.require_sequential_progress));
289
+ }
290
+ if (data.prerequisite_module_ids) {
291
+ for (const id of data.prerequisite_module_ids) {
292
+ params.append('module[prerequisite_module_ids][]', String(id));
293
+ }
294
+ }
295
+ if (data.completion_requirements) {
296
+ for (const req of data.completion_requirements) {
297
+ params.append('module[completion_requirements][][id]', String(req.id));
298
+ params.append('module[completion_requirements][][type]', req.type);
299
+ if (req.min_score !== undefined) {
300
+ params.append('module[completion_requirements][][min_score]', String(req.min_score));
301
+ }
302
+ }
303
+ }
304
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}`, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
305
+ return response.data;
306
+ }
307
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}`, { module: data });
274
308
  return response.data;
275
309
  }
276
310
  async deleteModule(courseId, moduleId) {
@@ -285,9 +319,20 @@ export class CanvasClient {
285
319
  return response.data;
286
320
  }
287
321
  async updateModuleItem(courseId, moduleId, itemId, data) {
288
- const response = await this.client.put(`courses/${courseId}/modules/${moduleId}/items/${itemId}`, {
289
- module_item: data
290
- });
322
+ // Canvas only accepts completion_requirement via form-encoding, not JSON.
323
+ // Use URLSearchParams when completion_requirement is present.
324
+ if (data.completion_requirement) {
325
+ const params = new URLSearchParams();
326
+ params.set('module_item[completion_requirement][type]', data.completion_requirement.type);
327
+ const { completion_requirement, ...rest } = data;
328
+ for (const [key, value] of Object.entries(rest)) {
329
+ if (value !== undefined)
330
+ params.set(`module_item[${key}]`, String(value));
331
+ }
332
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}/items/${itemId}`, params, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
333
+ return response.data;
334
+ }
335
+ const response = await this.client.put(`courses/${courseId}/modules/${moduleId}/items/${itemId}`, { module_item: data });
291
336
  return response.data;
292
337
  }
293
338
  async deleteModuleItem(courseId, moduleId, itemId) {
@@ -328,6 +373,39 @@ export class CanvasClient {
328
373
  }
329
374
  return uploadResponse.data;
330
375
  }
376
+ async getQuizSubmissions(courseId, quizId) {
377
+ const response = await this.client.get(`courses/${courseId}/quizzes/${quizId}/submissions`, {
378
+ params: { include: ['user'], per_page: 100 }
379
+ });
380
+ return response.data.quiz_submissions ?? [];
381
+ }
382
+ async deleteQuiz(courseId, quizId) {
383
+ await this.client.delete(`courses/${courseId}/quizzes/${quizId}`);
384
+ return { deleted: true };
385
+ }
386
+ async checkQuizPending(courseId, quizId) {
387
+ const [students, submissionsResp] = await Promise.all([
388
+ this.getEnrollments(courseId),
389
+ this.client.get(`courses/${courseId}/quizzes/${quizId}/submissions`, {
390
+ params: { per_page: 100 }
391
+ })
392
+ ]);
393
+ const realStudents = students.filter(u => u.name !== 'Estudiante de prueba' && u.sis_user_id !== null);
394
+ const submittedIds = new Set((submissionsResp.data.quiz_submissions ?? []).map((s) => s.user_id));
395
+ const pending = realStudents
396
+ .filter(u => !submittedIds.has(u.id))
397
+ .map(u => ({
398
+ id: u.id,
399
+ name: u.name ?? 'Desconocido',
400
+ email: u.login_id ?? ''
401
+ }));
402
+ return {
403
+ quiz_id: quizId,
404
+ total_students: realStudents.length,
405
+ submitted: submittedIds.size,
406
+ pending
407
+ };
408
+ }
331
409
  async createQuiz(courseId, quiz) {
332
410
  const response = await this.client.post(`courses/${courseId}/quizzes`, {
333
411
  quiz: quiz
@@ -378,6 +456,15 @@ export class CanvasClient {
378
456
  return { deleted: true };
379
457
  }
380
458
  // --- Rubrics ---
459
+ async getRubrics(courseId) {
460
+ return this.getAllPages(`courses/${courseId}/rubrics`);
461
+ }
462
+ async getRubric(courseId, rubricId, include) {
463
+ const response = await this.client.get(`courses/${courseId}/rubrics/${rubricId}`, {
464
+ params: { include }
465
+ });
466
+ return response.data;
467
+ }
381
468
  async createRubric(courseId, rubricData, rubricAssociationData) {
382
469
  const payload = { rubric: rubricData };
383
470
  if (rubricAssociationData) {
@@ -386,6 +473,14 @@ export class CanvasClient {
386
473
  const response = await this.client.post(`courses/${courseId}/rubrics`, payload);
387
474
  return response.data;
388
475
  }
476
+ async updateRubric(courseId, rubricId, rubricData, rubricAssociationData) {
477
+ const payload = { rubric: rubricData };
478
+ if (rubricAssociationData) {
479
+ payload.rubric_association = rubricAssociationData;
480
+ }
481
+ const response = await this.client.put(`courses/${courseId}/rubrics/${rubricId}`, payload);
482
+ return response.data;
483
+ }
389
484
  async createRubricAssociation(courseId, associationData) {
390
485
  const response = await this.client.post(`courses/${courseId}/rubric_associations`, {
391
486
  rubric_association: associationData
@@ -456,4 +551,225 @@ export class CanvasClient {
456
551
  const response = await this.client.post(`groups/${groupId}/memberships`, { user_id: userId });
457
552
  return response.data;
458
553
  }
554
+ // --- Course Management ---
555
+ async createCourse(accountId, data) {
556
+ const response = await this.client.post(`accounts/${accountId}/courses`, { course: data });
557
+ return response.data;
558
+ }
559
+ async updateCourse(courseId, data) {
560
+ const response = await this.client.put(`courses/${courseId}`, { course: data });
561
+ return response.data;
562
+ }
563
+ async getSyllabus(courseId) {
564
+ const response = await this.client.get(`courses/${courseId}`, { params: { include: ['syllabus_body'] } });
565
+ return response.data;
566
+ }
567
+ async healthCheck() {
568
+ const response = await this.client.get('users/self/profile');
569
+ return { status: 'ok', user: response.data.name, domain: this.domain };
570
+ }
571
+ // --- Assignment ---
572
+ async deleteAssignment(courseId, assignmentId) {
573
+ await this.client.delete(`courses/${courseId}/assignments/${assignmentId}`);
574
+ return { deleted: true };
575
+ }
576
+ // --- Pages ---
577
+ async deletePage(courseId, pageUrlOrId) {
578
+ await this.client.delete(`courses/${courseId}/pages/${pageUrlOrId}`);
579
+ return { deleted: true };
580
+ }
581
+ // --- Discussions ---
582
+ async createDiscussion(courseId, data) {
583
+ const response = await this.client.post(`courses/${courseId}/discussion_topics`, data);
584
+ return response.data;
585
+ }
586
+ async deleteDiscussion(courseId, topicId) {
587
+ await this.client.delete(`courses/${courseId}/discussion_topics/${topicId}`);
588
+ return { deleted: true };
589
+ }
590
+ // --- Users & Enrollments ---
591
+ async getProfile() {
592
+ const response = await this.client.get('users/self/profile');
593
+ return response.data;
594
+ }
595
+ async getUser(userId) {
596
+ const response = await this.client.get(`users/${userId}`);
597
+ return response.data;
598
+ }
599
+ async searchUsers(accountId, searchTerm, enrollmentType) {
600
+ return this.getAllPages(`accounts/${accountId}/users`, {
601
+ search_term: searchTerm,
602
+ enrollment_type: enrollmentType,
603
+ per_page: 50
604
+ });
605
+ }
606
+ async listCourseEnrollments(courseId, type) {
607
+ return this.getAllPages(`courses/${courseId}/enrollments`, {
608
+ type,
609
+ include: ['user'],
610
+ per_page: 100
611
+ });
612
+ }
613
+ async enrollUser(courseId, userId, enrollmentType, notify) {
614
+ const response = await this.client.post(`courses/${courseId}/enrollments`, {
615
+ enrollment: {
616
+ user_id: userId,
617
+ type: enrollmentType,
618
+ enrollment_state: 'active',
619
+ notify: notify ?? false
620
+ }
621
+ });
622
+ return response.data;
623
+ }
624
+ async removeEnrollment(courseId, enrollmentId, task = 'conclude') {
625
+ const response = await this.client.delete(`courses/${courseId}/enrollments/${enrollmentId}`, { params: { task } });
626
+ return response.data;
627
+ }
628
+ // --- Student Submissions ---
629
+ async submitAssignmentFile(courseId, assignmentId, filePath, fileName, contentType) {
630
+ // Step 1: Pre-flight to get submission upload URL
631
+ const preflightResponse = await this.client.post(`courses/${courseId}/assignments/${assignmentId}/submissions/self/files`, { name: fileName, content_type: contentType });
632
+ const { upload_url, upload_params } = preflightResponse.data;
633
+ // Step 2: Upload file to storage
634
+ const fs = await import('node:fs');
635
+ const FormData = (await import('form-data')).default;
636
+ const form = new FormData();
637
+ Object.entries(upload_params).forEach(([key, value]) => {
638
+ form.append(key, value);
639
+ });
640
+ form.append('file', fs.createReadStream(filePath));
641
+ const uploadResponse = await axios.post(upload_url, form, {
642
+ headers: form.getHeaders(),
643
+ maxRedirects: 0,
644
+ validateStatus: (s) => s < 400
645
+ });
646
+ // Step 3: Confirm upload and get file ID
647
+ let fileId;
648
+ const location = uploadResponse.headers['location'] || uploadResponse.data?.location;
649
+ if (location) {
650
+ const confirmResponse = await this.client.get(location);
651
+ fileId = confirmResponse.data.id;
652
+ }
653
+ else {
654
+ fileId = uploadResponse.data.id;
655
+ }
656
+ // Step 4: Submit the assignment with the uploaded file
657
+ const submissionResponse = await this.client.post(`courses/${courseId}/assignments/${assignmentId}/submissions`, {
658
+ submission: {
659
+ submission_type: 'online_upload',
660
+ file_ids: [fileId]
661
+ }
662
+ });
663
+ return submissionResponse.data;
664
+ }
665
+ // --- Conversations ---
666
+ async listConversations(scope, perPage = 50) {
667
+ return this.getAllPages('conversations', {
668
+ scope: scope ?? 'inbox',
669
+ per_page: perPage
670
+ });
671
+ }
672
+ async getConversation(conversationId) {
673
+ const response = await this.client.get(`conversations/${conversationId}`);
674
+ return response.data;
675
+ }
676
+ async getConversationUnreadCount() {
677
+ const response = await this.client.get('conversations/unread_count');
678
+ return response.data;
679
+ }
680
+ async sendConversation(data) {
681
+ const payload = {
682
+ recipients: data.recipients,
683
+ subject: data.subject,
684
+ body: data.body,
685
+ group_conversation: data.group_conversation ?? false,
686
+ bulk_message: data.bulk_message ?? false
687
+ };
688
+ if (data.course_id) {
689
+ payload.context_code = `course_${data.course_id}`;
690
+ }
691
+ const response = await this.client.post('conversations', payload);
692
+ return response.data;
693
+ }
694
+ async replyToConversation(conversationId, body) {
695
+ const response = await this.client.post(`conversations/${conversationId}/add_message`, { body });
696
+ return response.data;
697
+ }
698
+ // --- Analytics ---
699
+ async getCourseAnalytics(courseId) {
700
+ const response = await this.client.get(`courses/${courseId}/analytics/activity`);
701
+ return response.data;
702
+ }
703
+ async getStudentAnalytics(courseId, studentId) {
704
+ const response = await this.client.get(`courses/${courseId}/analytics/users/${studentId}/activity`);
705
+ return response.data;
706
+ }
707
+ async getCourseActivityStream(courseId) {
708
+ const response = await this.client.get(`courses/${courseId}/activity_stream/summary`);
709
+ return response.data;
710
+ }
711
+ async searchCourseContent(courseId, query, contentTypes) {
712
+ const types = contentTypes && contentTypes.length > 0 ? contentTypes : ['assignments', 'pages', 'discussion_topics', 'quizzes', 'files'];
713
+ const results = [];
714
+ for (const type of types) {
715
+ try {
716
+ const items = await this.getAllPages(`courses/${courseId}/${type}`, { per_page: 50 });
717
+ const q = query.toLowerCase();
718
+ const matched = items.filter((i) => (i.name || i.title || '').toLowerCase().includes(q)).map((i) => ({ ...i, _content_type: type }));
719
+ results.push(...matched);
720
+ }
721
+ catch {
722
+ // skip types the user doesn't have access to
723
+ }
724
+ }
725
+ return results;
726
+ }
727
+ // --- Peer Reviews ---
728
+ async listPeerReviews(courseId, assignmentId) {
729
+ return this.getAllPages(`courses/${courseId}/assignments/${assignmentId}/peer_reviews`, { include: ['submission_comments', 'user'], per_page: 100 });
730
+ }
731
+ async getSubmissionPeerReviews(courseId, assignmentId, submissionId) {
732
+ return this.getAllPages(`courses/${courseId}/assignments/${assignmentId}/submissions/${submissionId}/peer_reviews`, { include: ['submission_comments', 'user'], per_page: 100 });
733
+ }
734
+ async createPeerReview(courseId, assignmentId, submissionId, revieweeId) {
735
+ const response = await this.client.post(`courses/${courseId}/assignments/${assignmentId}/submissions/${submissionId}/peer_reviews`, { user_id: revieweeId });
736
+ return response.data;
737
+ }
738
+ async deletePeerReview(courseId, assignmentId, submissionId, revieweeId) {
739
+ await this.client.delete(`courses/${courseId}/assignments/${assignmentId}/submissions/${submissionId}/peer_reviews`, { params: { user_id: revieweeId } });
740
+ return { deleted: true };
741
+ }
742
+ // --- New Quizzes (LTI) ---
743
+ async createNewQuiz(courseId, data) {
744
+ const response = await this.quizClient.post(`courses/${courseId}/quizzes`, data);
745
+ return response.data;
746
+ }
747
+ async updateNewQuiz(courseId, quizId, data) {
748
+ const response = await this.quizClient.patch(`courses/${courseId}/quizzes/${quizId}`, data);
749
+ return response.data;
750
+ }
751
+ async deleteNewQuiz(courseId, quizId) {
752
+ await this.quizClient.delete(`courses/${courseId}/quizzes/${quizId}`);
753
+ return { deleted: true };
754
+ }
755
+ async listNewQuizItems(courseId, quizId) {
756
+ const response = await this.quizClient.get(`courses/${courseId}/quizzes/${quizId}/items`);
757
+ return response.data.items ?? response.data ?? [];
758
+ }
759
+ async getNewQuizItem(courseId, quizId, itemId) {
760
+ const response = await this.quizClient.get(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`);
761
+ return response.data;
762
+ }
763
+ async createNewQuizItem(courseId, quizId, data) {
764
+ const response = await this.quizClient.post(`courses/${courseId}/quizzes/${quizId}/items`, data);
765
+ return response.data;
766
+ }
767
+ async updateNewQuizItem(courseId, quizId, itemId, data) {
768
+ const response = await this.quizClient.patch(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`, data);
769
+ return response.data;
770
+ }
771
+ async deleteNewQuizItem(courseId, quizId, itemId) {
772
+ await this.quizClient.delete(`courses/${courseId}/quizzes/${quizId}/items/${itemId}`);
773
+ return { deleted: true };
774
+ }
459
775
  }
@@ -0,0 +1,124 @@
1
+ import { GoogleGenAI } from "@google/genai";
2
+ const DEFAULT_MODEL = "gemini-2.5-flash";
3
+ // Gemini no acepta: $schema, additionalProperties, anyOf multi-tipo, nullable
4
+ function sanitizeSchema(schema) {
5
+ const { $schema, additionalProperties, nullable, ...clean } = schema;
6
+ // anyOf con un solo elemento → aplanar
7
+ if (Array.isArray(clean.anyOf) && clean.anyOf.length === 1) {
8
+ return sanitizeSchema(clean.anyOf[0]);
9
+ }
10
+ // anyOf con múltiples tipos (ej: number | string | null) → convertir a string
11
+ if (Array.isArray(clean.anyOf)) {
12
+ return { type: "string", description: clean.description ?? "" };
13
+ }
14
+ // type: ["string", "null"] → type: "string"
15
+ if (Array.isArray(clean.type)) {
16
+ clean.type = clean.type.find((t) => t !== "null") ?? "string";
17
+ }
18
+ if (clean.properties) {
19
+ const cleanProps = {};
20
+ for (const [k, v] of Object.entries(clean.properties)) {
21
+ cleanProps[k] = sanitizeSchema(v);
22
+ }
23
+ clean.properties = cleanProps;
24
+ }
25
+ if (clean.items) {
26
+ clean.items = sanitizeSchema(clean.items);
27
+ }
28
+ return clean;
29
+ }
30
+ function toolsToGemini(tools) {
31
+ const seen = new Set();
32
+ const unique = tools.filter(t => {
33
+ if (seen.has(t.tool.name))
34
+ return false;
35
+ seen.add(t.tool.name);
36
+ return true;
37
+ });
38
+ return [{
39
+ functionDeclarations: unique.map(t => ({
40
+ name: t.tool.name,
41
+ description: t.tool.description ?? "",
42
+ parameters: sanitizeSchema(t.tool.inputSchema)
43
+ }))
44
+ }];
45
+ }
46
+ export class GeminiRunner {
47
+ ai;
48
+ tools;
49
+ client;
50
+ constructor(apiKey, client, tools) {
51
+ this.ai = new GoogleGenAI({ apiKey });
52
+ this.client = client;
53
+ this.tools = tools;
54
+ }
55
+ async run(userMessage, options = {}) {
56
+ const model = options.model ?? DEFAULT_MODEL;
57
+ const geminiTools = toolsToGemini(this.tools);
58
+ const systemInstruction = options.systemPrompt ??
59
+ "Eres un asistente educativo con acceso a Canvas LMS. " +
60
+ "Usa las herramientas disponibles para responder sobre cursos, tareas, estudiantes y calificaciones. " +
61
+ "Responde SIEMPRE en español. Sé conciso y claro.";
62
+ const toolsUsed = [];
63
+ // Historial de conversación en formato Gemini
64
+ const contents = [
65
+ { role: "user", parts: [{ text: userMessage }] }
66
+ ];
67
+ // Agentic loop (máx. 10 iteraciones)
68
+ for (let i = 0; i < 10; i++) {
69
+ const response = await this.ai.models.generateContent({
70
+ model,
71
+ contents,
72
+ config: {
73
+ tools: geminiTools,
74
+ systemInstruction,
75
+ thinkingConfig: { thinkingBudget: 0 } // deshabilitar thinking para evitar respuestas vacías
76
+ }
77
+ });
78
+ const candidate = response.candidates?.[0];
79
+ if (!candidate)
80
+ throw new Error("Gemini no devolvió ningún candidato.");
81
+ const parts = candidate.content?.parts ?? [];
82
+ // Agregar respuesta del modelo al historial
83
+ contents.push({ role: "model", parts });
84
+ // Separar texto y function calls
85
+ const functionCalls = parts.filter((p) => p.functionCall);
86
+ const textParts = parts.filter((p) => p.text);
87
+ // Sin function calls → respuesta final
88
+ if (functionCalls.length === 0) {
89
+ const answer = textParts.map((p) => p.text).join("").trim();
90
+ return { answer, tools_used: toolsUsed, model, provider: "gemini" };
91
+ }
92
+ // Ejecutar cada function call
93
+ const functionResponses = [];
94
+ for (const part of functionCalls) {
95
+ const { name, args } = part.functionCall;
96
+ const toolDef = this.tools.find(t => t.name === name);
97
+ let resultText;
98
+ if (!toolDef) {
99
+ resultText = `Error: herramienta '${name}' no existe.`;
100
+ }
101
+ else {
102
+ try {
103
+ const result = await toolDef.handler(this.client, args ?? {});
104
+ const blocks = result?.content ?? [];
105
+ resultText = blocks
106
+ .filter(b => b.type === "text" && b.text)
107
+ .map(b => b.text)
108
+ .join("\n");
109
+ toolsUsed.push(name);
110
+ }
111
+ catch (err) {
112
+ resultText = `Error: ${err instanceof Error ? err.message : String(err)}`;
113
+ }
114
+ }
115
+ functionResponses.push({
116
+ functionResponse: { name, response: { result: resultText } }
117
+ });
118
+ }
119
+ // Devolver resultados al modelo
120
+ contents.push({ role: "user", parts: functionResponses });
121
+ }
122
+ throw new Error("El agente excedió el límite de iteraciones (10).");
123
+ }
124
+ }
@@ -0,0 +1,118 @@
1
+ import { resolveCourseId } from "../common/helpers.js";
2
+ import { z } from "zod";
3
+ export const analyticsTools = [
4
+ {
5
+ name: "canvas_get_course_analytics",
6
+ tool: {
7
+ name: "canvas_get_course_analytics",
8
+ description: "Get participation and activity analytics for a course (page views, participations per day)",
9
+ inputSchema: {
10
+ type: "object",
11
+ properties: {
12
+ course_id: {
13
+ anyOf: [{ type: "number" }, { type: "string" }],
14
+ description: "The ID or name of the course"
15
+ }
16
+ },
17
+ required: ["course_id"],
18
+ },
19
+ },
20
+ handler: async (client, args) => {
21
+ const input = z.object({ course_id: z.union([z.number(), z.string()]) }).parse(args);
22
+ const courseId = await resolveCourseId(client, input.course_id);
23
+ const result = await client.getCourseAnalytics(courseId);
24
+ return {
25
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
26
+ };
27
+ }
28
+ },
29
+ {
30
+ name: "canvas_get_student_analytics",
31
+ tool: {
32
+ name: "canvas_get_student_analytics",
33
+ description: "Get activity analytics for a specific student in a course (page views, participations, tardiness breakdown)",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ course_id: {
38
+ anyOf: [{ type: "number" }, { type: "string" }],
39
+ description: "The ID or name of the course"
40
+ },
41
+ student_id: { type: "number", description: "The Canvas user ID of the student" }
42
+ },
43
+ required: ["course_id", "student_id"],
44
+ },
45
+ },
46
+ handler: async (client, args) => {
47
+ const input = z.object({
48
+ course_id: z.union([z.number(), z.string()]),
49
+ student_id: z.coerce.number()
50
+ }).parse(args);
51
+ const courseId = await resolveCourseId(client, input.course_id);
52
+ const result = await client.getStudentAnalytics(courseId, input.student_id);
53
+ return {
54
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
55
+ };
56
+ }
57
+ },
58
+ {
59
+ name: "canvas_get_course_activity_stream",
60
+ tool: {
61
+ name: "canvas_get_course_activity_stream",
62
+ description: "Get a summary of recent activity in a course (submissions, discussions, announcements, etc.)",
63
+ inputSchema: {
64
+ type: "object",
65
+ properties: {
66
+ course_id: {
67
+ anyOf: [{ type: "number" }, { type: "string" }],
68
+ description: "The ID or name of the course"
69
+ }
70
+ },
71
+ required: ["course_id"],
72
+ },
73
+ },
74
+ handler: async (client, args) => {
75
+ const input = z.object({ course_id: z.union([z.number(), z.string()]) }).parse(args);
76
+ const courseId = await resolveCourseId(client, input.course_id);
77
+ const result = await client.getCourseActivityStream(courseId);
78
+ return {
79
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
80
+ };
81
+ }
82
+ },
83
+ {
84
+ name: "canvas_search_course_content",
85
+ tool: {
86
+ name: "canvas_search_course_content",
87
+ description: "Search for content across a course by keyword (assignments, pages, discussions, quizzes, files)",
88
+ inputSchema: {
89
+ type: "object",
90
+ properties: {
91
+ course_id: {
92
+ anyOf: [{ type: "number" }, { type: "string" }],
93
+ description: "The ID or name of the course"
94
+ },
95
+ query: { type: "string", description: "Search keyword" },
96
+ content_types: {
97
+ type: "array",
98
+ items: { type: "string" },
99
+ description: "Limit search to specific types: assignments, pages, discussion_topics, quizzes, files. Defaults to all."
100
+ }
101
+ },
102
+ required: ["course_id", "query"],
103
+ },
104
+ },
105
+ handler: async (client, args) => {
106
+ const input = z.object({
107
+ course_id: z.union([z.number(), z.string()]),
108
+ query: z.string().min(1),
109
+ content_types: z.array(z.string()).optional()
110
+ }).parse(args);
111
+ const courseId = await resolveCourseId(client, input.course_id);
112
+ const result = await client.searchCourseContent(courseId, input.query, input.content_types);
113
+ return {
114
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
115
+ };
116
+ }
117
+ }
118
+ ];