brightspace-mcp-server 3.7.2 → 3.7.3

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
@@ -105,6 +105,8 @@ Run it from your home folder. On macOS, a terminal that lacks Files and Folders
105
105
 
106
106
  **MFA at Purdue** commonly uses Microsoft Authenticator number matching (some schools use Duo instead). When a sign-in needs it, the tool call itself returns quickly with the number to enter, rather than sitting silent for up to five minutes — approve it on your phone, then call the tool again; sign-in finishes in the background in the meantime. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them, useful if you don't see the number in the tool response for some reason.
107
107
 
108
+ The number in that response can go stale if the request times out or you tap Deny before approving it: the server asks Microsoft for another one and the next tool call reports the new number, so always enter whatever number the most recent response shows. If a background sign-in gets stuck, running the auth command above in a terminal takes it over immediately instead of waiting for it to finish or time out.
109
+
108
110
  **On a Duo tenant**, if Duo asks "Is this your device?" before it will send a push, automatic sign-in answers **yes** so the push can go out at all — a headless run has nobody to click it otherwise. That also makes Duo remember the device, which skips its own device check on later logins from this machine. Don't run automatic sign-in on a shared or public computer if you'd rather Duo keep asking. Setting `D2L_DUO_PASSCODE` to any value switches from waiting for a push to typing a code from Duo Mobile's passcode option instead.
109
111
 
110
112
  ## What You Can Ask About
@@ -48,15 +48,20 @@ export class AuthenticationInProgressError extends Error {
48
48
  async function readOwner(lockPath) {
49
49
  try {
50
50
  const owner = JSON.parse(await fs.readFile(path.join(lockPath, "owner.json"), "utf8"));
51
- if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.nonce === "string")
52
- return owner;
51
+ if (Number.isSafeInteger(owner.pid) && owner.pid > 0 && typeof owner.host === "string" && typeof owner.nonce === "string") {
52
+ // A lock file written before `mode` existed, or one that carries an
53
+ // unrecognized value, is treated as "explicit" — the safer default,
54
+ // since only an "automatic" owner is ever eligible to be taken over.
55
+ const mode = owner.mode === "automatic" ? "automatic" : "explicit";
56
+ return { pid: owner.pid, host: owner.host, nonce: owner.nonce, mode };
57
+ }
53
58
  }
54
59
  catch {
55
60
  // An incomplete or unknown lock is not proof that its owner is gone.
56
61
  }
57
62
  return undefined;
58
63
  }
59
- function isDead(owner) {
64
+ function isDeadImpl(owner) {
60
65
  // Session directories are local-only. DHCP can change the host's name
61
66
  // without changing this process table, so only PID liveness decides.
62
67
  try {
@@ -67,6 +72,20 @@ function isDead(owner) {
67
72
  return error.code === "ESRCH";
68
73
  }
69
74
  }
75
+ /**
76
+ * Process operations behind a swappable seam. Production code always goes
77
+ * through this object with its real implementations; a test that needs a
78
+ * "live" owner it can safely terminate (without spawning or signaling a real
79
+ * process) replaces `kill`/`isDead` here instead. This file is intentionally
80
+ * free of relative imports — see tests/auth/auth-lock.test.ts, which loads it
81
+ * standalone via a data: URL for its real-process tests, and a relative
82
+ * import cannot be resolved from that base — so this seam is a plain
83
+ * exported object rather than an injected logger or similar dependency.
84
+ */
85
+ export const lockOps = {
86
+ kill: (pid, signal) => { process.kill(pid, signal); },
87
+ isDead: isDeadImpl,
88
+ };
70
89
  /** Remove only our fixed metadata names, after the directory was retired. */
71
90
  async function cleanRetired(lockPath) {
72
91
  await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
@@ -103,16 +122,56 @@ class Lease {
103
122
  await cleanRetired(this.directory);
104
123
  }
105
124
  }
125
+ /**
126
+ * How long an explicit run waits, after signaling a live automatic owner,
127
+ * for that owner to actually release the lock (either by dying outright or
128
+ * by finishing its own cleanup and renaming the directory away) before
129
+ * retrying acquisition.
130
+ */
131
+ const TAKEOVER_WAIT_MS = 10_000;
132
+ const TAKEOVER_POLL_MS = 100;
133
+ /**
134
+ * SIGTERM a live automatic owner and wait for it to actually give up the
135
+ * lock. browser-auth.ts's `closeOnSignal` handles SIGTERM by closing the
136
+ * browser, which makes the automatic run's in-flight MFA loop throw; its
137
+ * `authenticate()` then runs its `finally` and releases the lock the normal
138
+ * way (the directory is renamed away). If the child instead dies outright
139
+ * (already-registered signal handler races the browser launch, or the
140
+ * process is killed some other way), `isDead` catches that instead. Either
141
+ * way this resolves once the lock is free for `acquireLease` to retry, or
142
+ * once TAKEOVER_WAIT_MS has passed without either happening.
143
+ */
144
+ async function waitForTakeover(lockPath, stale) {
145
+ try {
146
+ lockOps.kill(stale.pid, "SIGTERM");
147
+ }
148
+ catch (error) {
149
+ // The owner exited between the liveness check and the signal. That is the
150
+ // outcome a takeover wants; the wait below sees the dead PID and returns.
151
+ if (error.code !== "ESRCH")
152
+ throw error;
153
+ }
154
+ const deadline = Date.now() + TAKEOVER_WAIT_MS;
155
+ while (Date.now() < deadline) {
156
+ if (lockOps.isDead(stale))
157
+ return;
158
+ const current = await readOwner(lockPath);
159
+ if (!current || current.nonce !== stale.nonce)
160
+ return;
161
+ await delay(TAKEOVER_POLL_MS);
162
+ }
163
+ }
106
164
  /** Acquire a process-shared lock without waiting or relying on its age. */
107
- export async function acquireProcessLock(lockPath) {
165
+ export async function acquireProcessLock(lockPath, options = {}) {
108
166
  await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
109
- const lease = await acquireLease(lockPath, 0);
167
+ const mode = options.mode ?? "explicit";
168
+ const lease = await acquireLease(lockPath, 0, mode);
110
169
  return () => lease.release();
111
170
  }
112
- async function acquireLease(lockPath, depth) {
171
+ async function acquireLease(lockPath, depth, mode) {
113
172
  if (depth > 16)
114
173
  throw new AuthenticationInProgressError();
115
- const owner = { pid: process.pid, host: hostname(), nonce: randomUUID() };
174
+ const owner = { pid: process.pid, host: hostname(), nonce: randomUUID(), mode };
116
175
  for (let attempt = 0; attempt < 3; attempt += 1) {
117
176
  try {
118
177
  await fs.mkdir(lockPath, { mode: 0o700 });
@@ -132,15 +191,29 @@ async function acquireLease(lockPath, depth) {
132
191
  throw error;
133
192
  }
134
193
  const stale = await readOwner(lockPath);
135
- if (!stale || !isDead(stale))
194
+ if (!stale)
136
195
  throw new AuthenticationInProgressError();
196
+ if (!lockOps.isDead(stale)) {
197
+ // A live owner is not stale — the existing behavior for two contending
198
+ // explicit runs, or an automatic run finding anything alive at all.
199
+ // The one exception: an explicit run may take over a live *automatic*
200
+ // one, since a background sign-in nobody is watching should not block
201
+ // someone who just sat down to fix it in a terminal.
202
+ if (mode !== "explicit" || stale.mode !== "automatic")
203
+ throw new AuthenticationInProgressError();
204
+ // No import of the shared logger here — see lockOps's doc comment on
205
+ // why this file stays free of relative imports.
206
+ console.error(`[${new Date().toISOString()}] [WARN] Took over a background sign-in (pid ${stale.pid}) for an explicit auth run`);
207
+ await waitForTakeover(lockPath, stale);
208
+ continue;
209
+ }
137
210
  // Recovery gets its own process lock within the stale directory. If a
138
211
  // recovering process crashes, the same dead-owner rules recover its lock.
139
212
  // Keep this claim in the directory during rename to serialize contenders.
140
- const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1);
213
+ const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1, mode);
141
214
  try {
142
215
  const current = await readOwner(lockPath);
143
- if (current?.nonce !== stale.nonce || !isDead(current))
216
+ if (current?.nonce !== stale.nonce || !lockOps.isDead(current))
144
217
  throw new AuthenticationInProgressError();
145
218
  const retired = `${lockPath}.stale.${owner.nonce}`;
146
219
  // A rename blocked by contention means another contender is inside this
@@ -408,7 +408,7 @@ export class AuthRunner {
408
408
  }
409
409
  else {
410
410
  const failures = {
411
- 2: ["busy", "Authentication already in progress in another process. Complete that attempt, then retry."],
411
+ 2: ["busy", `Authentication already in progress in another process. Complete that attempt, then retry, or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`],
412
412
  3: ["cooldown", `Automatic MFA is paused after an unsuccessful attempt. Run ${AUTH_COMMAND} to retry immediately.`],
413
413
  4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
414
414
  5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
@@ -68,7 +68,7 @@ export class BrowserAuth {
68
68
  }
69
69
  }
70
70
  async authenticate(options = {}) {
71
- const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"));
71
+ const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"), { mode: options.automatic ? "automatic" : "explicit" });
72
72
  try {
73
73
  // Even a cookie-only SAML redirect can issue an MFA push. Suppress all
74
74
  // automatic browser attempts during cooldown; HTTP token refresh runs
@@ -62,6 +62,8 @@ export class DuoMfaHandler {
62
62
  passcodeChoiceClicked = false;
63
63
  /** True once onMfaChallenge has been told about this login, code or not. */
64
64
  announcedToCaller = false;
65
+ /** The last code actually handed to onMfaChallenge. See purdue-sso.ts's lastAnnouncedNumber for why this is tracked separately from verificationCodeAnnounced. */
66
+ lastAnnouncedCode = null;
65
67
  constructor(options) {
66
68
  this.options = options;
67
69
  }
@@ -147,14 +149,15 @@ export class DuoMfaHandler {
147
149
  this.approvalAnnounced = true;
148
150
  log("WARN", "Waiting up to 5 minutes for Duo MFA approval on your device.");
149
151
  this.options.onMfaChallenge?.(verificationCode);
150
- if (verificationCode)
151
- this.announcedToCaller = true;
152
+ this.announcedToCaller = true;
153
+ this.lastAnnouncedCode = verificationCode;
152
154
  }
153
155
  if (verificationCode && verificationCode !== this.verificationCodeAnnounced) {
154
156
  this.verificationCodeAnnounced = verificationCode;
155
157
  log("WARN", `Duo verification code: ${verificationCode}. Enter it in Duo Mobile.`);
156
- if (!this.announcedToCaller) {
158
+ if (!this.announcedToCaller || verificationCode !== this.lastAnnouncedCode) {
157
159
  this.announcedToCaller = true;
160
+ this.lastAnnouncedCode = verificationCode;
158
161
  this.options.onMfaChallenge?.(verificationCode);
159
162
  }
160
163
  }
@@ -28,6 +28,48 @@ const MFA_CODE_SUBMIT_SELECTORS = ["#idSubmit_SAOTCC_Continue", "#idSIButton9"];
28
28
  const NUMBER_MATCH_POLL_MS = 2000;
29
29
  /** A person has to find their phone, unlock it, and read a prompt. */
30
30
  const MFA_TIMEOUT_MS = 5 * 60 * 1000;
31
+ /**
32
+ * Entra's number-match request itself expires (typically ~1-2 minutes) well
33
+ * before the 5-minute MFA_TIMEOUT_MS this flow allows overall, or the user
34
+ * can tap Deny. Either way Entra stops showing the number and offers a way to
35
+ * send another request; without asking for one this flow just polls a dead
36
+ * request until MFA_TIMEOUT_MS. Bounded so a tenant that never clears this
37
+ * screen still hits the ordinary timeout instead of resending forever.
38
+ */
39
+ const MAX_RESENDS = 2;
40
+ /**
41
+ * EXPECTED, not verified against a live tenant from here: Entra's "Send
42
+ * another request" control on the number-match timeout/denied view.
43
+ * Matched by id first because it is far more specific than the text fallback
44
+ * below. Entra's own title ids for this view — `#idDiv_SAASTO_Title` for a
45
+ * timeout, `#idDiv_SAASDS_Title` for a denial — are not matched directly
46
+ * since this id is the more stable target, but are left here as a breadcrumb
47
+ * for whoever next has a live tenant to confirm these against.
48
+ */
49
+ const RESEND_ID_SELECTOR = "#idA_SAASTO_Resend";
50
+ /**
51
+ * EXPECTED, not observed: a fallback for when Entra renames the id above.
52
+ * Any visible control whose text reads as a resend action, scoped to the
53
+ * sign-in surface (never the whole document, which can contain unrelated
54
+ * matches).
55
+ */
56
+ const RESEND_TEXT_PATTERN = /send another request|resend notification|try again|send again/i;
57
+ /**
58
+ * Never click a control offering to switch verification methods — that
59
+ * abandons number-match for something this headless flow cannot complete.
60
+ */
61
+ const RESEND_SWITCH_METHOD_PATTERN = /can.t use|sign in another way|different verification|other ways/i;
62
+ /** Where the text-based resend fallback is allowed to look. */
63
+ const RESEND_SCOPE_SELECTORS = ["#lightbox", "form"];
64
+ /** Log once, not on every poll, when no resend control turns up after a number vanishes. */
65
+ const RESEND_NOT_FOUND_WARN_MS = 30_000;
66
+ /**
67
+ * Don't click the resend control again on the immediately following poll —
68
+ * give Entra a full cycle to re-render before looking again. Two poll
69
+ * intervals rather than one so ordinary per-poll overhead can never make the
70
+ * elapsed time creep past a single interval and defeat the guard.
71
+ */
72
+ const RESEND_CLICK_GUARD_MS = NUMBER_MATCH_POLL_MS * 2;
31
73
  /** Microsoft expects Purdue's full sign-in name, while setup also accepts a career account. */
32
74
  function signInName(username, baseUrl) {
33
75
  const isPurdue = baseUrl && new URL(baseUrl).hostname.toLowerCase() === "purdue.brightspace.com";
@@ -229,6 +271,20 @@ export class PurdueSSOFlow {
229
271
  let announced = null;
230
272
  /** True once onMfaChallenge has been told about this login, number or not. */
231
273
  let announcedToCaller = false;
274
+ /**
275
+ * The last number actually handed to onMfaChallenge. Distinct from
276
+ * `announced` (which only dedupes the WARN log): once the caller has been
277
+ * told about a number, a later DIFFERENT one — including a numberless
278
+ * announcement's first number — must reach onMfaChallenge again, even
279
+ * though `announced` already fires the log every time it changes.
280
+ */
281
+ let lastAnnouncedNumber = null;
282
+ /** True once a number has appeared at least once this login (Fix 2 below only reacts after one has). */
283
+ let sawNumber = false;
284
+ let resendCount = 0;
285
+ let resendLastClickAt = null;
286
+ let numberVanishedAt = null;
287
+ let resendNotFoundWarned = false;
232
288
  try {
233
289
  while (Date.now() < deadline) {
234
290
  if (await this.duoMfa.handle(page))
@@ -236,6 +292,8 @@ export class PurdueSSOFlow {
236
292
  if (await this.submitMfaCode(page))
237
293
  challenged = true;
238
294
  const number = await this.readNumberMatch(page);
295
+ if (number)
296
+ sawNumber = true;
239
297
  const challengeVisible = number !== null ||
240
298
  await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
241
299
  await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false);
@@ -243,21 +301,46 @@ export class PurdueSSOFlow {
243
301
  challenged = true;
244
302
  log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
245
303
  this.config.onMfaChallenge?.(number);
246
- if (number)
247
- announcedToCaller = true;
304
+ announcedToCaller = true;
305
+ lastAnnouncedNumber = number;
248
306
  }
249
307
  if (number && number !== announced) {
250
308
  announced = number;
251
309
  log("WARN", `Number match: ${number}. Enter it in Microsoft Authenticator.`);
252
- if (!announcedToCaller) {
310
+ if (!announcedToCaller || number !== lastAnnouncedNumber) {
253
311
  announcedToCaller = true;
312
+ lastAnnouncedNumber = number;
254
313
  this.config.onMfaChallenge?.(number);
255
314
  }
256
315
  }
316
+ if (number) {
317
+ numberVanishedAt = null;
318
+ resendNotFoundWarned = false;
319
+ }
257
320
  if (await this.isAuthenticated(page)) {
258
321
  log("INFO", "Login successful - verified Brightspace home");
259
322
  return;
260
323
  }
324
+ // Fix 2: Entra's number-match request itself times out (or the user
325
+ // taps Deny) well before the 5-minute budget above. Once that has
326
+ // happened, ask Entra for another one instead of polling a dead
327
+ // request until MFA_TIMEOUT_MS; readNumberMatch on the next poll
328
+ // picks up the fresh number, and the re-announce logic above tells
329
+ // the caller about it.
330
+ if (sawNumber && number === null && new URL(page.url()).hostname === "login.microsoftonline.com") {
331
+ if (numberVanishedAt === null)
332
+ numberVanishedAt = Date.now();
333
+ resendNotFoundWarned = await this.tryResendNumberMatch(page, {
334
+ resendCount,
335
+ resendLastClickAt,
336
+ numberVanishedAt,
337
+ resendNotFoundWarned,
338
+ onResend: (clickedAt) => {
339
+ resendCount += 1;
340
+ resendLastClickAt = clickedAt;
341
+ },
342
+ });
343
+ }
261
344
  await this.clickProvenKmsi(page);
262
345
  // The federated-domain trust prompt arrives after the IdP succeeds, so
263
346
  // it has to be caught by this loop rather than by enterCredentials.
@@ -276,6 +359,53 @@ export class PurdueSSOFlow {
276
359
  throw new MfaApprovalError(undefined, announced ?? undefined);
277
360
  throw new UnsupportedAuthenticationError("Sign-in did not reach a supported MFA challenge or Brightspace within 5 minutes.");
278
361
  }
362
+ /**
363
+ * Look for Entra's "send another request" affordance and click it, at most
364
+ * MAX_RESENDS times per login and never twice in immediate succession.
365
+ * Returns the (possibly updated) resendNotFoundWarned flag for the caller
366
+ * to carry into the next poll.
367
+ */
368
+ async tryResendNumberMatch(page, state) {
369
+ if (state.resendCount >= MAX_RESENDS)
370
+ return state.resendNotFoundWarned;
371
+ if (state.resendLastClickAt !== null && Date.now() - state.resendLastClickAt < RESEND_CLICK_GUARD_MS) {
372
+ return state.resendNotFoundWarned;
373
+ }
374
+ const control = await this.findResendControl(page);
375
+ if (!control) {
376
+ if (!state.resendNotFoundWarned && Date.now() - state.numberVanishedAt >= RESEND_NOT_FOUND_WARN_MS) {
377
+ log("WARN", "Number-match request appears to have expired or been denied, but no \"send another request\" control was found; continuing to wait.");
378
+ return true;
379
+ }
380
+ return state.resendNotFoundWarned;
381
+ }
382
+ const clickedAt = Date.now();
383
+ state.onResend(clickedAt);
384
+ log("WARN", `Number-match request expired; sent another request (${state.resendCount + 1} of ${MAX_RESENDS}).`);
385
+ await control.click().catch(() => { });
386
+ return state.resendNotFoundWarned;
387
+ }
388
+ /** Entra's resend control, matched by id first, then by a scoped text fallback. See the selector constants' own comments. */
389
+ async findResendControl(page) {
390
+ const byId = page.locator(RESEND_ID_SELECTOR).first();
391
+ if (await byId.isVisible().catch(() => false))
392
+ return byId;
393
+ for (const scopeSelector of RESEND_SCOPE_SELECTORS) {
394
+ const scope = page.locator(scopeSelector).first();
395
+ if (!await scope.isVisible().catch(() => false))
396
+ continue;
397
+ const candidates = await scope.getByText(RESEND_TEXT_PATTERN).all().catch(() => []);
398
+ for (const candidate of candidates) {
399
+ if (!await candidate.isVisible().catch(() => false))
400
+ continue;
401
+ const text = (await candidate.textContent().catch(() => null)) ?? "";
402
+ if (RESEND_SWITCH_METHOD_PATTERN.test(text))
403
+ continue;
404
+ return candidate;
405
+ }
406
+ }
407
+ return null;
408
+ }
279
409
  async submitMfaCode(page) {
280
410
  const input = await this.firstVisible(page, MFA_CODE_SELECTORS);
281
411
  if (!input)
@@ -57,7 +57,8 @@ export function errorResponse(message) {
57
57
  * words in the response. The full error still goes to the log.
58
58
  */
59
59
  const AUTH_FAILURE_GUIDANCE = {
60
- busy: "A sign-in is already running in another process. Let it finish, then try again.",
60
+ busy: "A sign-in is already running in another process. Let it finish, then try again, " +
61
+ `or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
61
62
  cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
62
63
  `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to retry now and see the number to enter.`,
63
64
  unsupported: "This login needs something your AI client cannot supply, usually a code from an "
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.7.2",
3
+ "version": "3.7.3",
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",