@charlie.act7/canvas-mcp-server 1.1.3 → 1.2.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.
@@ -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
+ ];
@@ -214,7 +214,7 @@ export const assignmentTools = [
214
214
  student_id: z.coerce.number()
215
215
  }).parse(args);
216
216
  const courseId = await resolveCourseId(client, input.course_id);
217
- const submission = await client.getSubmissionComments(courseId, input.assignment_id, input.student_id);
217
+ const submission = await client.getSingleSubmission(courseId, input.assignment_id, input.student_id);
218
218
  return {
219
219
  content: [{ type: "text", text: JSON.stringify(submission.submission_comments || [], null, 2) }],
220
220
  };
@@ -518,6 +518,82 @@ export const assignmentTools = [
518
518
  };
519
519
  }
520
520
  },
521
+ {
522
+ name: "canvas_submit_assignment",
523
+ tool: {
524
+ name: "canvas_submit_assignment",
525
+ description: "Submit a file upload assignment as a student. Uploads a local file and creates the submission in one step.",
526
+ inputSchema: {
527
+ type: "object",
528
+ properties: {
529
+ course_id: {
530
+ anyOf: [{ type: "number" }, { type: "string" }],
531
+ description: "The ID or name of the course"
532
+ },
533
+ assignment_id: {
534
+ type: "number",
535
+ description: "The ID of the assignment to submit"
536
+ },
537
+ file_path: {
538
+ type: "string",
539
+ description: "Absolute local path to the file to submit (e.g. C:/Users/name/file.pdf)"
540
+ },
541
+ file_name: {
542
+ type: "string",
543
+ description: "File name to use in Canvas (e.g. MySubmission.pdf)"
544
+ },
545
+ content_type: {
546
+ type: "string",
547
+ description: "MIME type of the file (e.g. application/pdf)"
548
+ }
549
+ },
550
+ required: ["course_id", "assignment_id", "file_path", "file_name", "content_type"]
551
+ }
552
+ },
553
+ handler: async (client, args) => {
554
+ const input = z.object({
555
+ course_id: z.union([z.number(), z.string()]),
556
+ assignment_id: z.coerce.number(),
557
+ file_path: z.string(),
558
+ file_name: z.string(),
559
+ content_type: z.string()
560
+ }).parse(args);
561
+ const courseId = await resolveCourseId(client, input.course_id);
562
+ const submission = await client.submitAssignmentFile(courseId, input.assignment_id, input.file_path, input.file_name, input.content_type);
563
+ return {
564
+ content: [{ type: "text", text: JSON.stringify(submission, null, 2) }]
565
+ };
566
+ }
567
+ },
568
+ {
569
+ name: "canvas_delete_assignment",
570
+ tool: {
571
+ name: "canvas_delete_assignment",
572
+ description: "Delete an assignment from a course",
573
+ inputSchema: {
574
+ type: "object",
575
+ properties: {
576
+ course_id: {
577
+ anyOf: [{ type: "number" }, { type: "string" }],
578
+ description: "The ID or name of the course"
579
+ },
580
+ assignment_id: { type: "number", description: "The ID of the assignment to delete" }
581
+ },
582
+ required: ["course_id", "assignment_id"],
583
+ },
584
+ },
585
+ handler: async (client, args) => {
586
+ const input = z.object({
587
+ course_id: z.union([z.number(), z.string()]),
588
+ assignment_id: z.coerce.number()
589
+ }).parse(args);
590
+ const courseId = await resolveCourseId(client, input.course_id);
591
+ const result = await client.deleteAssignment(courseId, input.assignment_id);
592
+ return {
593
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
594
+ };
595
+ }
596
+ },
521
597
  {
522
598
  name: "canvas_update_assignment",
523
599
  tool: {
@@ -100,6 +100,107 @@ export const communicationTools = [
100
100
  };
101
101
  }
102
102
  },
103
+ {
104
+ name: "canvas_update_announcement",
105
+ tool: {
106
+ name: "canvas_update_announcement",
107
+ description: "Update the title or message of an existing announcement in a course",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ course_id: { type: "number", description: "The ID of the course" },
112
+ topic_id: { type: "number", description: "The ID of the announcement (discussion topic)" },
113
+ title: { type: "string", description: "New title for the announcement (optional)" },
114
+ message: { type: "string", description: "New HTML message body for the announcement (optional)" },
115
+ },
116
+ required: ["course_id", "topic_id"],
117
+ },
118
+ },
119
+ handler: async (client, args) => {
120
+ const input = z.object({
121
+ course_id: z.coerce.number(),
122
+ topic_id: z.coerce.number(),
123
+ title: z.string().optional(),
124
+ message: z.string().optional(),
125
+ }).parse(args);
126
+ const fields = {};
127
+ if (input.title)
128
+ fields.title = input.title;
129
+ if (input.message)
130
+ fields.message = input.message;
131
+ const result = await client.updateAnnouncement(input.course_id, input.topic_id, fields);
132
+ return {
133
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
134
+ };
135
+ }
136
+ },
137
+ {
138
+ name: "canvas_create_discussion",
139
+ tool: {
140
+ name: "canvas_create_discussion",
141
+ description: "Create a new discussion topic in a course",
142
+ inputSchema: {
143
+ type: "object",
144
+ properties: {
145
+ course_id: { type: "number", description: "The ID of the course" },
146
+ title: { type: "string", description: "Title of the discussion" },
147
+ message: { type: "string", description: "Body/message of the discussion" },
148
+ discussion_type: {
149
+ type: "string",
150
+ enum: ["side_comment", "threaded"],
151
+ description: "Discussion type (default: side_comment)"
152
+ },
153
+ published: { type: "boolean", description: "Whether to publish immediately (default: true)" },
154
+ pinned: { type: "boolean", description: "Pin to top of discussion list" },
155
+ require_initial_post: { type: "boolean", description: "Students must post before seeing replies" },
156
+ allow_rating: { type: "boolean", description: "Allow students to rate posts" }
157
+ },
158
+ required: ["course_id", "title", "message"],
159
+ },
160
+ },
161
+ handler: async (client, args) => {
162
+ const input = z.object({
163
+ course_id: z.coerce.number(),
164
+ title: z.string(),
165
+ message: z.string(),
166
+ discussion_type: z.enum(["side_comment", "threaded"]).optional(),
167
+ published: z.boolean().optional(),
168
+ pinned: z.boolean().optional(),
169
+ require_initial_post: z.boolean().optional(),
170
+ allow_rating: z.boolean().optional()
171
+ }).parse(args);
172
+ const { course_id, ...data } = input;
173
+ const result = await client.createDiscussion(course_id, data);
174
+ return {
175
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
176
+ };
177
+ }
178
+ },
179
+ {
180
+ name: "canvas_delete_discussion",
181
+ tool: {
182
+ name: "canvas_delete_discussion",
183
+ description: "Delete a discussion topic from a course",
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ course_id: { type: "number", description: "The ID of the course" },
188
+ topic_id: { type: "number", description: "The ID of the discussion topic to delete" }
189
+ },
190
+ required: ["course_id", "topic_id"],
191
+ },
192
+ },
193
+ handler: async (client, args) => {
194
+ const input = z.object({
195
+ course_id: z.coerce.number(),
196
+ topic_id: z.coerce.number()
197
+ }).parse(args);
198
+ const result = await client.deleteDiscussion(input.course_id, input.topic_id);
199
+ return {
200
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
201
+ };
202
+ }
203
+ },
103
204
  {
104
205
  name: "canvas_post_discussion_reply",
105
206
  tool: {
@@ -0,0 +1,130 @@
1
+ import { z } from "zod";
2
+ export const conversationTools = [
3
+ {
4
+ name: "canvas_list_conversations",
5
+ tool: {
6
+ name: "canvas_list_conversations",
7
+ description: "List conversations (inbox messages) for the current user",
8
+ inputSchema: {
9
+ type: "object",
10
+ properties: {
11
+ scope: {
12
+ type: "string",
13
+ enum: ["inbox", "unread", "archived", "sent"],
14
+ description: "Which mailbox to list (default: inbox)"
15
+ }
16
+ },
17
+ },
18
+ },
19
+ handler: async (client, args) => {
20
+ const input = z.object({
21
+ scope: z.enum(["inbox", "unread", "archived", "sent"]).optional()
22
+ }).parse(args);
23
+ const result = await client.listConversations(input.scope);
24
+ return {
25
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
26
+ };
27
+ }
28
+ },
29
+ {
30
+ name: "canvas_get_conversation",
31
+ tool: {
32
+ name: "canvas_get_conversation",
33
+ description: "Get a full conversation thread including all messages",
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ conversation_id: { type: "number", description: "The ID of the conversation" }
38
+ },
39
+ required: ["conversation_id"],
40
+ },
41
+ },
42
+ handler: async (client, args) => {
43
+ const input = z.object({ conversation_id: z.coerce.number() }).parse(args);
44
+ const result = await client.getConversation(input.conversation_id);
45
+ return {
46
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
47
+ };
48
+ }
49
+ },
50
+ {
51
+ name: "canvas_get_conversation_unread_count",
52
+ tool: {
53
+ name: "canvas_get_conversation_unread_count",
54
+ description: "Get the number of unread messages in the inbox",
55
+ inputSchema: { type: "object", properties: {} },
56
+ },
57
+ handler: async (client, _args) => {
58
+ const result = await client.getConversationUnreadCount();
59
+ return {
60
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
61
+ };
62
+ }
63
+ },
64
+ {
65
+ name: "canvas_send_conversation",
66
+ tool: {
67
+ name: "canvas_send_conversation",
68
+ description: "Send a new message to one or more users (private message or course announcement)",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ recipients: {
73
+ type: "array",
74
+ items: { anyOf: [{ type: "number" }, { type: "string" }] },
75
+ description: "Array of user IDs or login IDs to send the message to"
76
+ },
77
+ subject: { type: "string", description: "Message subject" },
78
+ body: { type: "string", description: "Message body" },
79
+ course_id: {
80
+ type: "number",
81
+ description: "Associate message with a course context (optional but recommended)"
82
+ },
83
+ group_conversation: {
84
+ type: "boolean",
85
+ description: "If true, all recipients share one thread. If false, each gets an individual message (default: false)"
86
+ }
87
+ },
88
+ required: ["recipients", "subject", "body"],
89
+ },
90
+ },
91
+ handler: async (client, args) => {
92
+ const input = z.object({
93
+ recipients: z.array(z.union([z.coerce.number(), z.string()])),
94
+ subject: z.string(),
95
+ body: z.string(),
96
+ course_id: z.coerce.number().optional(),
97
+ group_conversation: z.boolean().optional()
98
+ }).parse(args);
99
+ const result = await client.sendConversation(input);
100
+ return {
101
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
102
+ };
103
+ }
104
+ },
105
+ {
106
+ name: "canvas_reply_to_conversation",
107
+ tool: {
108
+ name: "canvas_reply_to_conversation",
109
+ description: "Reply to an existing conversation thread",
110
+ inputSchema: {
111
+ type: "object",
112
+ properties: {
113
+ conversation_id: { type: "number", description: "The ID of the conversation to reply to" },
114
+ body: { type: "string", description: "The reply message text" }
115
+ },
116
+ required: ["conversation_id", "body"],
117
+ },
118
+ },
119
+ handler: async (client, args) => {
120
+ const input = z.object({
121
+ conversation_id: z.coerce.number(),
122
+ body: z.string()
123
+ }).parse(args);
124
+ const result = await client.replyToConversation(input.conversation_id, input.body);
125
+ return {
126
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }],
127
+ };
128
+ }
129
+ }
130
+ ];