brightspace-mcp-server 5.1.28 → 5.1.30

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.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT: see LICENSE file for details.
5
+ */
6
+ import { BrowserInstallError } from "../utils/browser-install.js";
7
+ import { NativeCredentialStoreError } from "./credential-store.js";
8
+ import { MfaApprovalError } from "./sso-flow.js";
9
+ /**
10
+ * The exit code auth-cli ends with for a failed sign-in. AuthRunner
11
+ * maps each code back to a failure kind (see its `failures` table), so the two
12
+ * must stay in step: 2 busy, 3 cooldown, 4 unsupported, 5 secureStorage,
13
+ * 6 transport, 7 MFA not approved, 8/9 browser install, 10 stuck, 1 anything else.
14
+ */
15
+ export function authExitCode(error) {
16
+ const code = error?.code;
17
+ return error instanceof BrowserInstallError ? (error.dependencyMissing ? 9 : 8)
18
+ : error instanceof NativeCredentialStoreError ? 5
19
+ : code === "AUTH_IN_PROGRESS" ? 2
20
+ : code === "AUTH_COOLDOWN" ? 3
21
+ : code === "AUTH_UNSUPPORTED" ? 4
22
+ : code === "AUTH_TRANSPORT" ? 6
23
+ : code === "AUTH_STUCK" ? 10
24
+ : error instanceof MfaApprovalError ? 7 : 1;
25
+ }
@@ -134,6 +134,15 @@ function mfaPendingFailure(numberMatch) {
134
134
  * send the user looking for a prompt that will never arrive.
135
135
  */
136
136
  const automaticPendingFailure = (relayed = false) => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.", undefined, false, relayed);
137
+ /** What every caller is told when the school's sign-in page never settled. */
138
+ const STUCK_MESSAGE = "Sign-in got stuck on the school's sign-in page and stopped. Call sign_in again.";
139
+ /**
140
+ * Stuck failures from a child that never showed the user anything (no number,
141
+ * no automatic-code notice, no held prompt). Only these are retried by
142
+ * themselves: after a prompt reached the user's phone, a second sign-in could
143
+ * send a second one.
144
+ */
145
+ const retryableStuck = new WeakSet();
137
146
  /**
138
147
  * Microsoft is refusing a new prompt for the account until an earlier one
139
148
  * expires. Nothing is on the user's phone to approve, and calling again does
@@ -733,7 +742,16 @@ export class AuthRunner {
733
742
  // prompt starts a fresh attempt rather than inheriting the stale
734
743
  // rejection. Ownership is checked because a caller could in principle
735
744
  // clear the latch while this flow is still running.
736
- const flow = this.spawnAuth().finally(() => {
745
+ const flow = this.spawnAuth().catch((error) => {
746
+ // The sign-in page jammed before anything reached the user: one more
747
+ // try, in the same call. Anything else (a number shown, a prompt sent)
748
+ // is reported as it is.
749
+ if (error instanceof AuthProcessError && retryableStuck.has(error)) {
750
+ log("WARN", "Sign-in got stuck before any challenge; trying once more");
751
+ return this.spawnAuth();
752
+ }
753
+ throw error;
754
+ }).finally(() => {
737
755
  if (this.inFlight === flow)
738
756
  this.inFlight = null;
739
757
  });
@@ -1163,6 +1181,9 @@ export class AuthRunner {
1163
1181
  4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
1164
1182
  5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
1165
1183
  6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
1184
+ // The page never settled (BrowserAuthStuckError): Brightspace was
1185
+ // reachable, so this is not the network. runOnce retries it once.
1186
+ 10: ["stuck", STUCK_MESSAGE],
1166
1187
  // The MFA loop gave up (MfaApprovalError) and recorded the
1167
1188
  // cooldown: the number it showed no longer works, so a caller
1168
1189
  // still polling hears what the next call would, not "enter 42".
@@ -1187,7 +1208,10 @@ export class AuthRunner {
1187
1208
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
1188
1209
  kill("SIGKILL");
1189
1210
  const challenged = kind === "mfaPending" || kind === "automaticPending" || kind === "inProgress";
1190
- finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged));
1211
+ const failure = new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged);
1212
+ if (kind === "stuck" && !challengeSeen && !automaticSeen && !heldSeen && !relayed)
1213
+ retryableStuck.add(failure);
1214
+ finishChild(failure);
1191
1215
  }
1192
1216
  });
1193
1217
  });
@@ -18,6 +18,9 @@ import { launchWithBrowserRecovery } from "../utils/browser-install.js";
18
18
  import { signInCommand } from "../utils/commands.js";
19
19
  const SILENT_SSO_TIMEOUT_MS = 30000;
20
20
  const SILENT_SSO_POLL_MS = 1000;
21
+ /** How long navigateAndLogin keeps looking for a challenge once silent SSO ends on a page it does not recognise. */
22
+ const CHALLENGE_SETTLE_POLLS = 5;
23
+ const LOAD_STATE_WAIT_MS = 2000;
21
24
  const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
22
25
  const VISIBLE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
23
26
  const SILENT_SSO = {
@@ -50,6 +53,29 @@ export class BrowserAuthTransportError extends Error {
50
53
  this.name = "BrowserAuthTransportError";
51
54
  }
52
55
  }
56
+ /**
57
+ * The school's sign-in page never settled on anything the sign-in can act on:
58
+ * Brightspace was reachable, the page just kept moving or showed something
59
+ * unrecognized. Still a BrowserAuthTransportError for existing callers, but
60
+ * its own code, so the user is told to call sign_in again rather than that the
61
+ * network is down.
62
+ */
63
+ export class BrowserAuthStuckError extends BrowserAuthTransportError {
64
+ code = "AUTH_STUCK";
65
+ constructor(message, options) {
66
+ super(message, options);
67
+ this.name = "BrowserAuthStuckError";
68
+ }
69
+ }
70
+ /**
71
+ * Playwright's errors for a page that navigated while it was being read. They
72
+ * are the normal state of a redirect chain, not a dead browser: the next poll
73
+ * reads the page that arrived.
74
+ */
75
+ const NAVIGATION_RACE = /Execution context was destroyed|because of a navigation|Cannot find context with specified id|frame was detached/i;
76
+ export function isNavigationRace(error) {
77
+ return error instanceof Error && NAVIGATION_RACE.test(error.message);
78
+ }
53
79
  export class BrowserAuth {
54
80
  config;
55
81
  ssoFlow;
@@ -409,9 +435,22 @@ export class BrowserAuth {
409
435
  log("INFO", "Saved session is active");
410
436
  return true;
411
437
  }
412
- const pendingMfa = await this.hasMfaChallenge(page);
413
- const credentialPrompt = await this.hasCredentialPrompt(page);
438
+ let pendingMfa = await this.hasMfaChallenge(page);
439
+ let credentialPrompt = await this.hasCredentialPrompt(page);
414
440
  const hasCredentials = this.ssoFlow.hasCredentials();
441
+ // A page caught mid-redirect shows nothing yet; give it a few more seconds
442
+ // before calling it unsupported. Without credentials there is nothing to
443
+ // do with a challenge anyway, so that path does not wait.
444
+ for (let i = 0; hasCredentials && this.config.headless !== false && !pendingMfa && !credentialPrompt && i < CHALLENGE_SETTLE_POLLS; i++) {
445
+ // A closed browser is classified by the probe below, not by the wait.
446
+ await page.waitForTimeout(SILENT_SSO_POLL_MS).catch(() => { });
447
+ if (await this.hasLiveSession(page)) {
448
+ log("INFO", "Saved session is active");
449
+ return true;
450
+ }
451
+ pendingMfa = await this.hasMfaChallenge(page);
452
+ credentialPrompt = await this.hasCredentialPrompt(page);
453
+ }
415
454
  if (!hasCredentials && !pendingMfa) {
416
455
  if (this.config.headless === false) {
417
456
  await this.waitForVisibleLogin(page);
@@ -424,7 +463,7 @@ export class BrowserAuth {
424
463
  await this.waitForVisibleLogin(page);
425
464
  return false;
426
465
  }
427
- throw new BrowserAuthTransportError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
466
+ throw new BrowserAuthStuckError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
428
467
  }
429
468
  const loginStartedAt = Date.now();
430
469
  this.challengeSeenAt = undefined;
@@ -478,7 +517,10 @@ export class BrowserAuth {
478
517
  let accountHintAttempted = false;
479
518
  let passwordPromptPolls = 0;
480
519
  let passwordSwitchAttempted = false;
481
- do {
520
+ // The poll that starts at or after the deadline is the last: it still looks
521
+ // for a challenge, so a password field first seen on it is acted on.
522
+ for (;;) {
523
+ const finalPoll = Date.now() >= deadline;
482
524
  if (await this.hasLiveSession(page))
483
525
  return true;
484
526
  // Only a visible supported challenge justifies entering credentials or
@@ -520,7 +562,7 @@ export class BrowserAuth {
520
562
  // Entra can briefly expose a password-shaped control while its email
521
563
  // view initializes. Brightspace Bar polls the evolving page instead of
522
564
  // concluding from that first transient snapshot.
523
- if (passwordPromptPolls >= 2) {
565
+ if (passwordPromptPolls >= 2 || finalPoll) {
524
566
  log("INFO", "The identity provider requires a login challenge");
525
567
  return false;
526
568
  }
@@ -528,6 +570,8 @@ export class BrowserAuth {
528
570
  continue;
529
571
  }
530
572
  passwordPromptPolls = 0;
573
+ if (finalPoll)
574
+ break;
531
575
  try {
532
576
  await this.clickSilentSurfaces(page);
533
577
  await page.waitForTimeout(SILENT_SSO_POLL_MS);
@@ -535,12 +579,18 @@ export class BrowserAuth {
535
579
  catch (error) {
536
580
  if (error instanceof BrowserAuthError || error instanceof BrowserAuthTransportError)
537
581
  throw error;
582
+ // The page navigated under the click: read the page that arrived.
583
+ if (isNavigationRace(error)) {
584
+ log("DEBUG", "The sign-in page navigated during a poll; polling again");
585
+ await page.waitForTimeout(SILENT_SSO_POLL_MS).catch(() => { });
586
+ continue;
587
+ }
538
588
  throw new BrowserAuthTransportError("Silent sign-in could not reach the identity provider. Saved state is preserved.", { cause: error });
539
589
  }
540
- } while (Date.now() < deadline);
590
+ }
541
591
  if (this.config.headless === false)
542
592
  return false;
543
- throw new BrowserAuthTransportError("Silent sign-in did not reach Brightspace or a supported login challenge within 30 seconds. Retry shortly; saved state is preserved.");
593
+ throw new BrowserAuthStuckError("Silent sign-in did not reach Brightspace or a supported login challenge within 30 seconds. Retry shortly; saved state is preserved.");
544
594
  }
545
595
  async hasCredentialPrompt(page) {
546
596
  return await this.isAnyOnScreen(page, SILENT_SSO.emailFields) ||
@@ -572,6 +622,18 @@ export class BrowserAuth {
572
622
  * cookie and a reachable D2L JS context are required.
573
623
  */
574
624
  async hasLiveSession(page) {
625
+ // A page caught mid-navigation is read once more after the page that
626
+ // arrived has loaded: the check right after a sign-in has no next poll, so
627
+ // a race there would otherwise fail a login the user just approved.
628
+ for (let attempt = 1;; attempt++) {
629
+ const live = await this.readLiveSession(page);
630
+ if (live !== "raced")
631
+ return live;
632
+ if (attempt >= 2)
633
+ return false;
634
+ }
635
+ }
636
+ async readLiveSession(page) {
575
637
  try {
576
638
  const location = new URL(page.url());
577
639
  if (location.origin !== new URL(this.config.baseUrl).origin)
@@ -584,12 +646,18 @@ export class BrowserAuth {
584
646
  if (!cookies.some((c) => c.name === "d2lSessionVal" && Boolean(c.value))) {
585
647
  return false;
586
648
  }
649
+ // An expired session answers /d2l/home with a stub that redirects to the
650
+ // login page while already carrying the cookie; read it once it loads.
651
+ await page.waitForLoadState("domcontentloaded", { timeout: LOAD_STATE_WAIT_MS }).catch(() => { });
587
652
  return await page.evaluate(() => {
588
653
  const d2l = window.D2L;
589
654
  return d2l !== undefined && Boolean(d2l.LP);
590
655
  });
591
656
  }
592
657
  catch (error) {
658
+ // Mid-navigation is "not live yet", not a dead browser.
659
+ if (isNavigationRace(error))
660
+ return "raced";
593
661
  throw new BrowserAuthTransportError("The browser could not verify the saved Brightspace session. Saved state is preserved.", { cause: error });
594
662
  }
595
663
  }
@@ -644,6 +712,8 @@ export class BrowserAuth {
644
712
  return await page.locator(selector).first().isVisible();
645
713
  }
646
714
  catch (error) {
715
+ if (isNavigationRace(error))
716
+ return false;
647
717
  throw new BrowserAuthTransportError("The browser could not inspect the sign-in page. Saved state is preserved.", { cause: error });
648
718
  }
649
719
  }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT: see LICENSE file for details.
5
+ */
6
+ const MAX_CAUSES = 4;
7
+ const MAX_LINE = 200;
8
+ /**
9
+ * One safe line per link of an error's `cause` chain, outermost first, so a
10
+ * failure reported as a wrapper ("could not verify the session") also logs
11
+ * what actually broke. Only the first line of each message is kept, any URL
12
+ * is cut to its origin (query strings and paths can carry tokens), and the
13
+ * line is length-capped. The top error itself is not included.
14
+ */
15
+ export function causeSummaries(error) {
16
+ const lines = [];
17
+ const seen = new Set([error]);
18
+ let current = error?.cause;
19
+ while (current !== undefined && current !== null && lines.length < MAX_CAUSES && !seen.has(current)) {
20
+ seen.add(current);
21
+ const raw = current instanceof Error ? current.message : typeof current === "string" ? current : "";
22
+ const name = current instanceof Error && current.name !== "Error" ? `${current.name}: ` : "";
23
+ const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
24
+ const safe = `${name}${firstLine}`
25
+ .replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#)'"]+[^\s)'"]*/gi, (url) => {
26
+ try {
27
+ return new URL(url).origin;
28
+ }
29
+ catch {
30
+ return "[url]";
31
+ }
32
+ })
33
+ .slice(0, MAX_LINE);
34
+ lines.push(safe || "(no message)");
35
+ current = current.cause;
36
+ }
37
+ return lines;
38
+ }
package/build/auth-cli.js CHANGED
@@ -8,7 +8,6 @@ import dotenv from "dotenv";
8
8
  import { loadConfig } from "./utils/config.js";
9
9
  import { BrowserAuth, TokenManager } from "./auth/index.js";
10
10
  import { MfaApprovalError } from "./auth/sso-flow.js";
11
- import { NativeCredentialStoreError } from "./auth/credential-store.js";
12
11
  import { retireLegacyProfile } from "./auth/legacy-profile.js";
13
12
  import { formatPhaseMarker } from "./auth/auth-phases.js";
14
13
  import { AuthenticationInProgressError } from "./auth/auth-lock.js";
@@ -20,6 +19,8 @@ import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
20
19
  import { reexecLatestIfStale } from "./utils/self-update.js";
21
20
  import { detectSkew } from "./utils/install-sites.js";
22
21
  import { BrowserInstallError } from "./utils/browser-install.js";
22
+ import { causeSummaries } from "./auth/error-cause.js";
23
+ import { authExitCode } from "./auth/auth-exit-code.js";
23
24
  dotenv.config({ quiet: true });
24
25
  const pkg = JSON.parse(readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
25
26
  async function requestMfaCode() {
@@ -104,7 +105,6 @@ async function main() {
104
105
  console.error("\nAuthentication successful. Your encrypted session is ready for the MCP server.");
105
106
  }
106
107
  catch (error) {
107
- const code = error?.code;
108
108
  // A pending number-match approval gets its own exit code so the caller
109
109
  // can tell "still waiting on the phone" apart from a hard failure, and
110
110
  // its own stdout marker so the digits — already validated to 1-3 digits
@@ -122,14 +122,10 @@ async function main() {
122
122
  for (const line of relayedLockLines(error.challenge, error.ownerMode))
123
123
  console.log(line);
124
124
  }
125
- process.exitCode = error instanceof BrowserInstallError ? (error.dependencyMissing ? 9 : 8)
126
- : error instanceof NativeCredentialStoreError ? 5
127
- : code === "AUTH_IN_PROGRESS" ? 2
128
- : code === "AUTH_COOLDOWN" ? 3
129
- : code === "AUTH_UNSUPPORTED" ? 4
130
- : code === "AUTH_TRANSPORT" ? 6
131
- : error instanceof MfaApprovalError ? 7 : 1;
125
+ process.exitCode = authExitCode(error);
132
126
  console.error("\nAuthentication failed:", error instanceof Error ? error.message : "Unknown authentication error");
127
+ for (const cause of causeSummaries(error))
128
+ console.error(` caused by: ${cause}`);
133
129
  if (!(error instanceof BrowserInstallError)) {
134
130
  console.error(`Run \`${SETUP_COMMAND}\` to update saved credentials.`);
135
131
  console.error(`Run \`${AUTH_COMMAND}\` to retry explicitly. This bypasses the automatic MFA cooldown.`);
@@ -164,7 +164,7 @@ export const GetServerInfoSchema = z.object({});
164
164
  export const SignOutSchema = z.object({});
165
165
  export const SignInSchema = z.object({
166
166
  visible: z.boolean().optional()
167
- .describe("Open a browser window for the user to sign in by hand. Omit to decide automatically: a window opens only when no password is saved on this computer, because a sign-in with no window has nothing to type into the school's form and would only time out. Pass true when a background sign-in has already failed or the school's page needs a step it cannot do, false to insist on a background sign-in."),
167
+ .describe("Open a browser window for the user to sign in by hand. Omit it: a window then opens by itself only when no password is saved on this computer, because a sign-in with no window has nothing to type into the school's form and would only time out. Pass true ONLY when the user asks for a window, or an error says a step needs the user's own hands (an unsupported sign-in, or no saved password). Do NOT pass true after a \"could not be reached\", stuck, or timeout failure: just call sign_in again, because the saved password is typed in the background and a window adds nothing. Pass false to insist on a background sign-in."),
168
168
  });
169
169
  // ── Instructor / TA dropbox tools ──────────────────────────────────────────
170
170
  // Read-only, instructor/TA-side views of a course's dropbox (assignment)
@@ -14,7 +14,10 @@ const ENV_CREDENTIAL = "Nothing to do. This server authenticates with a credenti
14
14
  const NO_RUNNER = `This server cannot start a sign-in itself. Tell the user to run \`${AUTH_COMMAND}\` in a terminal, prefixed with D2L_HEADLESS=false if they need to see the sign-in page and finish it by hand.`;
15
15
  const SUCCESS = "Signed in to Brightspace. The session is saved on this computer, so other Brightspace apps here can use it too.";
16
16
  /** What an open window means for the caller: this one waits on a person, not on a retry. */
17
- const WINDOW_OPEN = "A browser window is opening on the user's screen for them to sign in by hand. Tell the user to complete it, including any 2FA, then ask them to say when they are done.";
17
+ const WINDOW_FINISH = "Tell the user to complete it, including any 2FA, then ask them to say when they are done.";
18
+ const WINDOW_ASKED = `A browser window is opening on the user's screen because a window was requested. ${WINDOW_FINISH}`;
19
+ const WINDOW_NO_PASSWORD = `A browser window is opening on the user's screen because no password is saved on this computer, so the user has to type it themselves. ${WINDOW_FINISH}`;
20
+ const WINDOW_JOINED = "No window was opened: a sign-in was already running, and this call joined it.";
18
21
  const NEEDS_PASSWORD_FOR_HEADLESS = `No password is saved on this computer, so a sign-in with no window has nothing to type into the school's form and would only wait and time out. Either call sign_in again with visible true, or tell the user to run \`${SETUP_COMMAND}\` to save one.`;
19
22
  /**
20
23
  * A sign-in still working is progress, not a failure, so these are not marked
@@ -58,7 +61,7 @@ export function registerSignIn(server, config, tokenManager, authRunner) {
58
61
  server.registerTool("sign_in", {
59
62
  title: "Sign In to Brightspace",
60
63
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
61
- description: "Sign in to Brightspace on this computer so the other tools work. Does nothing and says so if the saved session is already valid, so it is safe to call first when you are unsure. Opens a browser window for the user to sign in by hand when no password is saved (nothing to type means a windowless sign-in can only time out); otherwise it signs in in the background and reports the 2FA number for the user to approve on their phone. Call this when a Brightspace tool failed because of authentication, or when the user asks to sign in or log in to Brightspace. Every answer says what to do next, including whether to call again.",
64
+ description: "Sign in to Brightspace on this computer so the other tools work. Does nothing and says so if the saved session is already valid, so it is safe to call first when you are unsure. Opens a browser window for the user to sign in by hand when no password is saved (nothing to type means a windowless sign-in can only time out); otherwise it signs in in the background and reports the 2FA number for the user to approve on their phone. A window is for the user's own hands only (no saved password, or when the user asks to see one): after a \"could not be reached\", stuck, or timeout answer, call sign_in again without visible. Call this when a Brightspace tool failed because of authentication, or when the user asks to sign in or log in to Brightspace. Every answer says what to do next, including whether to call again.",
62
65
  inputSchema: SignInSchema,
63
66
  }, async ({ visible }) => {
64
67
  if (config.envAccessToken || config.envSessionCookie)
@@ -98,7 +101,8 @@ export function registerSignIn(server, config, tokenManager, authRunner) {
98
101
  catch (error) {
99
102
  if (error instanceof AuthProcessError) {
100
103
  const guidance = authPendingNotice(error);
101
- return text(windowOpened ? `${WINDOW_OPEN} ${guidance}` : guidance, !STILL_WORKING.has(error.kind));
104
+ return text(windowOpened ? `${visible === true ? WINDOW_ASKED : WINDOW_NO_PASSWORD} ${guidance}`
105
+ : wantWindow ? `${WINDOW_JOINED} ${guidance}` : guidance, !STILL_WORKING.has(error.kind));
102
106
  }
103
107
  // Everything else goes through the house path, which logs it and
104
108
  // keeps stack traces and raw responses out of the answer.
@@ -139,9 +139,13 @@ const AUTH_FAILURE_GUIDANCE = {
139
139
  secureStorage: "The operating system credential store is locked or unavailable, so the saved " +
140
140
  "password could not be read. Unlock your keychain or keyring, then try again.",
141
141
  transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
142
- "Check your connection and try again in a few minutes.",
142
+ "Call sign_in once more without visible; forcing a browser window does not help. " +
143
+ "If it fails again, ask the user to check their connection and try again in a few minutes.",
144
+ stuck: "Sign-in got stuck on the school's sign-in page and stopped; the saved session was kept. " +
145
+ "Call sign_in again — no window is needed.",
143
146
  timeout: "The sign-in did not finish in time, usually a missed MFA prompt, and has stopped: the prompt it " +
144
147
  "sent no longer works. The next call starts a new sign-in, or says automatic sign-in is paused. " +
148
+ "Don't open a window to retry; a window does not skip the prompt. " +
145
149
  `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
146
150
  failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
147
151
  "or `brightspace-setup` if your saved school or username is wrong.",
@@ -103,7 +103,7 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
103
103
  if (["success", "error", "sessionExpired", "transport"].includes(fields.outcome ?? ""))
104
104
  record.outcome = fields.outcome;
105
105
  if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending",
106
- "automaticPending", "inProgress", "mfaHeld"].includes(fields.reason ?? ""))
106
+ "automaticPending", "inProgress", "mfaHeld", "stuck"].includes(fields.reason ?? ""))
107
107
  record.reason = fields.reason;
108
108
  if (AUTH_PHASES.includes(fields.phase ?? ""))
109
109
  record.phase = fields.phase;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.28",
3
+ "version": "5.1.30",
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",