brightspace-mcp-server 3.13.18 → 3.13.20

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.
@@ -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".
@@ -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) {
@@ -12,6 +12,7 @@ import { secureDownload, readBodyCapped } from "../utils/download-helpers.js";
12
12
  import { DownloadError } from "../utils/download-errors.js";
13
13
  import { MAX_FILE_SIZE } from "../utils/file-validator.js";
14
14
  import { extractPdfText } from "../utils/pdf-extractor.js";
15
+ import { parseContentDispositionFilename } from "./download-file.js";
15
16
  import { log } from "../utils/logger.js";
16
17
  import path from "node:path";
17
18
  import fs from "node:fs/promises";
@@ -172,10 +173,8 @@ export function registerGetSyllabus(server, apiClient) {
172
173
  }
173
174
  // Get filename from Content-Disposition header
174
175
  const disposition = response.headers.get("Content-Disposition") ?? "";
175
- const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
176
- if (match?.[1]) {
177
- attachmentFilename = match[1].replace(/['"]/g, "");
178
- }
176
+ attachmentFilename =
177
+ parseContentDispositionFilename(disposition) || attachmentFilename;
179
178
  // Download body as buffer
180
179
  // Capped read: Content-Length can be missing or understated.
181
180
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.13.18",
3
+ "version": "3.13.20",
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",