brightspace-mcp-server 5.1.20 → 5.1.21

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
@@ -159,7 +159,7 @@ npx -y brightspace-mcp-server@latest auth
159
159
 
160
160
  **Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
161
161
 
162
- **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. If a sign-in is in progress, it refuses and deletes nothing.
162
+ **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Asking your AI app also cancels a sign-in still waiting for you to approve it; the `auth --logout` command instead refuses and deletes nothing while a sign-in is in progress. Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
163
163
 
164
164
  How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
165
165
 
@@ -116,6 +116,17 @@ export async function inspectLockOwner(lockPath) {
116
116
  return undefined;
117
117
  return { mode: owner.mode === "automatic" ? "automatic" : "explicit", gone: lockOps.isDead(owner) };
118
118
  }
119
+ /**
120
+ * The PID of a live "automatic" owner (a background AuthRunner child), or
121
+ * undefined when the lock is free, its owner is gone, or it is held some other
122
+ * way. Used by sign_out to cancel another app's waiting sign-in.
123
+ */
124
+ export async function liveAutomaticOwner(lockPath) {
125
+ const owner = await readOwner(lockPath);
126
+ if (!owner || owner.mode !== "automatic" || lockOps.isDead(owner))
127
+ return undefined;
128
+ return owner.pid;
129
+ }
119
130
  /** Remove only our fixed metadata names, after the directory was retired. */
120
131
  async function cleanRetired(lockPath) {
121
132
  await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
@@ -13,8 +13,8 @@ import { AUTH_COMMAND } from "../utils/commands.js";
13
13
  import { dependencyRepairHint, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
14
14
  import { devActivity, newRecoveryId, RECOVERY_ID_ENV } from "../utils/dev-activity.js";
15
15
  import { parsePhaseMarker } from "./auth-phases.js";
16
- import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
17
- import { inspectLockOwner } from "./auth-lock.js";
16
+ import { AUTOMATIC_PENDING_MARKER, MFA_HELD_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
17
+ import { inspectLockOwner, liveAutomaticOwner } from "./auth-lock.js";
18
18
  /**
19
19
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
20
20
  * which is five minutes: a person has to find their phone, unlock it, and read
@@ -134,6 +134,14 @@ 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
+ /**
138
+ * Microsoft is refusing a new prompt for the account until an earlier one
139
+ * expires. Nothing is on the user's phone to approve, and calling again does
140
+ * not help sooner, so it is neither "approve now" nor "still starting".
141
+ */
142
+ const mfaHeldFailure = () => new AuthProcessError("mfaHeld", "Microsoft is holding a new prompt until an earlier one expires. Try again in a minute.");
143
+ /** What every caller of a sign-in that was stopped from outside (sign_out, a kill) is answered. */
144
+ const cancelledFailure = (relayed = false) => new AuthProcessError("cancelled", "The sign-in was cancelled before it finished.", undefined, false, relayed);
137
145
  export class AuthProcessError extends AuthError {
138
146
  kind;
139
147
  numberMatch;
@@ -200,6 +208,80 @@ function descendantPids(parentPid) {
200
208
  visit(parentPid);
201
209
  return descendants;
202
210
  }
211
+ /**
212
+ * SIGKILL a sign-in child, its Chromium descendants and its process group.
213
+ * SIGKILL rather than SIGTERM: a graceful stop runs the MFA loop's failure
214
+ * path, which records the cooldown, and the user never had a chance to answer.
215
+ */
216
+ function forceStop(pid) {
217
+ if (process.platform === "win32") {
218
+ try {
219
+ execFileSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5000 });
220
+ }
221
+ catch { /* Already exited. */ }
222
+ return;
223
+ }
224
+ try {
225
+ for (const descendant of descendantPids(pid)) {
226
+ try {
227
+ process.kill(descendant, "SIGKILL");
228
+ }
229
+ catch { /* Already exited. */ }
230
+ }
231
+ }
232
+ catch { /* Still terminate the owned process group if ps is unavailable. */ }
233
+ try {
234
+ process.kill(-pid, "SIGKILL");
235
+ }
236
+ catch { /* Not a group leader, or gone. */ }
237
+ try {
238
+ process.kill(pid, "SIGKILL");
239
+ }
240
+ catch { /* Already exited. */ }
241
+ }
242
+ /** How long a cancel waits for a stopped sign-in to actually exit. */
243
+ const CANCEL_WAIT_MS = 5000;
244
+ /**
245
+ * Stop a background sign-in that ANOTHER server process on this computer
246
+ * started and that holds the cross-process lock, so sign_out can cancel a
247
+ * sign-in whichever app began it. Only an "automatic" owner is stopped (a
248
+ * person's `auth` run in a terminal never is), and only after its command
249
+ * line confirms it is this package's sign-in child: the PID comes from a file,
250
+ * and a PID the system has since reused must never be killed. That check
251
+ * needs ps, so on Windows nothing is stopped and sign_out reports the sign-in
252
+ * as one it cannot cancel. Its own server's waiting calls are answered
253
+ * "cancelled" (see the close handler in spawnAuth). The dead owner leaves a
254
+ * stale lock the next lock holder reclaims. Resolves true once it has exited.
255
+ */
256
+ export async function stopAutomaticSignIn(lockPath) {
257
+ if (process.platform === "win32")
258
+ return false;
259
+ const pid = await liveAutomaticOwner(lockPath);
260
+ if (pid === undefined)
261
+ return false;
262
+ let command = "";
263
+ try {
264
+ command = execFileSync("ps", ["-o", "command=", "-p", String(pid)], { encoding: "utf8", timeout: 1000 });
265
+ }
266
+ catch {
267
+ return false;
268
+ }
269
+ if (!/auth-cli\.js\b/.test(command) || !command.includes("--automatic"))
270
+ return false;
271
+ log("WARN", `Cancelling another app's background sign-in (pid ${pid}) for sign_out`);
272
+ forceStop(pid);
273
+ const deadline = Date.now() + CANCEL_WAIT_MS;
274
+ while (Date.now() < deadline) {
275
+ try {
276
+ process.kill(pid, 0);
277
+ }
278
+ catch {
279
+ return true;
280
+ }
281
+ await new Promise((resolve) => setTimeout(resolve, 50));
282
+ }
283
+ return false;
284
+ }
203
285
  /**
204
286
  * Forward a child stream to the server log, one line at a time.
205
287
  *
@@ -322,6 +404,8 @@ export class AuthRunner {
322
404
  * which can linger for seconds after it has said everything it will say.
323
405
  */
324
406
  relayProbe = null;
407
+ /** Stops the current child and resolves once it has exited; null between children. */
408
+ cancelChild = null;
325
409
  scriptPath;
326
410
  timeoutMs;
327
411
  onProgress;
@@ -344,6 +428,22 @@ export class AuthRunner {
344
428
  busy() {
345
429
  return this.inFlight !== null || this.childDone !== null;
346
430
  }
431
+ /**
432
+ * Cancel this process's sign-in, if one is running: stop the child at once
433
+ * (no MFA cooldown is recorded, see forceStop) and answer every call waiting
434
+ * on it, here or behind the shared HTTP server, with a "cancelled" error
435
+ * instead of leaving it to time out. The next call starts a new sign-in and
436
+ * sends a new prompt. Resolves true once the child has exited, so the
437
+ * cross-process lock it held is reclaimable; false when nothing was running.
438
+ */
439
+ async cancel() {
440
+ const cancelChild = this.cancelChild;
441
+ if (!cancelChild)
442
+ return false;
443
+ log("INFO", "Cancelling the background sign-in for sign_out");
444
+ await cancelChild();
445
+ return true;
446
+ }
347
447
  /**
348
448
  * Authenticate, joining the login this process already started if there is
349
449
  * one. Returns true on success and throws a useful error on failure.
@@ -547,10 +647,11 @@ export class AuthRunner {
547
647
  this.relayTold.clear();
548
648
  return true;
549
649
  }
550
- // The owner died without releasing its lock. Waiting longer only runs
551
- // the clock down; the next call's sign-in reclaims the dead lock.
650
+ // The owner died without releasing its lock: cancelled (sign_out) or
651
+ // crashed. Waiting longer only runs the clock down; the next call's
652
+ // sign-in reclaims the dead lock.
552
653
  if ((await inspectLockOwner(lockPath))?.gone)
553
- throw new AuthProcessError("inProgress", STILL_SIGNING_IN);
654
+ throw cancelledFailure(true);
554
655
  const relayed = await relayChallenge(lockPath);
555
656
  if (relayed) {
556
657
  const next = relayed.kind === "automatic"
@@ -664,6 +765,10 @@ export class AuthRunner {
664
765
  reject(automaticPendingFailure());
665
766
  return;
666
767
  }
768
+ if (pending?.kind === "mfaHeld") {
769
+ reject(mfaHeldFailure());
770
+ return;
771
+ }
667
772
  const numberMatch = this.pendingChallenge?.numberMatch ?? challenge?.numberMatch;
668
773
  reject(new AuthProcessError(...mfaPendingFailure(numberMatch), numberMatch));
669
774
  }, graceMs);
@@ -725,6 +830,8 @@ export class AuthRunner {
725
830
  let relayed = false;
726
831
  /** The child reported it is answering its own code (see the marker below). */
727
832
  let automaticSeen = false;
833
+ /** Microsoft held back a new prompt at some point in this sign-in (MFA_HELD_MARKER). */
834
+ let heldSeen = false;
728
835
  /** watchAttention is armed once per child; a second timer could kill a live sign-in. */
729
836
  let attentionWatched = false;
730
837
  let abandonTimer;
@@ -759,6 +866,7 @@ export class AuthRunner {
759
866
  };
760
867
  const onExit = () => kill("SIGTERM");
761
868
  process.once("exit", onExit);
869
+ const exited = new Promise((resolve) => child.once("exit", () => resolve()));
762
870
  const settleCaller = (error) => {
763
871
  if (callerSettled)
764
872
  return;
@@ -788,10 +896,21 @@ export class AuthRunner {
788
896
  this.challengeTold = null;
789
897
  this.challengeToldKey = null;
790
898
  this.relayProbe = null;
899
+ this.cancelChild = null;
791
900
  }
792
901
  });
793
902
  trackedCompletion.catch(() => { });
794
903
  this.childDone = trackedCompletion;
904
+ this.cancelChild = async () => {
905
+ if (!childFinished) {
906
+ if (child.pid)
907
+ forceStop(child.pid);
908
+ else
909
+ child.kill("SIGKILL");
910
+ finishChild(cancelledFailure());
911
+ }
912
+ await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, CANCEL_WAIT_MS).unref?.())]);
913
+ };
795
914
  this.pendingChallenge = null;
796
915
  this.challengeToldKey = null;
797
916
  this.relayProbe = null;
@@ -831,7 +950,9 @@ export class AuthRunner {
831
950
  // An automatic sign-in that later shows a real approval challenge (it
832
951
  // fell back, or Entra changed its mind) must be upgraded, so a joiner
833
952
  // is told the number instead of "still working".
834
- const firstChallenge = this.pendingChallenge === null || this.pendingChallenge.kind === "automaticPending";
953
+ // A hold is replaced by any real challenge, numbered or not.
954
+ const firstChallenge = this.pendingChallenge === null || this.pendingChallenge.kind === "automaticPending" ||
955
+ this.pendingChallenge.kind === "mfaHeld";
835
956
  startWatching();
836
957
  if (firstChallenge)
837
958
  devActivity("mfa_observed", { elapsedMs: Date.now() - started, recoveryId });
@@ -868,6 +989,20 @@ export class AuthRunner {
868
989
  this.relayProbe = answer;
869
990
  settleCaller(answer);
870
991
  };
992
+ /**
993
+ * Microsoft refused a new prompt while an earlier one is open. Answer
994
+ * the waiting caller now with the real wait; a number that appears
995
+ * later replaces this (it is published like any challenge).
996
+ */
997
+ const publishHeld = () => {
998
+ heldSeen = true;
999
+ if (this.pendingChallenge?.kind === "mfaPending")
1000
+ return;
1001
+ this.pendingChallenge = { kind: "mfaHeld" };
1002
+ startWatching();
1003
+ resolveChallengeSignal();
1004
+ settleCaller(mfaHeldFailure());
1005
+ };
871
1006
  // Runs once the child is actually done. Settles the caller too, if an
872
1007
  // early answer had not already done so; otherwise this is just the
873
1008
  // background sign-in finishing, which only childDone's joiner sees.
@@ -929,6 +1064,9 @@ export class AuthRunner {
929
1064
  else if (line === AUTOMATIC_PENDING_MARKER) {
930
1065
  publishAutomaticProgress();
931
1066
  }
1067
+ else if (line === MFA_HELD_MARKER) {
1068
+ publishHeld();
1069
+ }
932
1070
  else if (line === RELAYED_CHALLENGE_MARKER) {
933
1071
  // Recorded as the line is read, so it is set before the challenge
934
1072
  // marker that follows it settles the caller.
@@ -957,10 +1095,16 @@ export class AuthRunner {
957
1095
  kill("SIGKILL");
958
1096
  finishChild(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
959
1097
  });
960
- child.on("close", (code) => {
1098
+ child.on("close", (code, signal) => {
961
1099
  if (childFinished)
962
1100
  return;
963
- if (timedOut) {
1101
+ if (!timedOut && signal === "SIGKILL") {
1102
+ // Stopped from outside this process: another app's sign_out (see
1103
+ // stopAutomaticSignIn), or someone killed it. Either way it will not
1104
+ // finish, and the next call starts a new sign-in.
1105
+ finishChild(cancelledFailure());
1106
+ }
1107
+ else if (timedOut) {
964
1108
  kill("SIGKILL");
965
1109
  finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
966
1110
  }
@@ -994,6 +1138,10 @@ export class AuthRunner {
994
1138
  // challenge yet: the caller can wait on it (see attendRelay).
995
1139
  else if (code === 2 && relayed && this.sessionDir)
996
1140
  failures[2] = ["inProgress", STILL_SIGNING_IN];
1141
+ // Unsupported only because Microsoft held every new prompt until
1142
+ // this sign-in ran out of time: the next call starts a fresh one.
1143
+ if (code === 4 && heldSeen)
1144
+ failures[4] = ["mfaHeld", mfaHeldFailure().message];
997
1145
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
998
1146
  kill("SIGKILL");
999
1147
  const challenged = kind === "mfaPending" || kind === "automaticPending" || kind === "inProgress";
@@ -84,7 +84,12 @@ export class BrowserAuth {
84
84
  onAutomaticPending?.();
85
85
  }
86
86
  catch { /* Reporting must not interrupt authentication. */ }
87
- });
87
+ }, options.onMfaHeld && (() => {
88
+ try {
89
+ options.onMfaHeld?.();
90
+ }
91
+ catch { /* Reporting must not interrupt authentication. */ }
92
+ }));
88
93
  this.stateStore = new BrowserStateStore(config.sessionDir);
89
94
  this.cooldown = new AuthCooldown(config.sessionDir);
90
95
  }
@@ -77,6 +77,11 @@ export function challengeRelayedAt(lockPath) {
77
77
  * beside challengeMarker so the child and AuthRunner cannot drift apart.
78
78
  */
79
79
  export const AUTOMATIC_PENDING_MARKER = "AUTH_AUTOMATIC_PENDING";
80
+ /**
81
+ * The child reports that Microsoft is refusing to send a new prompt because an
82
+ * earlier one for the account is still open (see PurdueSSOFlow's onMfaHeld).
83
+ */
84
+ export const MFA_HELD_MARKER = "AUTH_MFA_HELD";
80
85
  /**
81
86
  * Printed on the line before a challenge marker when the challenge is another
82
87
  * process's, relayed from the lock directory, rather than this child's own.
@@ -120,6 +120,31 @@ const CODE_METHOD_STEPS = [
120
120
  ["code", /^use a verification code$/i],
121
121
  ["other", /^(?:I can.t use my .+ right now|sign in another way|use a different verification option)$/i],
122
122
  ];
123
+ /**
124
+ * Entra's "Verify your identity" heading when it refuses a new prompt: an
125
+ * earlier prompt for the account is still open (BeginAuth answers
126
+ * UserAuthFailedDuplicateRequest, error 500121), whether it was missed or its
127
+ * sign-in was cancelled. It lasts until that prompt expires, up to 5 minutes
128
+ * after it was sent. Seen live on Purdue's tenant on 2026-10-25, where asking
129
+ * the list for another Authenticator request during the hold was refused the
130
+ * same way (see HELD_RETRY_MS for asking again once it may have ended).
131
+ */
132
+ const MFA_HELD_TEXT = /having trouble verifying your account/i;
133
+ /**
134
+ * While Microsoft holds the account, ask it again through the list's
135
+ * Authenticator option this often. A request made during the hold is refused
136
+ * and sends nothing to the phone; the first one after the earlier prompt
137
+ * expires sends exactly one new prompt, whose number ends the hold here.
138
+ * Without this the page never changes and the login waits out its own 5
139
+ * minutes after Microsoft would already have sent a new prompt.
140
+ */
141
+ const HELD_RETRY_MS = 60_000;
142
+ /** Enough tries to outlast a 5-minute hold, and no more. */
143
+ const MAX_HELD_RETRIES = 6;
144
+ const PUSH_METHOD_SELECTOR = '[data-value="PhoneAppNotification"]';
145
+ const PUSH_METHOD_LABEL = /^approve a request on my microsoft authenticator app$/i;
146
+ /** Short, so a list that changes under the click cannot hold the loop for Playwright's 30 s default. */
147
+ const PUSH_CLICK_TIMEOUT_MS = 5000;
123
148
  /** Let Entra re-render the method page before the loop looks at it again. */
124
149
  const METHOD_SWITCH_SETTLE_MS = 500;
125
150
  /**
@@ -389,6 +414,11 @@ export class PurdueSSOFlow {
389
414
  const startedAt = Date.now();
390
415
  const deadline = startedAt + MFA_TIMEOUT_MS;
391
416
  let challenged = false;
417
+ /** True once onMfaHeld has reported this login. */
418
+ let heldReported = false;
419
+ /** Requests made through the held list, and when the last one was made. */
420
+ let heldRetries = 0;
421
+ let heldRetryAt = 0;
392
422
  /**
393
423
  * True once this login has told the user to go approve something. Only
394
424
  * then is a timeout really a missed approval; an automatic code sign-in
@@ -481,6 +511,24 @@ export class PurdueSSOFlow {
481
511
  await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
482
512
  await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false) ||
483
513
  await this.anyVisible(page, PASSWORDLESS_APPROVAL_SELECTORS);
514
+ // Microsoft refusing a new prompt while an earlier one is still open:
515
+ // tell the caller how long that lasts instead of "still starting".
516
+ if (manualRequired && !challengeVisible && await this.isHeldByMicrosoft(page)) {
517
+ if (!heldReported) {
518
+ heldReported = true;
519
+ heldRetryAt = Date.now();
520
+ log("WARN", "Microsoft is holding back a new prompt until an earlier one for this account expires (up to 5 minutes after it was sent).");
521
+ this.config.onMfaHeld?.();
522
+ }
523
+ else if (!sawNumber && heldRetries < MAX_HELD_RETRIES && Date.now() - heldRetryAt >= HELD_RETRY_MS) {
524
+ heldRetryAt = Date.now();
525
+ if (await this.requestPromptFromHeldList(page)) {
526
+ heldRetries += 1;
527
+ await page.waitForTimeout(METHOD_SWITCH_SETTLE_MS);
528
+ continue;
529
+ }
530
+ }
531
+ }
484
532
  // Entra showed a challenge but never a way to type a code. Stop
485
533
  // waiting for one and fall back to the announce-and-approve path this
486
534
  // flow has always used, rather than polling a dead page for 5 minutes.
@@ -739,6 +787,49 @@ export class PurdueSSOFlow {
739
787
  }
740
788
  return "noSwitch";
741
789
  }
790
+ /**
791
+ * Ask the held list for an Authenticator prompt (see HELD_RETRY_MS). Only
792
+ * when Microsoft's page names the configured account, so a held list for
793
+ * someone else never sends that person a prompt.
794
+ */
795
+ async requestPromptFromHeldList(page) {
796
+ const tile = page.locator(PUSH_METHOD_SELECTOR).first();
797
+ const control = await tile.isVisible().catch(() => false) ? tile : page.getByText(PUSH_METHOD_LABEL).first();
798
+ if (!await control.isVisible().catch(() => false))
799
+ return false;
800
+ if (!await this.showsConfiguredAccount(page))
801
+ return false;
802
+ log("INFO", "Asking Microsoft again for an Authenticator prompt.");
803
+ await control.click({ timeout: PUSH_CLICK_TIMEOUT_MS }).catch(() => { });
804
+ devActivity("mfa_method_selected");
805
+ return true;
806
+ }
807
+ /**
808
+ * True only when this is Microsoft's own sign-in page and one of its account
809
+ * labels names the configured account. Stricter than
810
+ * assertExpectedMicrosoftAccount, which lets a page with no label through.
811
+ */
812
+ async showsConfiguredAccount(page) {
813
+ if (new URL(page.url()).hostname !== "login.microsoftonline.com" || !this.config.username)
814
+ return false;
815
+ const expected = signInName(this.config.username, this.config.baseUrl).toLowerCase();
816
+ for (const selector of ACCOUNT_LABEL_SELECTORS) {
817
+ const account = page.locator(selector).first();
818
+ if (await account.count().catch(() => 0) === 0)
819
+ continue;
820
+ const value = await account.textContent({ timeout: 1000 }).catch(() => null);
821
+ const shown = value?.match(/[\w.+-]+@[\w.-]+\.[a-z]{2,}/i)?.[0]?.toLowerCase();
822
+ if (shown)
823
+ return shown === expected;
824
+ }
825
+ return false;
826
+ }
827
+ /** Microsoft's own sign-in page refusing a new prompt; see MFA_HELD_TEXT. */
828
+ async isHeldByMicrosoft(page) {
829
+ if (new URL(page.url()).hostname !== "login.microsoftonline.com")
830
+ return false;
831
+ return await page.getByText(MFA_HELD_TEXT).first().isVisible().catch(() => false);
832
+ }
742
833
  /**
743
834
  * Refuse to drive Entra's method pages, or type a code into them, unless
744
835
  * this really is Microsoft showing the account we are signing in as. A code
@@ -49,7 +49,7 @@ export class MfaApprovalError extends BrowserAuthError {
49
49
  * else uses the default flow, which already covers the common Shibboleth,
50
50
  * CAS, and Microsoft Entra forms.
51
51
  */
52
- export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomaticPending) {
52
+ export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomaticPending, onMfaHeld) {
53
53
  const credentials = {
54
54
  username: config.username,
55
55
  password: config.password,
@@ -67,6 +67,7 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge, onAutomati
67
67
  requestMfaCode,
68
68
  onMfaChallenge,
69
69
  onAutomaticPending,
70
+ onMfaHeld,
70
71
  };
71
72
  if (isTUDelftBrightspace(config.baseUrl)) {
72
73
  return new TUDelftSSOFlow(credentials);
package/build/auth-cli.js CHANGED
@@ -12,7 +12,7 @@ import { NativeCredentialStoreError } from "./auth/credential-store.js";
12
12
  import { retireLegacyProfile } from "./auth/legacy-profile.js";
13
13
  import { formatPhaseMarker } from "./auth/auth-phases.js";
14
14
  import { AuthenticationInProgressError } from "./auth/auth-lock.js";
15
- import { AUTOMATIC_PENDING_MARKER, challengeMarker, relayedLockLines } from "./auth/mfa-challenge.js";
15
+ import { AUTOMATIC_PENDING_MARKER, MFA_HELD_MARKER, challengeMarker, relayedLockLines } from "./auth/mfa-challenge.js";
16
16
  import { runLogout } from "./auth/logout.js";
17
17
  import { configureDevActivity } from "./utils/dev-activity.js";
18
18
  import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
@@ -88,7 +88,10 @@ async function main() {
88
88
  const onAutomaticPending = automatic
89
89
  ? () => console.log(AUTOMATIC_PENDING_MARKER)
90
90
  : undefined;
91
- await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase, onAutomaticPending }).authenticate({
91
+ // Microsoft refused a new prompt until an earlier one expires: the parent
92
+ // says so instead of "still starting". A terminal run reads the log line.
93
+ const onMfaHeld = automatic ? () => console.log(MFA_HELD_MARKER) : undefined;
94
+ await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase, onAutomaticPending, onMfaHeld }).authenticate({
92
95
  automatic,
93
96
  onAuthenticated: async (token) => {
94
97
  await tokenManager.setToken(token);
@@ -3,8 +3,10 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
+ import { stopAutomaticSignIn } from "../auth/auth-runner.js";
6
7
  import { AuthenticationInProgressError } from "../auth/auth-lock.js";
7
8
  import { clearSavedSession, RemoveError } from "../auth/logout.js";
9
+ import { authLockPath } from "../auth/mfa-challenge.js";
8
10
  import { log } from "../utils/logger.js";
9
11
  import { SignOutSchema } from "./schemas.js";
10
12
  import { withUpdateNotice } from "./tool-helpers.js";
@@ -12,8 +14,11 @@ const KEPT = "Kept (if you set them up): your saved password, your settings, and
12
14
  const OTHER_APPS = "Other Brightspace apps open on this computer are signed out too and will sign in again from scratch on their next question. An app you opened before updating to this version may keep answering for up to an hour; quit and reopen it to sign it out right away.";
13
15
  const NEXT_SIGN_IN = "Because your saved password (and authenticator key, if you saved one) stay on this computer, that next sign-in may just ask you to approve it, or finish on its own if you saved an authenticator key. On a shared computer, also delete the saved \"brightspace-mcp-server\" entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows).";
14
16
  const LOCAL_ONLY = "This only clears this computer. It does not sign you out of Microsoft or Brightspace on other devices.";
15
- const IN_PROGRESS = "Not signed out: a sign-in is in progress on this computer, so nothing was deleted. If you don't want to sign in, don't approve the prompt. Tell the user to ask again after the sign-in finishes or times out; do not call sign_out again on your own.";
17
+ const IN_PROGRESS = "Not signed out: a sign-in that sign_out can't cancel is in progress on this computer (one started in a terminal with `auth`, for example), so nothing was deleted and it was left running. Tell the user to finish or close that sign-in, then ask again; do not call sign_out again on your own.";
18
+ const CANCELLED = "Cancelled the Brightspace sign-in that was waiting for approval, so the prompt or number from it no longer works.";
19
+ const NEXT_QUESTION = "Microsoft keeps that prompt open for up to 5 minutes after sending it and sends this account no other until then, so the next Brightspace question can take that long to show a new number to approve.";
16
20
  const ALREADY_SIGNED_OUT = "You're already signed out of Brightspace on this computer; there was no saved session to remove.";
21
+ const NOTHING_SAVED = "There was no saved session to remove.";
17
22
  const UNEXPECTED_FAILURE = "Could not clear the saved session because of an unexpected error. Nothing more was changed; try again, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal to see the details.";
18
23
  function text(message, isError = false) {
19
24
  const result = withUpdateNotice([{ type: "text", text: message }]);
@@ -22,47 +27,58 @@ function text(message, isError = false) {
22
27
  /**
23
28
  * Register sign_out tool.
24
29
  *
25
- * The in-chat version of `auth --logout`: it deletes this computer's saved
30
+ * The in-chat version of `auth --logout`, plus one thing the command does not
31
+ * do: it first cancels a background sign-in still waiting on MFA, this
32
+ * server's (AuthRunner.cancel, which answers every call waiting on it) or
33
+ * another app's on this computer (stopAutomaticSignIn). A user who missed the
34
+ * prompt otherwise waited out its 5-minute window for a new one. Then it
35
+ * deletes this computer's saved
26
36
  * session for the account the server is configured with (clearSavedSession,
27
37
  * under the sign-in lock, so a running sign-in is never disturbed), then
28
38
  * empties the API client's response cache and this process's in-memory token
29
39
  * so it serves nothing from the session it just signed out of. Answers are
30
- * plain text, not JSON. A sign-in in progress and an env-supplied credential
31
- * are ordinary answers rather than errors, so a model has no reason to retry
32
- * in a loop; only a file that could not be deleted is reported as a failure.
40
+ * plain text, not JSON. A sign-in it cannot cancel (a person's `auth` run)
41
+ * and an env-supplied credential are ordinary answers rather than errors, so
42
+ * a model has no reason to retry in a loop; only a file that could not be
43
+ * deleted is reported as a failure.
33
44
  *
34
45
  * authRunner covers the moment between this server spawning a sign-in and
35
46
  * that child taking the cross-process lock, when the lock alone still looks
36
47
  * free. It is absent when the server uses a pasted credential (no runner).
37
48
  *
38
- * clear is an injection seam: tests point it at a temporary directory.
49
+ * clear and stopElsewhere are injection seams for tests.
39
50
  */
40
- export function registerSignOut(server, config, apiClient, tokenManager, authRunner, clear = clearSavedSession) {
51
+ export function registerSignOut(server, config, apiClient, tokenManager, authRunner, clear = clearSavedSession, stopElsewhere = (dir) => stopAutomaticSignIn(authLockPath(dir))) {
41
52
  server.registerTool("sign_out", {
42
53
  title: "Sign Out of Brightspace",
43
54
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
44
- description: "Sign out of Brightspace on this computer: delete the saved Brightspace session, so every Brightspace app here must sign in again from scratch on its next question. Keeps the saved password, settings, and authenticator key. Does not sign out of Microsoft or Brightspace on other devices. Call this ONLY when the user explicitly asks to sign out or log out of Brightspace (a shared computer, handing the machine on). Never call it to fix an error, never as part of answering another question, and never to retry or refresh a sign-in.",
55
+ description: "Sign out of Brightspace on this computer: cancel any sign-in still waiting for MFA approval here, then delete the saved Brightspace session, so every Brightspace app here must sign in again from scratch on its next question. Keeps the saved password, settings, and authenticator key. Does not sign out of Microsoft or Brightspace on other devices. Cancelling a waiting sign-in doesn't get a new prompt sooner: Microsoft sends no other prompt to the account until the one already sent expires, up to 5 minutes after it was sent. Call this ONLY when the user explicitly asks to sign out or log out of Brightspace (a shared computer, handing the machine on). Never call it on your own to fix an error, to get a new prompt, or as part of answering another question.",
45
56
  inputSchema: SignOutSchema,
46
57
  }, async () => {
47
58
  if (config.envAccessToken || config.envSessionCookie) {
48
59
  return text("Nothing was signed out. This server uses a credential from its configuration (D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE, set in your AI app's client configuration or a .env file), and sign_out cannot remove that. To stop using it, remove that setting and restart the app.");
49
60
  }
50
- if (authRunner?.busy())
51
- return text(IN_PROGRESS);
52
61
  const dropInMemory = () => {
53
62
  apiClient.clearCache();
54
63
  tokenManager.forgetSession();
55
64
  };
56
65
  try {
66
+ // This server's own sign-in first: stopping it also answers its waiting calls.
67
+ let cancelled = (await authRunner?.cancel()) ?? false;
68
+ if (await stopElsewhere(config.sessionDir))
69
+ cancelled = true;
57
70
  const { removed } = await clear(config.sessionDir);
58
71
  dropInMemory();
59
- if (removed.length === 0)
60
- return text(ALREADY_SIGNED_OUT);
72
+ if (removed.length === 0) {
73
+ return text(cancelled ? [CANCELLED, NOTHING_SAVED, NEXT_QUESTION].join("\n") : ALREADY_SIGNED_OUT);
74
+ }
61
75
  return text([
76
+ ...(cancelled ? [CANCELLED] : []),
62
77
  "Signed out of Brightspace on this computer.",
63
78
  `Removed: ${removed.join(", ")}`,
64
79
  KEPT,
65
80
  OTHER_APPS,
81
+ ...(cancelled ? [NEXT_QUESTION] : []),
66
82
  NEXT_SIGN_IN,
67
83
  LOCAL_ONLY,
68
84
  ].join("\n"));
@@ -94,13 +94,36 @@ export function errorResponse(message, kind) {
94
94
  */
95
95
  const MFA_SOLO_CALL = "Don't run other Brightspace calls in parallel until the sign-in completes: a batched " +
96
96
  "answer can reach the user too late to approve. Make one call at a time.";
97
+ /**
98
+ * What a retry does to the prompt on the user's phone. Without this a model
99
+ * filled the gap itself, telling a user that a call timing out would make the
100
+ * server send a new push: the retry joins the background sign-in instead, and
101
+ * only a sign-in that is cancelled (sign_out) or left alone for ABANDON_MS
102
+ * (auth-runner.ts) is replaced.
103
+ */
104
+ const MFA_SAME_PROMPT = "Calling again doesn't send a new prompt, and neither does a call timing out: the sign-in " +
105
+ "keeps waiting, for up to 5 minutes, on the prompt already sent.";
106
+ /** Entra number match only: purdue-sso.ts asks for another request when one expires or is denied. */
107
+ const MFA_NEW_NUMBER = "If Microsoft's number expires or is denied, the sign-in asks Microsoft for a new one by itself " +
108
+ "and the next answer shows it; always give the user the latest number.";
109
+ /**
110
+ * Why a missed prompt cannot be skipped. Microsoft keeps a prompt open for up
111
+ * to 5 minutes and refuses another for the same account until then
112
+ * (UserAuthFailedDuplicateRequest, seen live on Purdue's tenant 2026-10-25),
113
+ * so cancelling the sign-in only throws away the one that could finish.
114
+ */
115
+ const MFA_NO_FASTER = "Cancelling the sign-in (sign_out) doesn't get one sooner: Microsoft sends no other prompt to " +
116
+ "this account until the one already sent expires, up to 5 minutes after it was sent.";
97
117
  const AUTH_FAILURE_GUIDANCE = {
98
118
  browserInstall: PLAYWRIGHT_INSTALL_HINT,
99
119
  dependencyMissing: dependencyRepairHint(),
100
120
  busy: "A sign-in is already running in another process. Let it finish, then try again, " +
101
121
  `or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
102
- cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
103
- `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to retry now and see the number to enter.`,
122
+ cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered; the pause ends 5 minutes " +
123
+ "after that prompt was missed. Until then " +
124
+ "calls send no prompt and return this message; after that, the next call starts a new sign-in " +
125
+ "and sends a new prompt. " +
126
+ `To sign in now, run \`${AUTH_COMMAND}\` in a terminal (from your home folder); it shows the number to enter.`,
104
127
  unsupported: "This login needs something your AI client cannot supply, usually a code from an "
105
128
  + "authenticator app. " +
106
129
  `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) and sign in there.`,
@@ -108,7 +131,8 @@ const AUTH_FAILURE_GUIDANCE = {
108
131
  "password could not be read. Unlock your keychain or keyring, then try again.",
109
132
  transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
110
133
  "Check your connection and try again in a few minutes.",
111
- timeout: "The sign-in did not finish in time, usually a missed MFA prompt. " +
134
+ timeout: "The sign-in did not finish in time, usually a missed MFA prompt, and has stopped: the prompt it " +
135
+ "sent no longer works. The next call starts a new sign-in, or says automatic sign-in is paused. " +
112
136
  `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
113
137
  failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
114
138
  "or `brightspace-setup` if your saved school or username is wrong.",
@@ -122,11 +146,22 @@ const AUTH_FAILURE_GUIDANCE = {
122
146
  "again right away without waiting for them to " +
123
147
  "confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
124
148
  "as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
125
- MFA_SOLO_CALL,
149
+ `${MFA_SAME_PROMPT} ${MFA_NO_FASTER} ${MFA_SOLO_CALL}`,
150
+ cancelled: "The sign-in was cancelled before it finished (by sign_out, or its process was stopped), so " +
151
+ "the prompt or number from it no longer works. Calling this tool again starts a new sign-in. " +
152
+ "Microsoft keeps the cancelled prompt open for up to 5 minutes after sending it and sends this " +
153
+ "account no other until then, so a new number can take that long; give the user the new number " +
154
+ "when an answer carries one.",
155
+ mfaHeld: "Microsoft is still holding an earlier sign-in prompt for this account (one that was missed, or " +
156
+ "whose sign-in was cancelled) and won't send a new prompt until that one expires, up to 5 minutes " +
157
+ "after it was sent. There is nothing to approve right now, and the earlier number no longer works. " +
158
+ "The sign-in asks Microsoft again every minute. Tell the user that, then call this tool again " +
159
+ "right away: each call waits up to 45 seconds and returns the new number as soon as Microsoft " +
160
+ "sends it. If a prompt the user didn't start shows on their phone, they should deny it.",
126
161
  inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
127
162
  "school's login pages). Call this tool again right away: the next call picks up that " +
128
- "sign-in and returns the number to approve as soon as one appears. Don't ask the user to " +
129
- "do anything yet.",
163
+ "sign-in and returns the number to approve as soon as one appears. No prompt has been sent " +
164
+ "yet, and calling again doesn't start a second sign-in. Don't ask the user to do anything yet.",
130
165
  };
131
166
  /**
132
167
  * mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
@@ -167,7 +202,7 @@ function mfaPendingMessage(error) {
167
202
  "tool again right away without waiting for them " +
168
203
  "to confirm: each call waits up to 45 seconds for the approval and returns the result as " +
169
204
  "soon as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
170
- MFA_SOLO_CALL;
205
+ `${MFA_SAME_PROMPT} ${MFA_NEW_NUMBER} ${MFA_NO_FASTER} ${MFA_SOLO_CALL}`;
171
206
  }
172
207
  return AUTH_FAILURE_GUIDANCE[error.kind];
173
208
  }
@@ -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"].includes(fields.reason ?? ""))
106
+ "automaticPending", "inProgress", "mfaHeld"].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.20",
3
+ "version": "5.1.21",
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",