brightspace-mcp-server 3.11.12 → 3.11.13

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.
@@ -38,8 +38,15 @@ async function renameThroughContention(from, to) {
38
38
  }
39
39
  return false;
40
40
  }
41
+ /**
42
+ * The MFA challenge the lock owner's sign-in is showing, published inside the
43
+ * lock directory so it lives and dies with the lock (see mfa-challenge.ts).
44
+ */
45
+ export const CHALLENGE_FILE = "challenge.json";
41
46
  export class AuthenticationInProgressError extends Error {
42
47
  code = "AUTH_IN_PROGRESS";
48
+ /** The owner's MFA challenge, when the caller looked it up (see BrowserAuth.authenticate). */
49
+ challenge;
43
50
  constructor() {
44
51
  super("Authentication already in progress. Retry after the current authentication finishes.");
45
52
  this.name = "AuthenticationInProgressError";
@@ -89,6 +96,7 @@ export const lockOps = {
89
96
  /** Remove only our fixed metadata names, after the directory was retired. */
90
97
  async function cleanRetired(lockPath) {
91
98
  await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
99
+ await fs.unlink(path.join(lockPath, CHALLENGE_FILE)).catch(() => { });
92
100
  await cleanChild(path.join(lockPath, "reclaim.lock"));
93
101
  await fs.rmdir(lockPath).catch(() => { });
94
102
  }
@@ -11,6 +11,7 @@ import { AuthError } from "../utils/errors.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
12
  import { devActivity } from "../utils/dev-activity.js";
13
13
  import { parsePhaseMarker } from "./auth-phases.js";
14
+ import { authLockPath, challengeRelayedAt } from "./mfa-challenge.js";
14
15
  /**
15
16
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
16
17
  * which is five minutes: a person has to find their phone, unlock it, and read
@@ -41,6 +42,25 @@ const MFA_POLL_MS = 45000;
41
42
  * the number in the answer.
42
43
  */
43
44
  const CALL_BUDGET_MS = 55000;
45
+ /**
46
+ * How long a background sign-in past its MFA challenge keeps running once
47
+ * nobody is waiting on it (issue #199). The answer carrying the number can be
48
+ * cancelled, or held in a parallel batch the user never sees; the child then
49
+ * kept its browser and the cross-process lock for the whole 5-minute MFA
50
+ * window, and every server sharing the session was stuck behind it. A user
51
+ * who did see the number calls again, which polls for another MFA_POLL_MS, so
52
+ * the same span without any caller (in this process, or relaying the number
53
+ * from another one) means nobody is approving.
54
+ */
55
+ const ABANDON_MS = MFA_POLL_MS;
56
+ /**
57
+ * Milliseconds until a background sign-in counts as abandoned; zero or less
58
+ * means it already is. A caller still waiting keeps it alive outright;
59
+ * otherwise the clock runs from the latest moment anyone attended to it.
60
+ */
61
+ function abandonDelayMs(waiting, attendedAt, now) {
62
+ return waiting > 0 ? ABANDON_MS : attendedAt + ABANDON_MS - now;
63
+ }
44
64
  /** How long to poll after a challenge seen elapsedMs into the call. */
45
65
  function pollWindowMs(elapsedMs) {
46
66
  return Math.min(MFA_POLL_MS, CALL_BUDGET_MS - elapsedMs);
@@ -179,15 +199,21 @@ export class AuthRunner {
179
199
  pendingChallenge = null;
180
200
  /** Resolves on the next marker from the current child; null between children. */
181
201
  challengeSignal = null;
202
+ /** Callers inside run() right now. */
203
+ waiting = 0;
204
+ /** When the last caller left run(). */
205
+ lastAttendedAt = 0;
182
206
  scriptPath;
183
207
  timeoutMs;
184
208
  onProgress;
209
+ sessionDir;
185
210
  constructor(options = {}) {
186
211
  // Resolve paths relative to this file's compiled location (build/auth/auth-runner.js)
187
212
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
188
213
  this.scriptPath = path.resolve(thisDir, "..", "auth-cli.js");
189
214
  this.timeoutMs = options.timeoutMs ?? AUTH_TIMEOUT_MS;
190
215
  this.onProgress = options.onProgress;
216
+ this.sessionDir = options.sessionDir;
191
217
  }
192
218
  /**
193
219
  * Authenticate, joining the login this process already started if there is
@@ -206,6 +232,16 @@ export class AuthRunner {
206
232
  * the next call to join.
207
233
  */
208
234
  async run(onChallenge) {
235
+ this.waiting += 1;
236
+ try {
237
+ return await this.attend(onChallenge);
238
+ }
239
+ finally {
240
+ this.waiting -= 1;
241
+ this.lastAttendedAt = Date.now();
242
+ }
243
+ }
244
+ async attend(onChallenge) {
209
245
  const startedAt = Date.now();
210
246
  try {
211
247
  return await withinCallBudget(this.runOnce(startedAt), startedAt);
@@ -333,6 +369,8 @@ export class AuthRunner {
333
369
  // against double cleanup.
334
370
  let childFinished = false;
335
371
  let numberMatch;
372
+ let challengeSeen = false;
373
+ let abandonTimer;
336
374
  let killTimer;
337
375
  const kill = (signal) => {
338
376
  try {
@@ -400,8 +438,30 @@ export class AuthRunner {
400
438
  // joinBackgroundChild. Idempotent on the "already known" question, but
401
439
  // a later number still overwrites a numberless pendingChallenge so a
402
440
  // fresh joiner sees it — see MFA_PENDING_MARKER's own comment.
441
+ // Once the child is past its challenge, stop it when nobody is waiting
442
+ // on it any more (see ABANDON_MS). SIGKILL rather than SIGTERM: a
443
+ // graceful stop runs the MFA loop's failure path, which records the
444
+ // cooldown, and the user never had a chance to answer. The dead owner
445
+ // leaves a stale lock the next sign-in reclaims.
446
+ const watchAttention = () => {
447
+ if (childFinished)
448
+ return;
449
+ const relayedAt = this.sessionDir ? challengeRelayedAt(authLockPath(this.sessionDir)) ?? 0 : 0;
450
+ const delayMs = abandonDelayMs(this.waiting, Math.max(this.lastAttendedAt, relayedAt), Date.now());
451
+ if (delayMs > 0) {
452
+ abandonTimer = setTimeout(watchAttention, delayMs);
453
+ abandonTimer.unref?.();
454
+ return;
455
+ }
456
+ log("WARN", "Stopping a background sign-in nobody is waiting on; its MFA prompt went unanswered");
457
+ kill("SIGKILL");
458
+ finishChild(new AuthProcessError("timeout", `The sign-in was stopped because nobody approved its MFA prompt. Try again, or run ${AUTH_COMMAND}.`));
459
+ };
403
460
  const publishChallenge = (matched) => {
461
+ challengeSeen = true;
404
462
  const firstChallenge = this.pendingChallenge === null;
463
+ if (firstChallenge)
464
+ watchAttention();
405
465
  if (firstChallenge)
406
466
  devActivity("mfa_observed", { elapsedMs: Date.now() - started });
407
467
  if (firstChallenge || matched) {
@@ -424,6 +484,8 @@ export class AuthRunner {
424
484
  clearTimeout(timer);
425
485
  if (killTimer)
426
486
  clearTimeout(killTimer);
487
+ if (abandonTimer)
488
+ clearTimeout(abandonTimer);
427
489
  process.off("exit", onExit);
428
490
  const answeredEarly = callerSettled;
429
491
  settleCaller(error);
@@ -500,6 +562,10 @@ export class AuthRunner {
500
562
  6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
501
563
  7: mfaPendingFailure(numberMatch),
502
564
  };
565
+ // Busy, but the owning sign-in's challenge came through: the caller
566
+ // was told what to approve, so answer with that, not "busy".
567
+ if (code === 2 && challengeSeen)
568
+ failures[2] = mfaPendingFailure(numberMatch);
503
569
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
504
570
  kill("SIGKILL");
505
571
  finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
@@ -3,7 +3,6 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT: see LICENSE file for details.
5
5
  */
6
- import * as path from "node:path";
7
6
  import { readFileSync, accessSync } from "node:fs";
8
7
  import { BrowserAuthError } from "../utils/errors.js";
9
8
  import { log } from "../utils/logger.js";
@@ -11,7 +10,8 @@ import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError } from
11
10
  import { isDuoPrompt } from "./duo-mfa.js";
12
11
  import { BrowserStateStore } from "./browser-state-store.js";
13
12
  import { hasNewerEntraState, recordMicrosoftSession } from "./microsoft-session.js";
14
- import { acquireProcessLock } from "./auth-lock.js";
13
+ import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
14
+ import { authLockPath, publishChallenge, relayChallenge } from "./mfa-challenge.js";
15
15
  import { AuthCooldown } from "./auth-cooldown.js";
16
16
  import { mintAccessToken } from "./token-mint.js";
17
17
  import { isMissingBrowserError, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
@@ -54,12 +54,19 @@ export class BrowserAuth {
54
54
  onPhase;
55
55
  /** When the current login first reported an MFA challenge; splits credentials from approvalWait. */
56
56
  challengeSeenAt;
57
+ lockPath;
58
+ /** The latest challenge write, awaited before the lock is released so none lands after it. */
59
+ publishing = Promise.resolve();
57
60
  constructor(config, options = {}) {
58
61
  this.config = config;
59
62
  this.onPhase = options.onPhase;
60
63
  const { onMfaChallenge } = options;
64
+ this.lockPath = authLockPath(config.sessionDir);
61
65
  this.ssoFlow = createSSOFlow(config, options.requestMfaCode, onMfaChallenge && ((number) => {
62
66
  this.challengeSeenAt ??= Date.now();
67
+ // Other server processes sharing this session cannot see our caller's
68
+ // tool response; the lock directory is where they look instead.
69
+ this.publishing = this.publishing.then(() => publishChallenge(this.lockPath, number)).catch(() => { });
63
70
  onMfaChallenge(number);
64
71
  }));
65
72
  this.stateStore = new BrowserStateStore(config.sessionDir);
@@ -101,7 +108,13 @@ export class BrowserAuth {
101
108
  }
102
109
  }
103
110
  async authenticate(options = {}) {
104
- const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"), { mode: options.automatic ? "automatic" : "explicit" });
111
+ const release = await acquireProcessLock(this.lockPath, { mode: options.automatic ? "automatic" : "explicit" }).catch(async (error) => {
112
+ // Hand back the number the owner is showing, so this caller's user can
113
+ // approve that sign-in instead of being told only to wait for it.
114
+ if (error instanceof AuthenticationInProgressError)
115
+ error.challenge = await relayChallenge(this.lockPath);
116
+ throw error;
117
+ });
105
118
  try {
106
119
  // Even a cookie-only SAML redirect can issue an MFA push. Suppress all
107
120
  // automatic browser attempts during cooldown; HTTP token refresh runs
@@ -114,6 +127,7 @@ export class BrowserAuth {
114
127
  return token;
115
128
  }
116
129
  finally {
130
+ await this.publishing;
117
131
  await release();
118
132
  }
119
133
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Brightspace MCP Server. Copyright (c) 2026 Rohan Muppa. MIT licensed.
3
+ *
4
+ * The MFA challenge a sign-in is showing, shared with every process that
5
+ * shares its session store (issue #199).
6
+ *
7
+ * Several server processes, one per open MCP client, can share one session
8
+ * directory, and only the one holding the sign-in lock can sign in. The number
9
+ * to enter used to reach the user only through the one tool call that started
10
+ * that sign-in. The owner now writes its challenge into the lock directory, so
11
+ * it disappears with the lock, and a contender reads it back and relays it.
12
+ * Relaying touches the file, which tells the owner's server that someone is
13
+ * still waiting on the sign-in (see AuthRunner's abandonment check).
14
+ */
15
+ import * as fs from "node:fs/promises";
16
+ import { statSync } from "node:fs";
17
+ import * as path from "node:path";
18
+ import { CHALLENGE_FILE } from "./auth-lock.js";
19
+ /** Same bound the auth child's stdout marker enforces: 1-3 digits and nothing else. */
20
+ const NUMBER_MATCH = /^\d{1,3}$/;
21
+ export function authLockPath(sessionDir) {
22
+ return path.join(sessionDir, ".auth.lock");
23
+ }
24
+ /** Record the challenge this process's sign-in is showing. Call only while holding the lock. */
25
+ export async function publishChallenge(lockPath, numberMatch) {
26
+ const challenge = numberMatch && NUMBER_MATCH.test(numberMatch) ? { numberMatch } : {};
27
+ await fs.writeFile(path.join(lockPath, CHALLENGE_FILE), JSON.stringify(challenge), { mode: 0o600 });
28
+ }
29
+ /**
30
+ * The challenge the lock owner is showing, or undefined when it has published
31
+ * none. Marks the challenge as just relayed, so the owner keeps waiting.
32
+ */
33
+ export async function relayChallenge(lockPath) {
34
+ const file = path.join(lockPath, CHALLENGE_FILE);
35
+ let challenge;
36
+ try {
37
+ challenge = JSON.parse(await fs.readFile(file, "utf8"));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ if (typeof challenge !== "object" || challenge === null)
43
+ return undefined;
44
+ const now = new Date();
45
+ await fs.utimes(file, now, now).catch(() => { });
46
+ const numberMatch = challenge.numberMatch;
47
+ return typeof numberMatch === "string" && NUMBER_MATCH.test(numberMatch) ? { numberMatch } : {};
48
+ }
49
+ /** When the lock's challenge was last published or relayed, or undefined when there is none. */
50
+ export function challengeRelayedAt(lockPath) {
51
+ try {
52
+ return statSync(path.join(lockPath, CHALLENGE_FILE)).mtimeMs;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /** The stdout marker AuthRunner parses for a challenge. */
59
+ export function challengeMarker(numberMatch) {
60
+ return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
61
+ }
package/build/auth-cli.js CHANGED
@@ -11,6 +11,8 @@ import { MfaApprovalError } from "./auth/sso-flow.js";
11
11
  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
+ import { AuthenticationInProgressError } from "./auth/auth-lock.js";
15
+ import { challengeMarker } from "./auth/mfa-challenge.js";
14
16
  import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
15
17
  import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
16
18
  import { reexecLatestIfStale } from "./utils/self-update.js";
@@ -62,7 +64,7 @@ async function main() {
62
64
  // seconds instead of waiting out the whole approval window. Stdout only
63
65
  // — the parent parses stdout for structured markers, never stderr.
64
66
  const onMfaChallenge = automatic
65
- ? (number) => console.log(number ? `MFA_NUMBER:${number}` : "MFA_PENDING")
67
+ ? (number) => console.log(challengeMarker(number))
66
68
  : undefined;
67
69
  // Stage timings for the server's dev activity log, same stdout channel.
68
70
  const onPhase = automatic
@@ -88,7 +90,12 @@ async function main() {
88
90
  // where they were scraped — can cross the process boundary as data
89
91
  // rather than free-form text.
90
92
  if (error instanceof MfaApprovalError && error.numberMatch) {
91
- console.log(`MFA_NUMBER:${error.numberMatch}`);
93
+ console.log(challengeMarker(error.numberMatch));
94
+ }
95
+ // Another process's sign-in holds the lock and is showing a challenge:
96
+ // pass it on, so this caller can tell its user what to approve.
97
+ if (automatic && error instanceof AuthenticationInProgressError && error.challenge) {
98
+ console.log(challengeMarker(error.challenge.numberMatch));
92
99
  }
93
100
  process.exitCode = error instanceof NativeCredentialStoreError ? 5
94
101
  : code === "AUTH_IN_PROGRESS" ? 2
package/build/index.js CHANGED
@@ -86,6 +86,7 @@ else {
86
86
  const authRunner = envAuthActive
87
87
  ? undefined
88
88
  : new AuthRunner({
89
+ sessionDir: config.sessionDir,
89
90
  onProgress: (message) => {
90
91
  void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
91
92
  },
@@ -82,6 +82,13 @@ export function errorResponse(message) {
82
82
  * nothing is derived from the caught message, so a child process cannot put
83
83
  * words in the response. The full error still goes to the log.
84
84
  */
85
+ /**
86
+ * Issue #199: a client running calls in a parallel batch can hold the answer
87
+ * carrying the number until the whole batch finishes, and the user never sees
88
+ * it in time. Ask the model to relay it on its own and call one tool at a time.
89
+ */
90
+ const MFA_SOLO_CALL = "Don't run other Brightspace calls in parallel until the sign-in completes: a batched " +
91
+ "answer can reach the user too late to approve. Make one call at a time.";
85
92
  const AUTH_FAILURE_GUIDANCE = {
86
93
  busy: "A sign-in is already running in another process. Let it finish, then try again, " +
87
94
  `or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
@@ -101,7 +108,8 @@ const AUTH_FAILURE_GUIDANCE = {
101
108
  mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo). " +
102
109
  "Tell the user that, then call this tool again right away without waiting for them to " +
103
110
  "confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
104
- "as the sign-in completes. Keep calling until it succeeds or reports a different error.",
111
+ "as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
112
+ MFA_SOLO_CALL,
105
113
  inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
106
114
  "school's login pages). Call this tool again right away: the next call joins the same " +
107
115
  "sign-in, so it sends no second MFA prompt, and returns the number to approve as soon as " +
@@ -119,7 +127,8 @@ function authFailureMessage(error) {
119
127
  return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
120
128
  "Tell the user that number, then call this tool again right away without waiting for them " +
121
129
  "to confirm: each call waits up to 45 seconds for the approval and returns the result as " +
122
- "soon as the sign-in completes. Keep calling until it succeeds or reports a different error.";
130
+ "soon as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
131
+ MFA_SOLO_CALL;
123
132
  }
124
133
  return AUTH_FAILURE_GUIDANCE[error.kind];
125
134
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.11.12",
3
+ "version": "3.11.13",
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",