brightspace-d2l-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +269 -0
  3. package/build/api/cache.js +49 -0
  4. package/build/api/client.js +345 -0
  5. package/build/api/errors.js +48 -0
  6. package/build/api/index.js +12 -0
  7. package/build/api/paginate.js +70 -0
  8. package/build/api/rate-limiter.js +52 -0
  9. package/build/api/retry.js +39 -0
  10. package/build/api/types.js +11 -0
  11. package/build/api/version-discovery.js +43 -0
  12. package/build/auth/authenticator.js +146 -0
  13. package/build/auth/browser.js +82 -0
  14. package/build/auth/index.js +6 -0
  15. package/build/auth/login.js +227 -0
  16. package/build/auth/session-store.js +46 -0
  17. package/build/auth/token-manager.js +150 -0
  18. package/build/auth/token-mint.js +75 -0
  19. package/build/cli/clients.js +344 -0
  20. package/build/cli/main.js +162 -0
  21. package/build/cli/school.js +74 -0
  22. package/build/cli/setup.js +189 -0
  23. package/build/cli/ui.js +39 -0
  24. package/build/index.js +13 -0
  25. package/build/server.js +175 -0
  26. package/build/tools/download-file.js +167 -0
  27. package/build/tools/get-announcements.js +116 -0
  28. package/build/tools/get-assignment-files.js +159 -0
  29. package/build/tools/get-assignments.js +343 -0
  30. package/build/tools/get-classlist-emails.js +38 -0
  31. package/build/tools/get-course-content.js +175 -0
  32. package/build/tools/get-discussions.js +192 -0
  33. package/build/tools/get-my-courses.js +42 -0
  34. package/build/tools/get-my-grades.js +93 -0
  35. package/build/tools/get-roster.js +99 -0
  36. package/build/tools/get-syllabus.js +154 -0
  37. package/build/tools/get-todo.js +61 -0
  38. package/build/tools/get-upcoming-due-dates.js +138 -0
  39. package/build/tools/index.js +18 -0
  40. package/build/tools/schemas.js +91 -0
  41. package/build/tools/submission-status.js +71 -0
  42. package/build/tools/tool-helpers.js +62 -0
  43. package/build/types/index.js +2 -0
  44. package/build/utils/atomic-write.js +66 -0
  45. package/build/utils/config.js +60 -0
  46. package/build/utils/course-filter.js +24 -0
  47. package/build/utils/deep-links.js +13 -0
  48. package/build/utils/download-errors.js +14 -0
  49. package/build/utils/download-helpers.js +73 -0
  50. package/build/utils/errors.js +31 -0
  51. package/build/utils/file-validator.js +121 -0
  52. package/build/utils/html-converter.js +20 -0
  53. package/build/utils/logger.js +69 -0
  54. package/build/utils/paths.js +15 -0
  55. package/build/utils/pdf-extractor.js +18 -0
  56. package/build/utils/version.js +18 -0
  57. package/build/utils/zip-extract.js +139 -0
  58. package/package.json +104 -0
@@ -0,0 +1,116 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetAnnouncementsSchema, } from "./schemas.js";
3
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
4
+ import { log } from "../utils/logger.js";
5
+ import { applyCourseFilter } from "../utils/course-filter.js";
6
+ function readableDate(raw) {
7
+ if (typeof raw !== "string")
8
+ return null;
9
+ return Number.isNaN(new Date(raw).getTime()) ? null : raw;
10
+ }
11
+ export function effectiveDate(item) {
12
+ return readableDate(item.StartDate) ?? readableDate(item.CreatedDate);
13
+ }
14
+ export function isPublishedNewsItem(item) {
15
+ return item.IsPublished !== false;
16
+ }
17
+ export function newestFirst(a, b) {
18
+ if (a.date === b.date)
19
+ return 0;
20
+ if (a.date === null)
21
+ return 1;
22
+ if (b.date === null)
23
+ return -1;
24
+ return new Date(b.date).getTime() - new Date(a.date).getTime();
25
+ }
26
+ export function mapNewsItem(item) {
27
+ return {
28
+ id: item.Id,
29
+ title: item.Title,
30
+ body: item.Body?.Text ?? "",
31
+ createdBy: item.CreatedBy?.DisplayName ?? "Unknown",
32
+ createdDate: item.CreatedDate,
33
+ startDate: item.StartDate,
34
+ date: effectiveDate(item),
35
+ isPinned: item.IsPinned,
36
+ };
37
+ }
38
+ export function registerGetAnnouncements(server, apiClient, config) {
39
+ server.registerTool("get_announcements", {
40
+ title: "Get Announcements",
41
+ description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said.",
42
+ inputSchema: GetAnnouncementsSchema,
43
+ }, async (args) => {
44
+ try {
45
+ log("DEBUG", "get_announcements tool called", { args });
46
+ // Parse and validate input
47
+ const { courseId, count } = GetAnnouncementsSchema.parse(args);
48
+ // Single course case
49
+ if (courseId) {
50
+ const path = apiClient.le(courseId, "/news/");
51
+ const newsItems = await apiClient.get(path, {
52
+ ttl: DEFAULT_CACHE_TTLS.announcements,
53
+ });
54
+ // Drop drafts, then map to clean objects
55
+ const announcements = newsItems
56
+ .filter(isPublishedNewsItem)
57
+ .map(mapNewsItem)
58
+ .sort(newestFirst)
59
+ .slice(0, count);
60
+ log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
61
+ return toolResponse(announcements);
62
+ }
63
+ // All courses case
64
+ // First, fetch enrolled courses
65
+ const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
66
+ const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
67
+ // Apply course filter
68
+ const filteredEnrollments = applyCourseFilter(enrollmentResponse.Items.map(item => ({
69
+ id: item.OrgUnit.Id,
70
+ name: item.OrgUnit.Name,
71
+ code: item.OrgUnit.Code,
72
+ isActive: item.Access.IsActive,
73
+ canAccess: item.Access.CanAccess,
74
+ ...item,
75
+ })), config.courseFilter);
76
+ // Fetch announcements for each course (handle 403s gracefully)
77
+ const announcementPromises = filteredEnrollments.map(async (item) => {
78
+ try {
79
+ const path = apiClient.le(item.OrgUnit.Id, "/news/");
80
+ const newsItems = await apiClient.get(path, {
81
+ ttl: DEFAULT_CACHE_TTLS.announcements,
82
+ });
83
+ return newsItems
84
+ .filter(isPublishedNewsItem)
85
+ .map((newsItem) => ({
86
+ ...mapNewsItem(newsItem),
87
+ courseId: item.OrgUnit.Id,
88
+ courseName: item.OrgUnit.Name,
89
+ }));
90
+ }
91
+ catch (error) {
92
+ // 403 means no access (past course, etc) - log and skip
93
+ if (error?.status === 403) {
94
+ log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
95
+ return [];
96
+ }
97
+ throw error; // Re-throw other errors
98
+ }
99
+ });
100
+ const results = await Promise.allSettled(announcementPromises);
101
+ const allAnnouncements = results
102
+ .filter((r) => r.status === "fulfilled")
103
+ .flatMap((r) => r.value);
104
+ // Sort by the scheduled date and slice to count
105
+ const announcements = allAnnouncements
106
+ .sort(newestFirst)
107
+ .slice(0, count);
108
+ log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total across ${enrollmentResponse.Items.length} courses)`);
109
+ return toolResponse(announcements);
110
+ }
111
+ catch (error) {
112
+ return sanitizeError(error);
113
+ }
114
+ });
115
+ }
116
+ //# sourceMappingURL=get-announcements.js.map
@@ -0,0 +1,159 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetAssignmentFilesSchema } from "./schemas.js";
3
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
4
+ import { extractPdfText } from "../utils/pdf-extractor.js";
5
+ import { officeDocumentText } from "../utils/zip-extract.js";
6
+ import { assignmentUrl } from "../utils/deep-links.js";
7
+ import { log } from "../utils/logger.js";
8
+ function unwrapList(raw) {
9
+ return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
10
+ }
11
+ const KIND_BY_EXTENSION = {
12
+ pdf: "pdf",
13
+ docx: "docx",
14
+ doc: "other",
15
+ xlsx: "xlsx",
16
+ xls: "other",
17
+ pptx: "pptx",
18
+ ppt: "other",
19
+ png: "image",
20
+ jpg: "image",
21
+ jpeg: "image",
22
+ gif: "image",
23
+ webp: "image",
24
+ txt: "text",
25
+ md: "text",
26
+ csv: "text",
27
+ json: "text",
28
+ };
29
+ export function fileKind(fileName) {
30
+ const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
31
+ return KIND_BY_EXTENSION[extension] ?? "other";
32
+ }
33
+ function describeAttachment(attachment) {
34
+ return {
35
+ fileId: attachment.FileId,
36
+ fileName: attachment.FileName,
37
+ size: attachment.Size,
38
+ kind: fileKind(attachment.FileName),
39
+ };
40
+ }
41
+ async function listFolders(apiClient, courseId, folderId) {
42
+ const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), {
43
+ ttl: DEFAULT_CACHE_TTLS.assignments,
44
+ });
45
+ return unwrapList(raw)
46
+ .filter((folder) => folder.IsHidden !== true)
47
+ .filter((folder) => (folderId === undefined ? true : folder.Id === folderId));
48
+ }
49
+ async function readAttachment(apiClient, courseId, folderId, attachment, extract, maxChars) {
50
+ const base = describeAttachment(attachment);
51
+ if (!extract)
52
+ return { ...base, text: null, note: "Text extraction was not requested." };
53
+ const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${attachment.FileId}`));
54
+ const buffer = Buffer.from(await response.arrayBuffer());
55
+ let text = null;
56
+ let note;
57
+ switch (base.kind) {
58
+ case "pdf": {
59
+ const extracted = await extractPdfText(buffer);
60
+ text = extracted?.text?.trim() || null;
61
+ if (!text)
62
+ note = "No text layer in this PDF. It may be a scan.";
63
+ break;
64
+ }
65
+ case "docx":
66
+ case "xlsx":
67
+ case "pptx": {
68
+ text = officeDocumentText(buffer);
69
+ if (!text)
70
+ note = "No readable text found in this Office document.";
71
+ break;
72
+ }
73
+ case "text": {
74
+ text = buffer.toString("utf-8").trim() || null;
75
+ break;
76
+ }
77
+ default: {
78
+ note = `Cannot extract text from a ${base.kind} file. Use download_file to save it.`;
79
+ }
80
+ }
81
+ const truncated = text !== null && text.length > maxChars;
82
+ return {
83
+ ...base,
84
+ bytes: buffer.length,
85
+ text: truncated ? text.slice(0, maxChars) : text,
86
+ truncated,
87
+ ...(note ? { note } : {}),
88
+ };
89
+ }
90
+ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
91
+ server.registerTool("get_assignment_files", {
92
+ title: "Get Assignment Files",
93
+ description: "Read the files an instructor attached to an assignment: the spec or instructions PDF, a starter workbook, a rubric document. Call it with just courseId to see which assignments have attachments, then with folderId and fileId to read one. Use this when the user asks what an assignment requires, what the instructions say, or to summarize a handout. Returns the text itself. Use download_file instead when the user wants the file saved to disk.",
94
+ inputSchema: GetAssignmentFilesSchema,
95
+ }, async (args) => {
96
+ try {
97
+ log("DEBUG", "get_assignment_files tool called", { args });
98
+ const { courseId, folderId, fileId, extractText, maxChars } = GetAssignmentFilesSchema.parse(args);
99
+ const folders = await listFolders(apiClient, courseId, folderId);
100
+ if (folderId !== undefined && folders.length === 0) {
101
+ return toolResponse({
102
+ courseId,
103
+ folderId,
104
+ error: `No visible assignment with id ${folderId} in course ${courseId}.`,
105
+ });
106
+ }
107
+ // Read one file.
108
+ if (fileId !== undefined) {
109
+ if (folderId === undefined) {
110
+ return toolResponse({
111
+ courseId,
112
+ error: "folderId is required when fileId is given.",
113
+ });
114
+ }
115
+ const folder = folders[0];
116
+ const attachment = (folder.Attachments ?? []).find((a) => a.FileId === fileId);
117
+ if (!attachment) {
118
+ return toolResponse({
119
+ courseId,
120
+ folderId,
121
+ fileId,
122
+ error: `No attachment with id ${fileId} on assignment "${folder.Name}".`,
123
+ available: (folder.Attachments ?? []).map(describeAttachment),
124
+ });
125
+ }
126
+ const file = await readAttachment(apiClient, courseId, folderId, attachment, extractText, maxChars);
127
+ return toolResponse({
128
+ courseId,
129
+ folderId,
130
+ folderName: folder.Name,
131
+ url: baseUrl ? assignmentUrl(baseUrl, courseId, folderId) : null,
132
+ file,
133
+ });
134
+ }
135
+ // Discovery: which assignments have files, without downloading any.
136
+ const withFiles = folders
137
+ .filter((folder) => (folder.Attachments ?? []).length > 0)
138
+ .map((folder) => ({
139
+ folderId: folder.Id,
140
+ folderName: folder.Name,
141
+ dueDate: folder.DueDate,
142
+ url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
143
+ attachments: (folder.Attachments ?? []).map(describeAttachment),
144
+ }));
145
+ log("INFO", `get_assignment_files: ${withFiles.length} assignments with attachments in course ${courseId}`);
146
+ return toolResponse({
147
+ courseId,
148
+ assignments: withFiles,
149
+ ...(withFiles.length === 0
150
+ ? { note: "No assignment in this course has an attached file." }
151
+ : {}),
152
+ });
153
+ }
154
+ catch (error) {
155
+ return sanitizeError(error);
156
+ }
157
+ });
158
+ }
159
+ //# sourceMappingURL=get-assignment-files.js.map
@@ -0,0 +1,343 @@
1
+ import { fetchSubmissionStatus } from "./submission-status.js";
2
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
3
+ import { GetAssignmentsSchema } from "./schemas.js";
4
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
5
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
6
+ import { log } from "../utils/logger.js";
7
+ import { applyCourseFilter } from "../utils/course-filter.js";
8
+ import { assignmentUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
9
+ function richTextHtml(field) {
10
+ if (!field)
11
+ return null;
12
+ const nested = typeof field.Text === "object" && field.Text !== null ? field.Text.Html : null;
13
+ return nested ?? field.Html ?? null;
14
+ }
15
+ const STUDENT_SCORED = new Set([1, 2, 3, 4]);
16
+ function missingContentQuizTopics(raw, listedIds) {
17
+ if (!raw || typeof raw !== "object")
18
+ return [];
19
+ const found = new Map();
20
+ const visit = (modules) => {
21
+ if (!Array.isArray(modules))
22
+ return;
23
+ for (const module of modules) {
24
+ for (const topic of module.Topics ?? []) {
25
+ const isQuiz = topic.ActivityType === 4
26
+ || topic.TypeIdentifier?.toLowerCase() === "quiz"
27
+ || /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "");
28
+ if (!isQuiz || topic.IsHidden || topic.IsBroken || topic.IsExempt)
29
+ continue;
30
+ const activityId = topic.ToolItemId ?? Number(topic.ActivityId);
31
+ if (!Number.isSafeInteger(activityId) || activityId <= 0 || listedIds.has(activityId))
32
+ continue;
33
+ if (!found.has(activityId))
34
+ found.set(activityId, topic);
35
+ }
36
+ visit(module.Modules);
37
+ }
38
+ };
39
+ visit(raw.Modules);
40
+ return [...found.values()];
41
+ }
42
+ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
43
+ return Promise.all(missingContentQuizTopics(toc, listedIds).map(async (topic) => {
44
+ const quizId = topic.ToolItemId ?? Number(topic.ActivityId);
45
+ try {
46
+ return await apiClient.get(apiClient.le(courseId, `/quizzes/${quizId}`), {
47
+ ttl: DEFAULT_CACHE_TTLS.assignments,
48
+ });
49
+ }
50
+ catch (error) {
51
+ log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
52
+ }
53
+ let detail = topic;
54
+ try {
55
+ detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
56
+ }
57
+ catch (error) {
58
+ log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
59
+ }
60
+ return {
61
+ QuizId: quizId,
62
+ Name: detail.Title || topic.Title,
63
+ Description: detail.Description ?? null,
64
+ StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
65
+ EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
66
+ DueDate: detail.DueDate ?? null,
67
+ IsActive: true,
68
+ AttemptsAllowed: null,
69
+ ContentMetadataOnly: true,
70
+ };
71
+ }));
72
+ }
73
+ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
74
+ const assignments = [];
75
+ const [dropboxResult, quizResult, gradebookResult, contentResult] = await Promise.allSettled([
76
+ apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
77
+ apiClient.get(apiClient.le(courseId, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
78
+ apiClient.get(apiClient.le(courseId, "/grades/"), {
79
+ ttl: DEFAULT_CACHE_TTLS.assignments,
80
+ }),
81
+ apiClient.get(apiClient.le(courseId, "/content/toc"), {
82
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
83
+ }),
84
+ ]);
85
+ // Process Dropbox folders
86
+ if (dropboxResult.status === "fulfilled") {
87
+ // D2L dropbox endpoint may return paged { Objects: [...] } or flat array
88
+ const dropboxRaw = dropboxResult.value;
89
+ const folders = Array.isArray(dropboxRaw) ? dropboxRaw : dropboxRaw.Objects ?? [];
90
+ for (const folder of folders) {
91
+ // Skip hidden folders
92
+ if (folder.IsHidden)
93
+ continue;
94
+ const submissionStatus = await fetchSubmissionStatus(apiClient, courseId, folder);
95
+ // Fetch feedback independently of submissions
96
+ let feedback = null;
97
+ try {
98
+ feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
99
+ }
100
+ catch (error) {
101
+ // 404/403 means no feedback available (or no access) - that's fine
102
+ if (error?.status !== 404 && error?.status !== 403) {
103
+ log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
104
+ }
105
+ }
106
+ // Build assignment object
107
+ const assignment = {
108
+ type: "assignment",
109
+ id: folder.Id,
110
+ name: folder.Name,
111
+ url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
112
+ instructions: folder.CustomInstructions?.Html
113
+ ? convertHtmlToMarkdown(folder.CustomInstructions.Html)
114
+ : { markdown: "", html: "" },
115
+ dueDate: folder.DueDate,
116
+ points: folder.Assessment?.ScoreDenominator ?? null,
117
+ isGroup: folder.GroupTypeId !== null,
118
+ rubric: folder.Assessment?.Rubrics?.map((r) => ({
119
+ name: r.Name,
120
+ criteria: r.Criteria?.map((c) => ({
121
+ name: c.Name,
122
+ levels: c.Levels?.map((l) => ({
123
+ name: l.Name,
124
+ points: l.Points,
125
+ description: l.Description?.Text ?? null,
126
+ })) ?? [],
127
+ })) ?? [],
128
+ })) ?? null,
129
+ startDate: folder.Availability?.StartDate ?? null,
130
+ endDate: folder.Availability?.EndDate ?? null,
131
+ ...submissionStatus,
132
+ feedback: feedback
133
+ ? {
134
+ score: feedback.Score,
135
+ feedback: feedback.Feedback?.Html
136
+ ? convertHtmlToMarkdown(feedback.Feedback.Html)
137
+ : null,
138
+ }
139
+ : null,
140
+ };
141
+ assignments.push(assignment);
142
+ }
143
+ }
144
+ else {
145
+ // Log dropbox fetch failure but don't throw
146
+ log("DEBUG", `Failed to fetch dropbox folders for course ${courseId}`, dropboxResult.reason);
147
+ }
148
+ // Process quizzes. Content discovery remains useful when the list route is
149
+ // forbidden or unavailable, so a failed list starts from an empty set.
150
+ let quizzes = [];
151
+ if (quizResult.status === "fulfilled") {
152
+ const quizResponse = quizResult.value;
153
+ // D2L quizzes API returns paged result { Objects: [...] } or a plain array
154
+ quizzes = Array.isArray(quizResponse)
155
+ ? quizResponse
156
+ : quizResponse?.Objects ?? [];
157
+ }
158
+ else {
159
+ log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
160
+ }
161
+ if (contentResult.status === "fulfilled") {
162
+ const listedIds = new Set(quizzes.map((quiz) => quiz.QuizId));
163
+ quizzes.push(...await recoverContentQuizzes(apiClient, courseId, contentResult.value, listedIds));
164
+ }
165
+ else {
166
+ log("DEBUG", `Failed to fetch course content for quiz discovery in course ${courseId}`, contentResult.reason);
167
+ }
168
+ let attemptsForbidden = false;
169
+ for (const quiz of quizzes) {
170
+ // Skip inactive quizzes
171
+ if (!quiz.IsActive)
172
+ continue;
173
+ // Fetch quiz attempts. null means "not measured", which is different
174
+ // from an empty list, and the output says which one it was.
175
+ let attempts = null;
176
+ if (!attemptsForbidden && !quiz.ContentMetadataOnly) {
177
+ try {
178
+ const attemptsRaw = await apiClient.get(apiClient.le(courseId, `/quizzes/${quiz.QuizId}/attempts/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
179
+ // D2L attempts endpoint may return paged { Objects: [...] } or flat array
180
+ attempts = Array.isArray(attemptsRaw) ? attemptsRaw : attemptsRaw.Objects ?? [];
181
+ }
182
+ catch (error) {
183
+ if (error?.status === 404) {
184
+ // 404 means no attempts yet, which is a measurement of zero
185
+ attempts = [];
186
+ }
187
+ else if (error?.status === 403) {
188
+ attemptsForbidden = true;
189
+ log("DEBUG", `Attempts are forbidden for course ${courseId}: not asking again`);
190
+ }
191
+ else {
192
+ log("DEBUG", `Failed to fetch attempts for quiz ${quiz.QuizId}`, error);
193
+ }
194
+ }
195
+ }
196
+ // Calculate remaining attempts
197
+ const completedAttempts = attempts?.filter((a) => a.IsCompleted) ?? null;
198
+ let attemptsRemaining = null;
199
+ let attemptWarning = null;
200
+ if (completedAttempts) {
201
+ attemptsRemaining = "Unlimited";
202
+ if (quiz.AttemptsAllowed && !quiz.AttemptsAllowed.IsUnlimited) {
203
+ const allowed = quiz.AttemptsAllowed.NumberOfAttemptsAllowed ?? 0;
204
+ attemptsRemaining = allowed - completedAttempts.length;
205
+ // Generate warning for low attempts
206
+ if (attemptsRemaining <= 0) {
207
+ attemptWarning = "WARNING: No attempts remaining";
208
+ }
209
+ else if (attemptsRemaining === 1) {
210
+ attemptWarning = "WARNING: Only 1 attempt remaining";
211
+ }
212
+ }
213
+ }
214
+ const timeLimit = quiz.SubmissionTimeLimit ?? quiz.TimeLimit;
215
+ const descriptionHtml = richTextHtml(quiz.Description);
216
+ // Build quiz object
217
+ const quizAssignment = {
218
+ type: "quiz",
219
+ id: quiz.QuizId,
220
+ name: quiz.Name,
221
+ url: baseUrl ? quizUrl(baseUrl, courseId, quiz.QuizId) : null,
222
+ instructions: descriptionHtml
223
+ ? convertHtmlToMarkdown(descriptionHtml)
224
+ : { markdown: "", html: "" },
225
+ dueDate: quiz.DueDate,
226
+ startDate: quiz.StartDate,
227
+ endDate: quiz.EndDate,
228
+ timeLimit: timeLimit?.IsEnforced ? timeLimit.TimeLimitValue : null,
229
+ attemptsAllowed: quiz.AttemptsAllowed?.IsUnlimited
230
+ ? "Unlimited"
231
+ : quiz.AttemptsAllowed?.NumberOfAttemptsAllowed ?? null,
232
+ // False when the tenant refused the attempts endpoint, in which case
233
+ // every count below is null rather than a guess of zero.
234
+ attemptsAvailable: completedAttempts !== null,
235
+ attemptsUsed: completedAttempts?.length ?? null,
236
+ attemptsRemaining,
237
+ attemptWarning,
238
+ bestScore: completedAttempts && completedAttempts.length > 0
239
+ ? Math.max(...completedAttempts.map((a) => a.Score ?? 0))
240
+ : null,
241
+ gracePeriodMinutes: quiz.SubmissionGracePeriod ?? null,
242
+ hasPassword: Boolean(quiz.Password),
243
+ };
244
+ assignments.push(quizAssignment);
245
+ }
246
+ // Process the gradebook last, once the fetched items are known.
247
+ if (gradebookResult.status === "fulfilled") {
248
+ assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl));
249
+ }
250
+ else {
251
+ log("DEBUG", `Failed to fetch the gradebook for course ${courseId}`, gradebookResult.reason);
252
+ }
253
+ return assignments;
254
+ }
255
+ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
256
+ if (!Array.isArray(raw))
257
+ return [];
258
+ const covered = new Set(fetched.map((item) => item.id).filter((id) => typeof id === "number"));
259
+ const rows = [];
260
+ for (const column of raw) {
261
+ if (!STUDENT_SCORED.has(column?.GradeObjectTypeId))
262
+ continue;
263
+ if (covered.has(column.AssociatedTool?.ToolItemId))
264
+ continue;
265
+ if (typeof column.Id !== "number" || typeof column.Name !== "string")
266
+ continue;
267
+ rows.push({
268
+ type: "gradeOnly",
269
+ id: column.Id,
270
+ name: column.Name,
271
+ // Always null: a grade column carries no due date of its own, and
272
+ // inventing one from the column name would be a guess.
273
+ dueDate: null,
274
+ url: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
275
+ });
276
+ }
277
+ return rows;
278
+ }
279
+ export function registerGetAssignments(server, apiClient, config) {
280
+ server.registerTool("get_assignments", {
281
+ title: "Get Assignments",
282
+ description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics.",
283
+ inputSchema: GetAssignmentsSchema,
284
+ }, async (args) => {
285
+ try {
286
+ log("DEBUG", "get_assignments tool called", { args });
287
+ // Parse and validate input
288
+ const { courseId } = GetAssignmentsSchema.parse(args);
289
+ // Single course case
290
+ if (courseId) {
291
+ const assignments = await fetchCourseAssignments(apiClient, courseId, config.baseUrl);
292
+ log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
293
+ return toolResponse({ courseId, assignments });
294
+ }
295
+ // All courses case
296
+ // First, fetch enrolled courses
297
+ const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
298
+ const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
299
+ // Apply course filter
300
+ const filteredEnrollments = applyCourseFilter(enrollmentResponse.Items.map(item => ({
301
+ id: item.OrgUnit.Id,
302
+ name: item.OrgUnit.Name,
303
+ code: item.OrgUnit.Code,
304
+ isActive: item.Access.IsActive,
305
+ canAccess: item.Access.CanAccess,
306
+ ...item,
307
+ })), config.courseFilter);
308
+ // Fetch assignments for each course (handle 403s gracefully)
309
+ const assignmentPromises = filteredEnrollments.map(async (item) => {
310
+ try {
311
+ const assignments = await fetchCourseAssignments(apiClient, item.OrgUnit.Id, config.baseUrl);
312
+ return {
313
+ courseId: item.OrgUnit.Id,
314
+ courseName: item.OrgUnit.Name,
315
+ assignments,
316
+ };
317
+ }
318
+ catch (error) {
319
+ // 403 means no access (past course, etc) - log and skip
320
+ if (error?.status === 403) {
321
+ log("DEBUG", `get_assignments: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
322
+ return null;
323
+ }
324
+ throw error; // Re-throw other errors
325
+ }
326
+ });
327
+ const results = await Promise.allSettled(assignmentPromises);
328
+ const courses = results
329
+ .filter((r) => r.status === "fulfilled" && r.value !== null)
330
+ .map((r) => r.value);
331
+ log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses (out of ${enrollmentResponse.Items.length} enrolled)`);
332
+ return toolResponse({ courses });
333
+ }
334
+ catch (error) {
335
+ // Temporary: log full error details to stderr for debugging
336
+ if (error instanceof Error) {
337
+ log("ERROR", `get_assignments failed: ${error.message}\n${error.stack}`);
338
+ }
339
+ return sanitizeError(error);
340
+ }
341
+ });
342
+ }
343
+ //# sourceMappingURL=get-assignments.js.map
@@ -0,0 +1,38 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { fetchAllObjects } from "../api/paginate.js";
3
+ import { GetClasslistEmailsSchema } from "./schemas.js";
4
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
5
+ import { log } from "../utils/logger.js";
6
+ export function registerGetClasslistEmails(server, apiClient) {
7
+ server.registerTool("get_classlist_emails", {
8
+ title: "Get Classlist Emails",
9
+ description: "Fetch all email addresses for everyone in a course: instructors, TAs, and students. " +
10
+ "Use this when the user wants a list of emails for a class, needs to email the whole class, " +
11
+ "or wants contact info for everyone enrolled.",
12
+ inputSchema: GetClasslistEmailsSchema,
13
+ }, async (args) => {
14
+ try {
15
+ log("DEBUG", "get_classlist_emails tool called", { args });
16
+ const { courseId } = GetClasslistEmailsSchema.parse(args);
17
+ // Fetch full classlist (all roles) using paged endpoint
18
+ const path = apiClient.le(courseId, "/classlist/paged/");
19
+ const users = await fetchAllObjects(apiClient, path, {
20
+ ttl: DEFAULT_CACHE_TTLS.roster,
21
+ });
22
+ // Extract emails, filtering out nulls (privacy-hidden)
23
+ const emails = users
24
+ .filter((user) => user.Email)
25
+ .map((user) => ({
26
+ name: user.DisplayName,
27
+ email: user.Email,
28
+ role: user.ClasslistRoleDisplayName,
29
+ }));
30
+ log("INFO", `get_classlist_emails: ${emails.length} emails from ${users.length} users in course ${courseId}`);
31
+ return toolResponse(emails);
32
+ }
33
+ catch (error) {
34
+ return sanitizeError(error);
35
+ }
36
+ });
37
+ }
38
+ //# sourceMappingURL=get-classlist-emails.js.map