brightspace-mcp-server 3.13.17 → 3.13.18

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 CHANGED
@@ -68,7 +68,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
68
68
  | Grades | "Am I passing all my classes?" · "Compare my grades across courses" · "What did my professor say about Mini-Assignment 1?" (own assignment feedback is read from every route Brightspace documents for students) · "Is there feedback on my midterm?" (quiz-scored grades link to the quiz, since quiz feedback, including feedback only viewable in LockDown Browser, isn't readable through the API) |
69
69
  | Due dates | "What's due in the next 48 hours?" · "Build me a study schedule for the week" |
70
70
  | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" |
71
- | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
71
+ | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" (quizzes are collected from the quiz list, the course content, and the gradebook, so a closed quiz or one the list leaves out still shows up; each course says whether its quiz sources all answered, and if Brightspace refused one it's marked incomplete rather than passed off as everything; "complete" means every source answered, not that every quiz is guaranteed to be listed. A gradebook column linked to a quiz the list omits now shows up as a quiz with its dates and attempts instead of a bare grade-only row when the quiz can be read. Gradebook-only rows now also appear on tenants that send only the documented `GradeType` on grade columns; hidden grade columns, hidden assignments and inactive quizzes never produce one. Content quizzes beyond the first 30 per course are listed from the course content alone and the course is marked incomplete. Quiz rows may also say whether they are open, closed, or not yet open, and mark a quiz whose details Brightspace would not return; gradebook lookups for unlisted quizzes are capped at 20 per course, or 5 when the course's quiz tool can't be identified) |
72
72
  | Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
73
73
  | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or just read a file inline instead of saving it |
74
74
  | Syllabus | "What's the late policy in ECE 264's syllabus?" (if the course keeps its syllabus in an external tool such as Simple Syllabus, you get the link to open instead) |
@@ -10,7 +10,7 @@ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } fro
10
10
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
11
11
  import { log } from "../utils/logger.js";
12
12
  import { applyCourseFilter } from "../utils/course-filter.js";
13
- import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
13
+ import { contentTopicUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
14
14
  import { assignmentLinkResolver } from "./assignment-links.js";
15
15
  import { dueIn } from "../utils/due-in.js";
16
16
  import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
@@ -24,36 +24,127 @@ function richTextHtml(field) {
24
24
  return nested ?? field.Html ?? null;
25
25
  }
26
26
  /**
27
- * The grade object types a student is actually scored on: 1 numeric,
28
- * 2 passfail, 3 selectbox, 4 text. The rest (category, calculated, formula,
29
- * final) are the gradebook's own arithmetic, not work anybody owes.
27
+ * The grade types a student is actually scored on. D2L documents exactly four
28
+ * GradeObject shapes, told apart by a string GradeType; anything else a
29
+ * gradebook lists (category, calculated, formula, final grade) is the
30
+ * gradebook's own arithmetic, not work anybody owes. Those are not documented
31
+ * as GradeType values, so a column is excluded when its GradeType is some
32
+ * other string, and a calculated column also carries no AssociatedTool.
30
33
  */
34
+ const STUDENT_SCORED_TYPES = new Set(["numeric", "passfail", "selectbox", "text"]);
35
+ /** The numeric GRADEOBJ_T values for the same four types: 1 numeric, 2 passfail, 3 selectbox, 4 text. */
31
36
  const STUDENT_SCORED = new Set([1, 2, 3, 4]);
32
- /** Quiz topics visible in course content but absent from the quiz listing. */
33
- function missingContentQuizTopics(raw, listedIds) {
37
+ function isStudentScored(column) {
38
+ if (!column || typeof column !== "object")
39
+ return false;
40
+ if (typeof column.GradeType === "string" && STUDENT_SCORED_TYPES.has(column.GradeType.toLowerCase()))
41
+ return true;
42
+ return STUDENT_SCORED.has(column.GradeObjectTypeId);
43
+ }
44
+ /** The columns a student is scored on, from whatever the grades route returned. */
45
+ function studentScoredColumns(gradebook) {
46
+ if (!Array.isArray(gradebook))
47
+ return [];
48
+ return gradebook.filter((c) => isStudentScored(c) && typeof c.Id === "number");
49
+ }
50
+ /** The quiz id a content topic points at, or null when it carries none. */
51
+ function contentQuizId(topic) {
52
+ const candidates = [
53
+ topic.ToolItemId,
54
+ Number(topic.ActivityId),
55
+ // quiz_summary.d2l?qi=N and quiz_start_frame_auto.d2l?qi=N: the same qi
56
+ // parameter quizUrl() builds, present when ToolItemId is not.
57
+ Number(/(?:[?&]|&)qi=(\d+)(?:[&#]|$)/i.exec(topic.Url ?? "")?.[1]),
58
+ ];
59
+ for (const id of candidates) {
60
+ if (typeof id === "number" && Number.isSafeInteger(id) && id > 0)
61
+ return id;
62
+ }
63
+ return null;
64
+ }
65
+ /** Titles compare by words, not by case or spacing, so a topic can be matched to a listed quiz. */
66
+ function normalizedTitle(title) {
67
+ return typeof title === "string" ? title.trim().replace(/\s+/g, " ").toLowerCase() : "";
68
+ }
69
+ /** Every quiz topic in a table of contents, depth first, hidden or broken ones included. */
70
+ function quizTopicsOf(raw) {
34
71
  if (!raw || typeof raw !== "object")
35
72
  return [];
36
- const found = new Map();
37
- const visit = (modules) => {
38
- if (!Array.isArray(modules))
73
+ const found = [];
74
+ const visit = (modules, depth) => {
75
+ if (!Array.isArray(modules) || depth > 50)
39
76
  return;
40
77
  for (const module of modules) {
41
78
  for (const topic of module.Topics ?? []) {
42
79
  const isQuiz = topic.ActivityType === 4
43
80
  || topic.TypeIdentifier?.toLowerCase() === "quiz"
44
- || /(?:[?&]|&)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "");
45
- if (!isQuiz || topic.IsHidden || topic.IsBroken || topic.IsExempt)
46
- continue;
47
- const activityId = topic.ToolItemId ?? Number(topic.ActivityId);
48
- if (!Number.isSafeInteger(activityId) || activityId <= 0 || listedIds.has(activityId))
49
- continue;
50
- if (!found.has(activityId))
51
- found.set(activityId, topic);
81
+ || /(?:[?&]|&amp;)type=quiz(?:[&#]|$)/i.test(topic.Url ?? "")
82
+ || /\/lms\/quizzing\/user\/quiz_/i.test(topic.Url ?? "");
83
+ if (isQuiz)
84
+ found.push(topic);
52
85
  }
53
- visit(module.Modules);
86
+ visit(module.Modules, depth + 1);
54
87
  }
55
88
  };
56
- visit(raw.Modules);
89
+ visit(raw.Modules, 0);
90
+ return found;
91
+ }
92
+ /**
93
+ * The quiz tool's id as content quiz topics report it (ToolId, documented on
94
+ * every ToC topic). Tells the gradebook probe which columns belong to quizzes.
95
+ */
96
+ function contentQuizToolIds(raw) {
97
+ return new Set(quizTopicsOf(raw)
98
+ .map((topic) => topic.ToolId)
99
+ .filter((id) => typeof id === "number" && Number.isSafeInteger(id) && id > 0));
100
+ }
101
+ /**
102
+ * The quiz id behind a topic that names none, when the grade column the topic
103
+ * writes to (GradeItemId, documented on ToC topics) is linked to a tool item.
104
+ * A column of a different tool than the topic's is not the topic's quiz.
105
+ */
106
+ function quizIdFromGradeColumn(topic, columnsById) {
107
+ if (typeof topic.GradeItemId !== "number")
108
+ return null;
109
+ const tool = columnsById.get(topic.GradeItemId)?.AssociatedTool;
110
+ const itemId = tool?.ToolItemId;
111
+ if (typeof itemId !== "number" || !Number.isSafeInteger(itemId) || itemId <= 0)
112
+ return null;
113
+ if (typeof topic.ToolId === "number" && typeof tool?.ToolId === "number" && topic.ToolId !== tool.ToolId) {
114
+ return null;
115
+ }
116
+ return itemId;
117
+ }
118
+ /**
119
+ * Quiz topics visible in course content but absent from the quiz listing.
120
+ * Topics are returned once each. Those that name a quiz (directly, or through
121
+ * their grade column) are keyed by id; one that names none is kept only if no
122
+ * listed quiz carries its title, and one linked from several modules is kept
123
+ * once, recognized by its title and link.
124
+ */
125
+ function missingContentQuizTopics(raw, listedIds, listedTitles, columnsById) {
126
+ const found = new Map();
127
+ for (const topic of quizTopicsOf(raw)) {
128
+ // A hidden or broken topic is not something the student can open. An
129
+ // exempt one (IsExempt: the user is exempt from the topic, per the
130
+ // content docs) is still listed, and flagged as exempt on its row.
131
+ if (topic.IsHidden || topic.IsBroken)
132
+ continue;
133
+ const quizId = contentQuizId(topic) ?? quizIdFromGradeColumn(topic, columnsById);
134
+ if (quizId !== null) {
135
+ if (listedIds.has(quizId))
136
+ continue;
137
+ }
138
+ else if (listedTitles.has(normalizedTitle(topic.Title))) {
139
+ continue;
140
+ }
141
+ const url = (topic.Url ?? "").trim();
142
+ const key = quizId !== null
143
+ ? `quiz:${quizId}`
144
+ : url !== "" ? `link:${normalizedTitle(topic.Title)}|${url}` : `topic:${topic.TopicId}`;
145
+ if (!found.has(key))
146
+ found.set(key, { topic, quizId });
147
+ }
57
148
  return [...found.values()];
58
149
  }
59
150
  /**
@@ -62,40 +153,234 @@ function missingContentQuizTopics(raw, listedIds) {
62
153
  * us the normal full shape. If that route is also unavailable, the content
63
154
  * topic still provides enough information to avoid silently hiding the work.
64
155
  */
65
- async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
66
- return Promise.all(missingContentQuizTopics(toc, listedIds).map(async (topic) => {
67
- const quizId = topic.ToolItemId ?? Number(topic.ActivityId);
68
- try {
69
- return await apiClient.get(apiClient.le(courseId, `/quizzes/${quizId}`), {
70
- ttl: DEFAULT_CACHE_TTLS.assignments,
71
- });
72
- }
73
- catch (error) {
74
- if (isAuthUnavailable(error))
75
- throw error;
76
- log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
156
+ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds, listedTitles, columnsById) {
157
+ const topics = missingContentQuizTopics(toc, listedIds, listedTitles, columnsById);
158
+ // Only the first topics are asked for their details; the rest are listed
159
+ // from the table of contents alone, so a huge course costs a bounded number
160
+ // of requests and no quiz is dropped.
161
+ const readable = Math.min(topics.length, MAX_CONTENT_QUIZ_RECOVERIES);
162
+ const found = await Promise.all(topics.map(async ({ topic, quizId }, index) => {
163
+ const exempt = topic.IsExempt === true ? { exempt: true } : {};
164
+ const mayRead = index < MAX_CONTENT_QUIZ_RECOVERIES;
165
+ if (mayRead && quizId !== null) {
166
+ try {
167
+ const quiz = await apiClient.get(apiClient.le(courseId, `/quizzes/${quizId}`), {
168
+ ttl: DEFAULT_CACHE_TTLS.assignments,
169
+ });
170
+ return { quiz, source: "courseContent", topicId: topic.TopicId, ...exempt };
171
+ }
172
+ catch (error) {
173
+ if (isAuthUnavailable(error))
174
+ throw error;
175
+ log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
176
+ }
77
177
  }
78
178
  let detail = topic;
179
+ if (mayRead) {
180
+ try {
181
+ detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
182
+ }
183
+ catch (error) {
184
+ if (isAuthUnavailable(error))
185
+ throw error;
186
+ log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
187
+ }
188
+ }
189
+ return {
190
+ source: "courseContent",
191
+ topicId: topic.TopicId,
192
+ ...exempt,
193
+ quiz: {
194
+ QuizId: quizId,
195
+ Name: detail.Title || topic.Title,
196
+ Description: detail.Description ?? null,
197
+ StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
198
+ EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
199
+ DueDate: detail.DueDate ?? null,
200
+ IsActive: true,
201
+ AttemptsAllowed: null,
202
+ ContentMetadataOnly: true,
203
+ },
204
+ };
205
+ }));
206
+ return { found, considered: topics.length, read: readable };
207
+ }
208
+ /**
209
+ * Content quizzes per course whose own record (and, failing that, content
210
+ * topic) is read. Each may cost two requests, so a course costs at most 60.
211
+ * Past the cap a quiz is still listed, from the table of contents alone, and
212
+ * the course's quizCoverage says the cap applied.
213
+ */
214
+ const MAX_CONTENT_QUIZ_RECOVERIES = 30;
215
+ /**
216
+ * Probes per course. A course whose quiz tool is known (a listed or content
217
+ * quiz sits on it) is asked about that tool's other columns, up to 20. With no
218
+ * known quiz tool every non-dropbox column is a candidate, so far fewer are
219
+ * asked: most of them are discussions, surveys or manual items.
220
+ */
221
+ const MAX_GRADEBOOK_QUIZ_PROBES = 20;
222
+ const MAX_UNTARGETED_GRADEBOOK_QUIZ_PROBES = 5;
223
+ /**
224
+ * A quiz read that answered 404 or 403 will answer the same again soon, so it
225
+ * is not repeated for ten minutes. Kept per client (so per signed-in account)
226
+ * and only for those two answers: a 401 is a sign-in matter and a 5xx or a
227
+ * network failure may pass.
228
+ */
229
+ const PROBE_REFUSAL_TTL_MS = 10 * 60 * 1000;
230
+ const refusedProbes = new WeakMap();
231
+ function probeRecentlyRefused(apiClient, path, now) {
232
+ const until = refusedProbes.get(apiClient)?.get(path);
233
+ if (until === undefined)
234
+ return false;
235
+ if (until > now)
236
+ return true;
237
+ refusedProbes.get(apiClient).delete(path);
238
+ return false;
239
+ }
240
+ function rememberRefusedProbe(apiClient, path, now) {
241
+ let paths = refusedProbes.get(apiClient);
242
+ if (!paths) {
243
+ paths = new Map();
244
+ refusedProbes.set(apiClient, paths);
245
+ }
246
+ paths.set(path, now + PROBE_REFUSAL_TTL_MS);
247
+ }
248
+ /**
249
+ * Quizzes behind gradebook columns that nothing else accounted for. A column
250
+ * linked to a tool item (AssociatedTool) the quiz list and content both omit
251
+ * may still be a quiz the student can read by id, so it is asked for once.
252
+ * The answer is accepted only when the quiz names that very column as its
253
+ * GradeItemId: a discussion or survey that happens to share the id answers
254
+ * 404 or names a different column, and stays a bare gradebook row.
255
+ *
256
+ * A column the instructor hid from students is not asked. The folder ids
257
+ * include hidden folders, whose columns are never asked either.
258
+ * Tools already seen on a dropbox folder are not asked; once a quiz tool is
259
+ * known (from a listed or content quiz), only that tool's other columns are.
260
+ * A column named like an id-less content quiz is that quiz, not a new one.
261
+ */
262
+ async function recoverGradebookQuizzes(apiClient, courseId, columns, folderIds, quizIds, contentQuizTools, idlessQuizTitles) {
263
+ const toolIdsOf = (ids) => new Set(columns
264
+ .filter((c) => ids.has(c.AssociatedTool?.ToolItemId))
265
+ .map((c) => c.AssociatedTool.ToolId));
266
+ const quizTools = new Set([...toolIdsOf(quizIds), ...contentQuizTools]);
267
+ const folderTools = toolIdsOf(folderIds);
268
+ const now = Date.now();
269
+ const candidates = columns
270
+ .filter((c) => {
271
+ const itemId = c.AssociatedTool?.ToolItemId;
272
+ if (typeof itemId !== "number" || !Number.isSafeInteger(itemId) || itemId <= 0)
273
+ return false;
274
+ if (c.IsHidden === true)
275
+ return false;
276
+ if (quizIds.has(itemId) || folderIds.has(itemId))
277
+ return false;
278
+ if (idlessQuizTitles.has(normalizedTitle(c.Name)))
279
+ return false;
280
+ if (probeRecentlyRefused(apiClient, apiClient.le(courseId, `/quizzes/${itemId}`), now))
281
+ return false;
282
+ if (quizTools.size > 0)
283
+ return quizTools.has(c.AssociatedTool.ToolId);
284
+ return !folderTools.has(c.AssociatedTool.ToolId);
285
+ })
286
+ .slice(0, quizTools.size > 0 ? MAX_GRADEBOOK_QUIZ_PROBES : MAX_UNTARGETED_GRADEBOOK_QUIZ_PROBES);
287
+ const found = await Promise.all(candidates.map(async (column) => {
288
+ const itemId = column.AssociatedTool.ToolItemId;
289
+ const path = apiClient.le(courseId, `/quizzes/${itemId}`);
79
290
  try {
80
- detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
291
+ const quiz = await apiClient.get(path, { ttl: DEFAULT_CACHE_TTLS.assignments });
292
+ if (quiz?.QuizId === itemId && quiz.GradeItemId === column.Id) {
293
+ return { quiz, source: "gradebook" };
294
+ }
81
295
  }
82
296
  catch (error) {
83
297
  if (isAuthUnavailable(error))
84
298
  throw error;
85
- log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
299
+ if (error?.status === 404 || error?.status === 403)
300
+ rememberRefusedProbe(apiClient, path, now);
301
+ log("DEBUG", `Gradebook column ${column.Id} is not a readable quiz (${itemId})`, error);
86
302
  }
87
- return {
88
- QuizId: quizId,
89
- Name: detail.Title || topic.Title,
90
- Description: detail.Description ?? null,
91
- StartDate: detail.StartDate ?? detail.StartDateTime ?? topic.StartDateTime ?? null,
92
- EndDate: detail.EndDate ?? detail.EndDateTime ?? topic.EndDateTime ?? null,
93
- DueDate: detail.DueDate ?? null,
94
- IsActive: true,
95
- AttemptsAllowed: null,
96
- ContentMetadataOnly: true,
97
- };
303
+ return null;
98
304
  }));
305
+ return found.filter((item) => item !== null);
306
+ }
307
+ /**
308
+ * The content table of contents with date-restricted items included. D2L's
309
+ * default view omits modules and topics outside their date window, which is
310
+ * exactly where a closed (or not yet opened) quiz sits; ignoreDateRestrictions
311
+ * is documented to be open to students, and supported from LE 1.67, which the
312
+ * client's latest version covers. D2L documents that unsupported parameters
313
+ * are ignored, so the plain view is only a fallback for a request the tenant
314
+ * rejects outright (400, 403, 404), reported as such so coverage is not
315
+ * overstated. Anything else (429, a 5xx, a dropped connection) says nothing
316
+ * about the parameter, so it is not retried here: the content counts as unread.
317
+ */
318
+ async function fetchContentToc(apiClient, courseId) {
319
+ try {
320
+ const toc = await apiClient.get(apiClient.le(courseId, "/content/toc?ignoreDateRestrictions=true&ignoreModuleDateRestrictions=true"), { ttl: DEFAULT_CACHE_TTLS.courseContent });
321
+ return { toc, datesIncluded: true };
322
+ }
323
+ catch (error) {
324
+ if (isAuthUnavailable(error))
325
+ throw error;
326
+ const status = error?.status;
327
+ if (status !== 400 && status !== 403 && status !== 404)
328
+ throw error;
329
+ log("DEBUG", `Content table of contents with date restrictions ignored was refused (${status}) for course ${courseId}: using the default view`, error);
330
+ }
331
+ const toc = await apiClient.get(apiClient.le(courseId, "/content/toc"), {
332
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
333
+ });
334
+ return { toc, datesIncluded: false };
335
+ }
336
+ const DETAILS_UNAVAILABLE_NOTE = "Brightspace showed this quiz in the course content but would not return its details to you; " +
337
+ "it may be closed, not yet open, or limited to certain students. Open the link to check.";
338
+ /**
339
+ * Whether a quiz is open, from its own start and end dates alone: before the
340
+ * start it is not yet open, after the end it is closed. With neither date
341
+ * (or none that parses) nothing is said, and a due date never closes a quiz.
342
+ */
343
+ function availabilityField(startDate, endDate, now = Date.now()) {
344
+ const start = startDate ? new Date(startDate).getTime() : NaN;
345
+ const end = endDate ? new Date(endDate).getTime() : NaN;
346
+ if (!Number.isFinite(start) && !Number.isFinite(end))
347
+ return {};
348
+ if (Number.isFinite(start) && now < start)
349
+ return { availability: "notYetOpen" };
350
+ if (Number.isFinite(end) && now > end)
351
+ return { availability: "closed" };
352
+ return { availability: "open" };
353
+ }
354
+ /**
355
+ * Coverage of the three routes quizzes are found through. Any one that did
356
+ * not answer may hide quizzes the others cannot see, so the inventory is only
357
+ * complete when all three did.
358
+ */
359
+ function quizCoverage(answered) {
360
+ const unread = [
361
+ answered.quizList ? null : "the course's quiz list",
362
+ answered.content === "full" ? null
363
+ : answered.content === "dateRestricted"
364
+ ? "the course content with date-restricted items included (quizzes whose module or access window has not opened or has closed are hidden from the default view)"
365
+ : "the course content (checked for quizzes the quiz list omits)",
366
+ answered.gradebook ? null : "the gradebook (checked for graded work the quiz list omits)",
367
+ ].filter((source) => source !== null);
368
+ const recovery = answered.contentRecovery;
369
+ const truncated = recovery !== undefined && recovery.read < recovery.considered;
370
+ if (unread.length === 0 && !truncated)
371
+ return { quizCoverage: "complete" };
372
+ return {
373
+ quizCoverage: "incomplete",
374
+ quizCoverageNote: (unread.length > 0
375
+ ? `Brightspace did not return ${unread.join(", or ")}, so this course may have quizzes not listed here. `
376
+ : "") +
377
+ (truncated
378
+ ? `Brightspace read the details of only the first ${recovery.read} of ${recovery.considered} quizzes found in the course content; ` +
379
+ "the rest are listed from the course content alone, without their details or attempts. "
380
+ : "") +
381
+ "Do not treat the listed quizzes as all of them, and do not report an unlisted quiz as missing or not taken - " +
382
+ "tell the user to check Brightspace directly.",
383
+ };
99
384
  }
100
385
  /**
101
386
  * Fetch assignments (dropbox + quizzes) for a single course
@@ -103,6 +388,10 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
103
388
  * baseUrl is optional: without it the items carry a null url.
104
389
  */
105
390
  export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
391
+ return (await fetchCourseInventory(apiClient, courseId, baseUrl)).assignments;
392
+ }
393
+ /** A course's assignments and quizzes, with whether its quiz list is complete. */
394
+ async function fetchCourseInventory(apiClient, courseId, baseUrl) {
106
395
  const assignments = [];
107
396
  const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, courseId);
108
397
  // Fetch dropbox folders, quizzes, and the gradebook in parallel. The
@@ -118,9 +407,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
118
407
  apiClient.get(apiClient.le(courseId, "/grades/"), {
119
408
  ttl: DEFAULT_CACHE_TTLS.assignments,
120
409
  }),
121
- apiClient.get(apiClient.le(courseId, "/content/toc"), {
122
- ttl: DEFAULT_CACHE_TTLS.courseContent,
123
- }),
410
+ fetchContentToc(apiClient, courseId),
124
411
  ]);
125
412
  // Each route may fail on its own without costing the others, but a failed
126
413
  // sign-in means none of them answered: report that, not an empty course.
@@ -128,15 +415,21 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
128
415
  if (result.status === "rejected" && isAuthUnavailable(result.reason))
129
416
  throw result.reason;
130
417
  }
418
+ // Ids of folders and quizzes the instructor hid or inactivated and that are
419
+ // therefore left out on purpose; their grade columns are not "uncovered".
420
+ const skippedIds = new Set();
131
421
  // Process Dropbox folders
132
422
  if (dropboxResult.status === "fulfilled") {
133
423
  // D2L dropbox endpoint may return paged { Objects: [...] } or flat array
134
424
  const dropboxRaw = dropboxResult.value;
135
425
  const folders = Array.isArray(dropboxRaw) ? dropboxRaw : dropboxRaw.Objects ?? [];
136
426
  for (const folder of folders) {
137
- // Skip hidden folders
138
- if (folder.IsHidden)
427
+ // Skip hidden folders. Their ids are remembered: the instructor hid the
428
+ // work, so its grade column must not resurface as a gradebook row.
429
+ if (folder.IsHidden) {
430
+ skippedIds.add(folder.Id);
139
431
  continue;
432
+ }
140
433
  // Fetch submissions for this folder. Some tenants deny students access
141
434
  // to mysubmissions outright (403), in which case an empty list would be
142
435
  // indistinguishable from genuinely unsubmitted work — track whether the
@@ -299,32 +592,50 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
299
592
  }
300
593
  // Process quizzes. Content discovery remains useful when the list route is
301
594
  // forbidden or unavailable, so a failed list starts from an empty set.
302
- let quizzes = [];
595
+ const quizzes = [];
303
596
  if (quizResult.status === "fulfilled") {
304
- quizzes = quizResult.value;
597
+ quizzes.push(...quizResult.value.map((quiz) => ({ quiz, source: "quizList" })));
305
598
  }
306
599
  else {
307
600
  log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
308
601
  }
602
+ const knownQuizIds = () => new Set(quizzes.map((found) => found.quiz.QuizId).filter((id) => typeof id === "number"));
603
+ const knownQuizTitles = () => new Set(quizzes.map((found) => normalizedTitle(found.quiz.Name)));
604
+ const gradebookColumns = gradebookResult.status === "fulfilled"
605
+ ? studentScoredColumns(gradebookResult.value)
606
+ : [];
607
+ let contentRecovery;
309
608
  if (contentResult.status === "fulfilled") {
310
- const listedIds = new Set(quizzes.map((quiz) => quiz.QuizId));
311
- quizzes.push(...await recoverContentQuizzes(apiClient, courseId, contentResult.value, listedIds));
609
+ const recovered = await recoverContentQuizzes(apiClient, courseId, contentResult.value.toc, knownQuizIds(), knownQuizTitles(), new Map(gradebookColumns.map((column) => [column.Id, column])));
610
+ quizzes.push(...recovered.found);
611
+ contentRecovery = { considered: recovered.considered, read: recovered.read };
312
612
  }
313
613
  else {
314
614
  log("DEBUG", `Failed to fetch course content for quiz discovery in course ${courseId}`, contentResult.reason);
315
615
  }
616
+ if (gradebookResult.status === "fulfilled") {
617
+ const folderIds = new Set([
618
+ ...assignments.map((item) => item.id).filter((id) => typeof id === "number"),
619
+ ...skippedIds,
620
+ ]);
621
+ quizzes.push(...await recoverGradebookQuizzes(apiClient, courseId, gradebookColumns, folderIds, knownQuizIds(), contentResult.status === "fulfilled" ? contentQuizToolIds(contentResult.value.toc) : new Set(), new Set(quizzes.filter((found) => found.quiz.QuizId === null).map((found) => normalizedTitle(found.quiz.Name)))));
622
+ }
316
623
  // Students on this tenant get 403 from /quizzes/{id}/attempts/. Once the
317
624
  // first quiz of a course proves that, the remaining quizzes are not asked:
318
625
  // the answer would be the same 403, at one wasted request each.
319
626
  let attemptsForbidden = false;
320
- for (const quiz of quizzes) {
321
- // Skip inactive quizzes
322
- if (!quiz.IsActive)
627
+ for (const { quiz, source, topicId, exempt } of quizzes) {
628
+ // Skip quizzes the instructor marked inactive. A row that does not say
629
+ // is not inactive: only an explicit false hides a quiz.
630
+ if (quiz.IsActive === false) {
631
+ if (typeof quiz.QuizId === "number")
632
+ skippedIds.add(quiz.QuizId);
323
633
  continue;
634
+ }
324
635
  // Fetch quiz attempts. null means "not measured", which is different
325
636
  // from an empty list, and the output says which one it was.
326
637
  let attempts = null;
327
- if (!attemptsForbidden && !quiz.ContentMetadataOnly) {
638
+ if (!attemptsForbidden && !quiz.ContentMetadataOnly && quiz.QuizId !== null) {
328
639
  try {
329
640
  const attemptsRaw = await apiClient.get(apiClient.le(courseId, `/quizzes/${quiz.QuizId}/attempts/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
330
641
  // D2L attempts endpoint may return paged { Objects: [...] } or flat array
@@ -334,8 +645,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
334
645
  if (isAuthUnavailable(error))
335
646
  throw error;
336
647
  if (error?.status === 404) {
337
- // 404 means no attempts yet, which is a measurement of zero
338
- attempts = [];
648
+ // For a quiz the course's quiz list returned, 404 means no
649
+ // attempts yet: a measurement of zero. A quiz that list did not
650
+ // return is one the quiz API is already hiding from this student,
651
+ // so the same 404 may be that, and says nothing about attempts.
652
+ if (source === "quizList")
653
+ attempts = [];
654
+ else
655
+ log("DEBUG", `Attempts for quiz ${quiz.QuizId} answered 404 and it is not a listed quiz: leaving them unknown`);
339
656
  }
340
657
  else if (error?.status === 403) {
341
658
  attemptsForbidden = true;
@@ -370,8 +687,17 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
370
687
  const quizAssignment = {
371
688
  type: "quiz",
372
689
  id: quiz.QuizId,
690
+ ...(quiz.QuizId === null && topicId !== undefined ? { topicId } : {}),
373
691
  name: quiz.Name,
374
- url: baseUrl ? quizUrl(baseUrl, courseId, quiz.QuizId) : null,
692
+ // A content quiz with no quiz id links to its content topic instead.
693
+ url: !baseUrl
694
+ ? null
695
+ : quiz.QuizId !== null
696
+ ? quizUrl(baseUrl, courseId, quiz.QuizId)
697
+ : topicId !== undefined ? contentTopicUrl(baseUrl, courseId, topicId) : null,
698
+ // Which route found this quiz: the course's quiz list, a quiz topic
699
+ // in the course content, or a gradebook column linked to it.
700
+ source,
375
701
  instructions: descriptionHtml
376
702
  ? convertHtmlToMarkdown(descriptionHtml).markdown
377
703
  : "",
@@ -404,17 +730,40 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
404
730
  : null,
405
731
  gracePeriodMinutes: quiz.SubmissionGracePeriod ?? null,
406
732
  hasPassword: Boolean(quiz.Password),
733
+ // Additive fields, each emitted only when it has something to say.
734
+ // availability is computed from the quiz's own start and end dates.
735
+ ...availabilityField(quiz.StartDate, quiz.EndDate),
736
+ // A row built from the table of contents alone: the quiz's own record
737
+ // was refused, or the topic names no quiz id to ask for.
738
+ ...(quiz.ContentMetadataOnly ? {
739
+ detailsStatus: "unavailable",
740
+ detailsStatusNote: DETAILS_UNAVAILABLE_NOTE,
741
+ } : {}),
742
+ // The topic's IsExempt: the user is exempt from the topic. Kept
743
+ // listed, since exempt does not mean it cannot be opened.
744
+ ...(exempt ? { exempt: true } : {}),
407
745
  };
408
746
  assignments.push(quizAssignment);
409
747
  }
410
748
  // Process the gradebook last, once the fetched items are known.
411
749
  if (gradebookResult.status === "fulfilled") {
412
- assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl));
750
+ assignments.push(...gradebookHeadsUp(gradebookResult.value, assignments, courseId, baseUrl, skippedIds));
413
751
  }
414
752
  else {
415
753
  log("DEBUG", `Failed to fetch the gradebook for course ${courseId}`, gradebookResult.reason);
416
754
  }
417
- return assignments;
755
+ return {
756
+ assignments,
757
+ ...quizCoverage({
758
+ // A 404 on the list means the course has no quiz tool: answered, and empty.
759
+ quizList: quizResult.status === "fulfilled" || quizResult.reason?.status === 404,
760
+ content: contentResult.status !== "fulfilled"
761
+ ? "failed"
762
+ : contentResult.value.datesIncluded ? "full" : "dateRestricted",
763
+ gradebook: gradebookResult.status === "fulfilled",
764
+ contentRecovery,
765
+ }),
766
+ };
418
767
  }
419
768
  /**
420
769
  * Gradebook columns describing work that no other route already offered.
@@ -429,18 +778,32 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
429
778
  * A column with no id or no name is skipped rather than fatal: one malformed
430
779
  * row should cost its own line, not the course's other heads-up rows.
431
780
  */
432
- function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
781
+ function gradebookHeadsUp(raw, fetched, courseId, baseUrl, skippedIds = new Set()) {
433
782
  if (!Array.isArray(raw))
434
783
  return [];
435
- const covered = new Set(fetched.map((item) => item.id).filter((id) => typeof id === "number"));
784
+ // Fetched items, plus the hidden folders and inactive quizzes that were left
785
+ // out on purpose: a column of one of those is not work the tool missed.
786
+ const covered = new Set([
787
+ ...fetched.map((item) => item.id).filter((id) => typeof id === "number"),
788
+ ...skippedIds,
789
+ ]);
790
+ // A content quiz that names no id cannot be matched to its column by id, so
791
+ // a column carrying the same title is taken to be that quiz.
792
+ const idlessQuizTitles = new Set(fetched
793
+ .filter((item) => item.type === "quiz" && item.id === null)
794
+ .map((item) => normalizedTitle(item.name)));
436
795
  const rows = [];
437
796
  for (const column of raw) {
438
- if (!STUDENT_SCORED.has(column?.GradeObjectTypeId))
797
+ if (!isStudentScored(column))
798
+ continue;
799
+ if (column.IsHidden === true)
439
800
  continue;
440
801
  if (covered.has(column.AssociatedTool?.ToolItemId))
441
802
  continue;
442
803
  if (typeof column.Id !== "number" || typeof column.Name !== "string")
443
804
  continue;
805
+ if (idlessQuizTitles.has(normalizedTitle(column.Name)))
806
+ continue;
444
807
  rows.push({
445
808
  type: "gradeOnly",
446
809
  id: column.Id,
@@ -462,7 +825,13 @@ export function registerGetAssignments(server, apiClient, config) {
462
825
  title: "Get Assignments",
463
826
  annotations: { readOnlyHint: true },
464
827
  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. " +
465
- "Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace.",
828
+ "Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace. " +
829
+ "Quizzes come from the quiz list, the course content, and the gradebook; each quiz row says which in source. A quiz with id null was found only as a content topic. " +
830
+ "A quiz row with detailsStatus \"unavailable\" was shown in the course content but Brightspace would not return its details (it may be closed, not yet open, or limited to certain students), so its dates and attempts may be missing: point the user to its link. " +
831
+ "availability (notYetOpen, open, closed) is worked out from the quiz's own start and end dates and is left out when it has none. exempt: true means Brightspace marks the user exempt from that quiz's topic; the quiz is still listed. " +
832
+ "Each course also carries quizCoverage. \"complete\" means every quiz source answered, not that every quiz is guaranteed to be listed; \"incomplete\" means a quiz source could not be read, so the course may have quizzes not listed. " +
833
+ "Never conclude that a quiz was missed or not taken because it is absent, and never present the listed quizzes as all of them. " +
834
+ "A gradeOnly row named like a quiz is a quiz whose details could not be read.",
466
835
  inputSchema: GetAssignmentsSchema,
467
836
  }, async (args) => {
468
837
  try {
@@ -472,9 +841,9 @@ export function registerGetAssignments(server, apiClient, config) {
472
841
  // Single course case
473
842
  if (courseId) {
474
843
  try {
475
- const assignments = await fetchCourseAssignments(apiClient, courseId, config.baseUrl);
476
- log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
477
- return toolResponse({ courseId, assignments });
844
+ const inventory = await fetchCourseInventory(apiClient, courseId, config.baseUrl);
845
+ log("INFO", `get_assignments: Retrieved ${inventory.assignments.length} assignments for course ${courseId}`);
846
+ return toolResponse({ courseId, ...inventory });
478
847
  }
479
848
  catch (error) {
480
849
  // A pending sign-in is not an empty course: the routes never
@@ -520,11 +889,11 @@ export function registerGetAssignments(server, apiClient, config) {
520
889
  // Fetch assignments for each course (handle 403s gracefully)
521
890
  const assignmentPromises = filteredEnrollments.map(async (item) => {
522
891
  try {
523
- const assignments = await fetchCourseAssignments(apiClient, item.OrgUnit.Id, config.baseUrl);
892
+ const inventory = await fetchCourseInventory(apiClient, item.OrgUnit.Id, config.baseUrl);
524
893
  return {
525
894
  courseId: item.OrgUnit.Id,
526
895
  courseName: item.OrgUnit.Name,
527
- assignments,
896
+ ...inventory,
528
897
  };
529
898
  }
530
899
  catch (error) {
@@ -557,7 +926,7 @@ export function registerGetAssignments(server, apiClient, config) {
557
926
  const pending = settled.filter((c) => c.authPending);
558
927
  const courses = settled
559
928
  .filter((c) => !c.authPending)
560
- .map(({ courseId, courseName, assignments }) => ({ courseId, courseName, assignments }));
929
+ .map(({ authPending: _authPending, authError: _authError, ...course }) => course);
561
930
  log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses ` +
562
931
  `(out of ${enrollmentItems.length} enrolled${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
563
932
  // Pending courses keep `assignments: []` so callers that read
@@ -29,6 +29,10 @@ export function assignmentUrl(baseUrl, courseId, folderId, groupId = 0) {
29
29
  export function quizUrl(baseUrl, courseId, quizId) {
30
30
  return `${trimBaseUrl(baseUrl)}/d2l/lms/quizzing/user/quiz_summary.d2l?qi=${quizId}&ou=${courseId}`;
31
31
  }
32
+ /** Link to a topic in the course content viewer. */
33
+ export function contentTopicUrl(baseUrl, courseId, topicId) {
34
+ return `${trimBaseUrl(baseUrl)}/d2l/le/content/${courseId}/viewContent/${topicId}/View`;
35
+ }
32
36
  /**
33
37
  * Link to a course's own grades page. There is no per-column page a student
34
38
  * may open, so every gradebook row in a course shares this one link.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.13.17",
3
+ "version": "3.13.18",
4
4
  "mcpName": "io.github.RohanMuppa/brightspace",
5
5
  "description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
6
6
  "type": "module",