brightspace-mcp-server 3.13.17 → 3.13.19

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) |
@@ -282,6 +282,17 @@ export class AuthRunner {
282
282
  pendingChallenge = null;
283
283
  /** Resolves on the next marker from the current child; null between children. */
284
284
  challengeSignal = null;
285
+ /**
286
+ * Callers polling the current child (awaitBackgroundChild), woken whenever
287
+ * it reports a challenge, so a changed number reaches them at once.
288
+ */
289
+ challengeWatchers = new Set();
290
+ /**
291
+ * The current child's last number-match challenge (relayKey) handed back
292
+ * to a caller here; null until one is. A retry joining the child waits
293
+ * only while the number is still this one (issue #229).
294
+ */
295
+ challengeToldKey = null;
285
296
  /** Callers inside run() right now. */
286
297
  waiting = 0;
287
298
  /** When the last caller left run(). */
@@ -365,9 +376,12 @@ export class AuthRunner {
365
376
  catch (error) {
366
377
  this.noteRelayReported(error);
367
378
  const answer = dedupeChallenge(error, wave);
368
- if (answer instanceof AuthProcessError && answer.kind === "mfaPending" && !answer.duplicate && !answer.relayed) {
369
- const now = Date.now();
370
- this.challengeTold = { anchor: now, last: now };
379
+ if (answer instanceof AuthProcessError && answer.kind === "mfaPending" && !answer.relayed) {
380
+ this.challengeToldKey = relayKey(answer.kind, answer.numberMatch);
381
+ if (!answer.duplicate) {
382
+ const now = Date.now();
383
+ this.challengeTold = { anchor: now, last: now };
384
+ }
371
385
  }
372
386
  throw answer;
373
387
  }
@@ -387,6 +401,7 @@ export class AuthRunner {
387
401
  if (!this.childDone || pending?.kind !== "mfaPending" || !told || !isQueuedSibling(told, arrivedAt))
388
402
  return null;
389
403
  told.last = arrivedAt;
404
+ this.challengeToldKey = relayKey(pending.kind, pending.numberMatch);
390
405
  // In full, not the short sibling line: if the answer before it was lost,
391
406
  // this one still carries the digits (#201). It never reaches catch in
392
407
  // run(), so it extends the chain without restarting it.
@@ -444,7 +459,8 @@ export class AuthRunner {
444
459
  // shows them: Claude Desktop sends one and displays nothing, so holding
445
460
  // the call open after announcing kept the number from the user for up
446
461
  // to 45 seconds. The retry is what joins and waits for the approval.
447
- // An automaticPending asks nothing of anyone, so keep waiting on it.
462
+ // An automaticPending asks nothing of anyone, so keep waiting on it,
463
+ // until it turns into a number: this caller was told none yet.
448
464
  if (error.kind === "mfaPending") {
449
465
  try {
450
466
  onChallenge?.(error.numberMatch);
@@ -457,7 +473,7 @@ export class AuthRunner {
457
473
  const windowMs = pollWindowMs(Date.now() - startedAt);
458
474
  if (windowMs <= 0)
459
475
  throw error;
460
- return this.awaitBackgroundChild(childDone, windowMs);
476
+ return this.awaitBackgroundChild(childDone, windowMs, null);
461
477
  }
462
478
  }
463
479
  /**
@@ -540,8 +556,9 @@ export class AuthRunner {
540
556
  ? { phase: "automatic" }
541
557
  : { phase: "approval", numberMatch: relayed.numberMatch ?? (latest.phase === "approval" ? latest.numberMatch : undefined) };
542
558
  // A challenge appearing while this caller waited on a sign-in that had
543
- // none yet is what the user needs right now: answer with it at once.
544
- if (next.phase === "approval" && latest.phase !== "approval") {
559
+ // none yet, or a changed number (Entra's expired and the owner asked
560
+ // for a new one), is what the user needs right now: answer at once.
561
+ if (next.phase === "approval" && (latest.phase !== "approval" || next.numberMatch !== latest.numberMatch)) {
545
562
  throw new AuthProcessError(...mfaPendingFailure(next.numberMatch), next.numberMatch, false, true);
546
563
  }
547
564
  latest = next;
@@ -603,6 +620,8 @@ export class AuthRunner {
603
620
  * read the number from the previous answer and is approving right now, so
604
621
  * this is the call that should carry the result. A still-unapproved
605
622
  * sign-in re-answers with the latest challenge and the caller tries again.
623
+ * A number no caller here was told yet (it changed since, or the call that
624
+ * would have carried it gave up first) is answered at once instead.
606
625
  */
607
626
  async joinBackgroundChild(childDone, startedAt) {
608
627
  if (!this.pendingChallenge && this.challengeSignal) {
@@ -610,21 +629,35 @@ export class AuthRunner {
610
629
  }
611
630
  if (!this.pendingChallenge)
612
631
  return childDone;
613
- return this.awaitBackgroundChild(childDone, Math.max(0, pollWindowMs(Date.now() - startedAt)));
632
+ return this.awaitBackgroundChild(childDone, Math.max(0, pollWindowMs(Date.now() - startedAt)), this.challengeToldKey);
614
633
  }
615
634
  /**
616
635
  * Race a background child already past its MFA challenge against a wait
617
636
  * of graceMs. The child's own outcome wins if it lands in time; otherwise
618
637
  * the caller is re-answered with the latest challenge the child reported.
638
+ * A number-match challenge other than toldKey (the one this caller already
639
+ * knows; null for none) answers at once, whether it is already there or
640
+ * arrives during the wait (issue #229): the user is looking at a number
641
+ * that no longer works.
619
642
  */
620
- awaitBackgroundChild(childDone, graceMs) {
643
+ awaitBackgroundChild(childDone, graceMs, toldKey) {
621
644
  const challenge = this.pendingChallenge;
622
645
  return new Promise((resolve, reject) => {
623
646
  let settled = false;
647
+ const watchNumber = () => {
648
+ const pending = this.pendingChallenge;
649
+ if (settled || pending?.kind !== "mfaPending" || relayKey(pending.kind, pending.numberMatch) === toldKey)
650
+ return;
651
+ settled = true;
652
+ clearTimeout(graceTimer);
653
+ this.challengeWatchers.delete(watchNumber);
654
+ reject(new AuthProcessError(...mfaPendingFailure(pending.numberMatch), pending.numberMatch));
655
+ };
624
656
  const graceTimer = setTimeout(() => {
625
657
  if (settled)
626
658
  return;
627
659
  settled = true;
660
+ this.challengeWatchers.delete(watchNumber);
628
661
  const pending = this.pendingChallenge ?? challenge;
629
662
  if (pending?.kind === "automaticPending") {
630
663
  reject(automaticPendingFailure());
@@ -634,17 +667,21 @@ export class AuthRunner {
634
667
  reject(new AuthProcessError(...mfaPendingFailure(numberMatch), numberMatch));
635
668
  }, graceMs);
636
669
  graceTimer.unref?.();
670
+ this.challengeWatchers.add(watchNumber);
671
+ watchNumber();
637
672
  childDone.then((value) => {
638
673
  if (settled)
639
674
  return;
640
675
  settled = true;
641
676
  clearTimeout(graceTimer);
677
+ this.challengeWatchers.delete(watchNumber);
642
678
  resolve(value);
643
679
  }, (error) => {
644
680
  if (settled)
645
681
  return;
646
682
  settled = true;
647
683
  clearTimeout(graceTimer);
684
+ this.challengeWatchers.delete(watchNumber);
648
685
  reject(error);
649
686
  });
650
687
  });
@@ -746,12 +783,14 @@ export class AuthRunner {
746
783
  this.challengeSignal = null;
747
784
  this.wave = null;
748
785
  this.challengeTold = null;
786
+ this.challengeToldKey = null;
749
787
  this.relayProbe = null;
750
788
  }
751
789
  });
752
790
  trackedCompletion.catch(() => { });
753
791
  this.childDone = trackedCompletion;
754
792
  this.pendingChallenge = null;
793
+ this.challengeToldKey = null;
755
794
  this.relayProbe = null;
756
795
  let resolveChallengeSignal;
757
796
  this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
@@ -798,6 +837,10 @@ export class AuthRunner {
798
837
  }
799
838
  if (firstChallenge)
800
839
  resolveChallengeSignal();
840
+ // A caller already polling is answered with a number it was not told
841
+ // (issue #229); the rest keep waiting.
842
+ for (const watch of [...this.challengeWatchers])
843
+ watch();
801
844
  const answer = new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch, false, relayed);
802
845
  if (relayed)
803
846
  this.relayProbe = answer;
@@ -929,7 +972,10 @@ export class AuthRunner {
929
972
  4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
930
973
  5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
931
974
  6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
932
- 7: mfaPendingFailure(numberMatch),
975
+ // The MFA loop gave up (MfaApprovalError) and recorded the
976
+ // cooldown: the number it showed no longer works, so a caller
977
+ // still polling hears what the next call would, not "enter 42".
978
+ 7: ["cooldown", `The MFA approval was not completed in time. Automatic MFA is paused; run ${AUTH_COMMAND} to retry immediately.`],
933
979
  };
934
980
  // Busy, but the owning sign-in's challenge came through: the caller
935
981
  // was told what to approve, so answer with that, not "busy".
@@ -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(/(?:[?&]|&amp;)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
- || /(?:[?&]|&amp;)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
@@ -191,7 +191,16 @@ export function registerGetMyGrades(server, apiClient, config) {
191
191
  authError: error,
192
192
  };
193
193
  }
194
- throw error; // Re-throw other errors
194
+ // Anything else (a 404, a 5xx, a dropped connection) still must
195
+ // not make the course vanish: the caller would read the result
196
+ // as every course's grades, with one silently missing (#245).
197
+ log("DEBUG", `get_my_grades: grades unavailable for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`, error);
198
+ return {
199
+ failed: true,
200
+ courseId: item.OrgUnit.Id,
201
+ courseName: item.OrgUnit.Name,
202
+ gradeUrl: gradebookUrl(config.baseUrl, item.OrgUnit.Id),
203
+ };
195
204
  }
196
205
  });
197
206
  const results = await Promise.allSettled(gradePromises);
@@ -202,12 +211,16 @@ export function registerGetMyGrades(server, apiClient, config) {
202
211
  const restrictedCourses = settled
203
212
  .filter((c) => c.restricted)
204
213
  .map(({ restricted: _restricted, ...rest }) => rest);
214
+ const failedCourses = settled
215
+ .filter((c) => c.failed)
216
+ .map(({ failed: _failed, ...rest }) => rest);
205
217
  const courses = settled
206
- .filter((c) => !c.authPending && !c.restricted)
218
+ .filter((c) => !c.authPending && !c.restricted && !c.failed)
207
219
  .map(({ courseId, courseName, grades }) => ({ courseId, courseName, grades }));
208
220
  log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses, ` +
209
221
  `${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled` +
210
- `${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
222
+ `${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""}` +
223
+ `${failedCourses.length > 0 ? `, ${failedCourses.length} failed` : ""})`);
211
224
  // Pending courses keep `grades: []` so callers that read
212
225
  // `courses[i].grades` still get an array; authPending marks it as
213
226
  // unchecked rather than empty. `restrictedCourses` is always present
@@ -224,15 +237,26 @@ export function registerGetMyGrades(server, apiClient, config) {
224
237
  ],
225
238
  restrictedCourses,
226
239
  };
240
+ const notices = [];
227
241
  if (pending.length > 0) {
228
242
  response.authPending = true;
229
243
  response.unavailableCourseIds = pending.map((c) => c.courseId);
230
- response.notice =
231
- "Sign-in to Brightspace is still in progress, so grades for " +
232
- `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
233
- `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_my_grades again once ` +
234
- "sign-in finishes.";
244
+ notices.push("Sign-in to Brightspace is still in progress, so grades for " +
245
+ `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
246
+ `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_my_grades again once ` +
247
+ "sign-in finishes.");
248
+ }
249
+ // Only present when something failed, so the usual response shape
250
+ // is unchanged; `courses` never includes these.
251
+ if (failedCourses.length > 0) {
252
+ response.failedCourses = failedCourses;
253
+ notices.push(`Grades for ${failedCourses.length} course(s) ` +
254
+ `(${failedCourses.map((c) => c.courseId).join(", ")}) could not be read from Brightspace, ` +
255
+ "so they are listed in failedCourses instead of courses. Do not report these courses as " +
256
+ "having no grades: open each gradeUrl, or call get_my_grades again for that courseId.");
235
257
  }
258
+ if (notices.length > 0)
259
+ response.notice = notices.join(" ");
236
260
  return toolResponse(response);
237
261
  }
238
262
  catch (error) {
@@ -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.19",
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",