brightspace-mcp-server 3.13.4 → 3.13.7

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.
@@ -4,6 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { spawn, execFileSync } from "node:child_process";
7
+ import * as fs from "node:fs/promises";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import * as path from "node:path";
9
10
  import { log } from "../utils/logger.js";
@@ -11,7 +12,7 @@ import { AuthError } from "../utils/errors.js";
11
12
  import { AUTH_COMMAND } from "../utils/commands.js";
12
13
  import { devActivity } from "../utils/dev-activity.js";
13
14
  import { parsePhaseMarker } from "./auth-phases.js";
14
- import { AUTOMATIC_PENDING_MARKER, authLockPath, challengeRelayedAt } from "./mfa-challenge.js";
15
+ import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
15
16
  /**
16
17
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
17
18
  * which is five minutes: a person has to find their phone, unlock it, and read
@@ -53,6 +54,20 @@ const CALL_BUDGET_MS = 55000;
53
54
  * from another one) means nobody is approving.
54
55
  */
55
56
  const ABANDON_MS = MFA_POLL_MS;
57
+ /**
58
+ * How often a caller waiting on ANOTHER process's sign-in looks at the lock.
59
+ * Each look also touches the challenge file (relayChallenge), which tells that
60
+ * process's abandonment watcher someone still wants its sign-in. It has to
61
+ * stay well under ABANDON_MS.
62
+ */
63
+ const RELAY_POLL_MS = 1000;
64
+ /**
65
+ * How long a relayed challenge stays "already told to a caller" for the rule
66
+ * that only a RETRY waits on another process's sign-in. The MFA window is five
67
+ * minutes, so a number reported longer ago than that belongs to a sign-in that
68
+ * is over; the same digits showing up again are a new challenge.
69
+ */
70
+ const RELAY_TOLD_MS = 5 * 60 * 1000;
56
71
  /**
57
72
  * Calls arriving within this long of a batch's first call count as that batch
58
73
  * (issue #212). A client running tool calls in parallel sends them within
@@ -95,11 +110,12 @@ function mfaPendingFailure(numberMatch) {
95
110
  * on anyone's phone to approve, so a caller told "approve the request" would
96
111
  * send the user looking for a prompt that will never arrive.
97
112
  */
98
- const automaticPendingFailure = () => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.");
113
+ const automaticPendingFailure = (relayed = false) => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.", undefined, false, relayed);
99
114
  export class AuthProcessError extends AuthError {
100
115
  kind;
101
116
  numberMatch;
102
117
  duplicate;
118
+ relayed;
103
119
  constructor(kind, message,
104
120
  /**
105
121
  * Entra number-match digits, when the failure is "mfaPending". Crossed
@@ -113,11 +129,19 @@ export class AuthProcessError extends AuthError {
113
129
  * challenge to the user (issue #212), so this one should answer briefly
114
130
  * instead of repeating the number and the instructions.
115
131
  */
116
- duplicate = false) {
132
+ duplicate = false,
133
+ /**
134
+ * The challenge belongs to a sign-in in ANOTHER server process that holds
135
+ * the cross-process lock; this process's child only relayed it and
136
+ * exited. There is no local child to join, so a caller that wants to
137
+ * keep waiting watches the lock instead (see AuthRunner.attendRelay).
138
+ */
139
+ relayed = false) {
117
140
  super(message);
118
141
  this.kind = kind;
119
142
  this.numberMatch = numberMatch;
120
143
  this.duplicate = duplicate;
144
+ this.relayed = relayed;
121
145
  this.name = "AuthProcessError";
122
146
  }
123
147
  }
@@ -197,8 +221,10 @@ function dedupeChallenge(error, wave) {
197
221
  wave.closed = true;
198
222
  return error;
199
223
  }
200
- return new AuthProcessError(error.kind, error.message, error.numberMatch, true);
224
+ return new AuthProcessError(error.kind, error.message, error.numberMatch, true, error.relayed);
201
225
  }
226
+ /** Identifies a challenge for "was this already reported to a caller". */
227
+ const relayKey = (kind, numberMatch) => `${kind}:${numberMatch ?? ""}`;
202
228
  export class AuthRunner {
203
229
  /**
204
230
  * The login this process already started, if one is still running.
@@ -237,6 +263,12 @@ export class AuthRunner {
237
263
  * childDone; this only stops every call in the batch repeating the number.
238
264
  */
239
265
  wave = null;
266
+ /**
267
+ * Challenges another process's sign-in is showing that a caller here was
268
+ * already told, by relayKey, and when. A retry of one of these waits on
269
+ * that sign-in instead of repeating the answer (see attendRelay).
270
+ */
271
+ relayTold = new Map();
240
272
  scriptPath;
241
273
  timeoutMs;
242
274
  onProgress;
@@ -268,11 +300,16 @@ export class AuthRunner {
268
300
  async run(onChallenge) {
269
301
  // Synchronous, before any await: calls in one tick cannot split a batch.
270
302
  const wave = this.joinWave();
303
+ // Also synchronous: what was already told BEFORE this call, so siblings in
304
+ // one parallel batch are not each other's "retry" (they answer briefly via
305
+ // dedupeChallenge instead of waiting).
306
+ const toldBefore = this.relayChallengesTold();
271
307
  this.waiting += 1;
272
308
  try {
273
- return await this.attend(onChallenge);
309
+ return await this.attend(onChallenge, toldBefore);
274
310
  }
275
311
  catch (error) {
312
+ this.noteRelayReported(error);
276
313
  throw dedupeChallenge(error, wave);
277
314
  }
278
315
  finally {
@@ -280,6 +317,25 @@ export class AuthRunner {
280
317
  this.lastAttendedAt = Date.now();
281
318
  }
282
319
  }
320
+ relayChallengesTold() {
321
+ const now = Date.now();
322
+ const told = new Set();
323
+ for (const [key, at] of this.relayTold) {
324
+ if (now - at > RELAY_TOLD_MS)
325
+ this.relayTold.delete(key);
326
+ else
327
+ told.add(key);
328
+ }
329
+ return told;
330
+ }
331
+ /** A relayed challenge that went back to a caller counts as told. */
332
+ noteRelayReported(error) {
333
+ if (!(error instanceof AuthProcessError) || !error.relayed)
334
+ return;
335
+ if (error.kind !== "mfaPending" && error.kind !== "automaticPending")
336
+ return;
337
+ this.relayTold.set(relayKey(error.kind, error.numberMatch), Date.now());
338
+ }
283
339
  joinWave() {
284
340
  const now = Date.now();
285
341
  if (!this.wave || this.wave.closed || now - this.wave.openedAt > WAVE_MS) {
@@ -287,12 +343,15 @@ export class AuthRunner {
287
343
  }
288
344
  return this.wave;
289
345
  }
290
- async attend(onChallenge) {
346
+ async attend(onChallenge, toldBefore) {
291
347
  const startedAt = Date.now();
292
348
  try {
293
349
  return await withinCallBudget(this.runOnce(startedAt), startedAt);
294
350
  }
295
351
  catch (error) {
352
+ if (this.sessionDir && error instanceof AuthProcessError && error.relayed) {
353
+ return this.attendRelay(error, startedAt, onChallenge, toldBefore);
354
+ }
296
355
  const childDone = this.childDone;
297
356
  if (!childDone || !(error instanceof AuthProcessError))
298
357
  throw error;
@@ -314,6 +373,86 @@ export class AuthRunner {
314
373
  return this.awaitBackgroundChild(childDone, windowMs);
315
374
  }
316
375
  }
376
+ /**
377
+ * The caller's sign-in found ANOTHER process's sign-in holding the lock,
378
+ * and that process is showing a challenge. The local child has already
379
+ * exited, so unlike awaitBackgroundChild there is nothing here to join:
380
+ * the lock is the only thing to watch.
381
+ *
382
+ * The first answer is still immediate for a caller that cannot be told
383
+ * mid-call, because the number is what the user needs and every second
384
+ * before it is a second the other sign-in's window runs down. A RETRY of the
385
+ * same challenge (already reported to a caller in this process) waits, like
386
+ * a retry joining a local child, instead of re-answering at once: answering
387
+ * a loop of retries in milliseconds made a model conclude the approval
388
+ * window had closed while the other sign-in was still waiting. A caller
389
+ * that can be told mid-call is told and waits at once, and an owner
390
+ * answering its own code has nothing to relay, so every caller waits.
391
+ */
392
+ attendRelay(error, startedAt, onChallenge, toldBefore) {
393
+ const retry = toldBefore.has(relayKey(error.kind, error.numberMatch));
394
+ const waits = error.kind === "automaticPending"
395
+ || (error.kind === "mfaPending" && (Boolean(onChallenge) || retry));
396
+ if (!waits)
397
+ throw error;
398
+ const windowMs = pollWindowMs(Date.now() - startedAt);
399
+ if (windowMs <= 0)
400
+ throw error;
401
+ if (error.kind === "mfaPending" && onChallenge) {
402
+ try {
403
+ onChallenge(error.numberMatch);
404
+ }
405
+ catch { /* Announcing must not interrupt authentication. */ }
406
+ this.noteRelayReported(error);
407
+ }
408
+ return this.awaitRelayedOwner(error, windowMs);
409
+ }
410
+ /**
411
+ * Wait up to windowMs for another process's sign-in to finish, by watching
412
+ * its lock directory about once a second.
413
+ *
414
+ * Each look also relays the challenge, which touches its file: that is the
415
+ * signal the owner's abandonment watcher (ABANDON_MS) reads as "someone is
416
+ * still waiting", so the owner is not killed under a user who is approving.
417
+ * The lock disappearing means the owner is done, and the caller resolves
418
+ * true so the client re-reads the session. If the owner FAILED there is no
419
+ * session, and the client's own "session expired" 401 follows; the next call
420
+ * starts a fresh sign-in and gets the owner's cooldown or error as usual.
421
+ * Spawning another sign-in here instead would risk a second MFA prompt for
422
+ * a login that just succeeded. When the window ends with the lock still
423
+ * held, the caller gets the latest challenge the owner showed.
424
+ */
425
+ async awaitRelayedOwner(error, windowMs) {
426
+ const lockPath = authLockPath(this.sessionDir);
427
+ const deadline = Date.now() + windowMs;
428
+ let latest = {
429
+ automatic: error.kind === "automaticPending",
430
+ numberMatch: error.numberMatch,
431
+ };
432
+ for (;;) {
433
+ const held = await fs.access(lockPath).then(() => true, () => false);
434
+ if (!held) {
435
+ this.relayTold.clear();
436
+ return true;
437
+ }
438
+ const relayed = await relayChallenge(lockPath);
439
+ if (relayed) {
440
+ latest = relayed.kind === "automatic"
441
+ ? { automatic: true }
442
+ : { automatic: false, numberMatch: relayed.numberMatch ?? (latest.automatic ? undefined : latest.numberMatch) };
443
+ }
444
+ const remaining = deadline - Date.now();
445
+ if (remaining <= 0)
446
+ break;
447
+ await new Promise((resolve) => {
448
+ const timer = setTimeout(resolve, Math.min(RELAY_POLL_MS, remaining));
449
+ timer.unref?.();
450
+ });
451
+ }
452
+ if (latest.automatic)
453
+ throw automaticPendingFailure(true);
454
+ throw new AuthProcessError(...mfaPendingFailure(latest.numberMatch), latest.numberMatch, false, true);
455
+ }
317
456
  runOnce(startedAt) {
318
457
  if (this.inFlight) {
319
458
  log("DEBUG", "Joining the authentication already in flight");
@@ -429,6 +568,8 @@ export class AuthRunner {
429
568
  let childFinished = false;
430
569
  let numberMatch;
431
570
  let challengeSeen = false;
571
+ /** The child said the challenge it is about to print is another process's. */
572
+ let relayed = false;
432
573
  /** The child reported it is answering its own code (see the marker below). */
433
574
  let automaticSeen = false;
434
575
  /** watchAttention is armed once per child; a second timer could kill a live sign-in. */
@@ -542,7 +683,7 @@ export class AuthRunner {
542
683
  if (firstChallenge)
543
684
  resolveChallengeSignal();
544
685
  if (!callerSettled) {
545
- settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch));
686
+ settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch, false, relayed));
546
687
  }
547
688
  };
548
689
  /**
@@ -558,7 +699,7 @@ export class AuthRunner {
558
699
  this.pendingChallenge = { kind: "automaticPending" };
559
700
  startWatching();
560
701
  resolveChallengeSignal();
561
- settleCaller(automaticPendingFailure());
702
+ settleCaller(automaticPendingFailure(relayed));
562
703
  };
563
704
  // Runs once the child is actually done. Settles the caller too, if an
564
705
  // early answer had not already done so; otherwise this is just the
@@ -621,6 +762,11 @@ export class AuthRunner {
621
762
  else if (line === AUTOMATIC_PENDING_MARKER) {
622
763
  publishAutomaticProgress();
623
764
  }
765
+ else if (line === RELAYED_CHALLENGE_MARKER) {
766
+ // Recorded as the line is read, so it is set before the challenge
767
+ // marker that follows it settles the caller.
768
+ relayed = true;
769
+ }
624
770
  else {
625
771
  log("DEBUG", line);
626
772
  }
@@ -662,7 +808,8 @@ export class AuthRunner {
662
808
  failures[2] = ["automaticPending", automaticPendingFailure().message];
663
809
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
664
810
  kill("SIGKILL");
665
- finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
811
+ const challenged = kind === "mfaPending" || kind === "automaticPending";
812
+ finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged));
666
813
  }
667
814
  });
668
815
  });
@@ -77,6 +77,15 @@ 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
+ * Printed on the line before a challenge marker when the challenge is another
82
+ * process's, relayed from the lock directory, rather than this child's own.
83
+ * The parent then knows its caller is waiting on a sign-in it does not own,
84
+ * which it can only watch through the lock (see AuthRunner.awaitRelayedOwner).
85
+ * The child is always the same build as the parent, so no negotiation is
86
+ * needed.
87
+ */
88
+ export const RELAYED_CHALLENGE_MARKER = "AUTH_RELAYED";
80
89
  /** The stdout marker AuthRunner parses for a challenge. */
81
90
  export function challengeMarker(numberMatch) {
82
91
  return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
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 } from "./auth/mfa-challenge.js";
15
+ import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, challengeMarker } 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";
@@ -112,6 +112,7 @@ async function main() {
112
112
  // Another process's sign-in holds the lock and is showing a challenge:
113
113
  // pass it on, so this caller can tell its user what to approve.
114
114
  if (automatic && error instanceof AuthenticationInProgressError && error.challenge) {
115
+ console.log(RELAYED_CHALLENGE_MARKER);
115
116
  console.log(error.challenge.kind === "automatic"
116
117
  ? AUTOMATIC_PENDING_MARKER
117
118
  : challengeMarker(error.challenge.numberMatch));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.13.4",
3
+ "version": "3.13.7",
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",