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.
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
544
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
176
|
-
|
|
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.
|
|
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",
|