brightspace-mcp-server 3.11.6 → 3.11.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.
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ /**
7
+ * The browser sign-in stages whose durations the automatic child reports to
8
+ * the server (issue #182). A fixed list, so a stage name is never arbitrary
9
+ * child output by the time it reaches the dev activity log.
10
+ */
11
+ export const AUTH_PHASES = ["launch", "navigation", "silentSso", "credentials", "approvalWait", "token"];
12
+ const PHASE_MARKER = new RegExp(`^AUTH_PHASE:(${AUTH_PHASES.join("|")}):(\\d{1,9})$`);
13
+ /** The stdout line the automatic child prints when a stage ends. */
14
+ export function formatPhaseMarker(phase, elapsedMs) {
15
+ return `AUTH_PHASE:${phase}:${Math.max(0, Math.round(elapsedMs))}`;
16
+ }
17
+ /** The stage and duration from a whole stdout line, or null for anything else. */
18
+ export function parsePhaseMarker(line) {
19
+ const match = PHASE_MARKER.exec(line);
20
+ return match ? { phase: match[1], elapsedMs: Number(match[2]) } : null;
21
+ }
@@ -10,6 +10,7 @@ import { log } from "../utils/logger.js";
10
10
  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
+ import { parsePhaseMarker } from "./auth-phases.js";
13
14
  /**
14
15
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
15
16
  * which is five minutes: a person has to find their phone, unlock it, and read
@@ -31,12 +32,13 @@ const KILL_GRACE_MS = 5000;
31
32
  */
32
33
  const MFA_POLL_MS = 45000;
33
34
  /**
34
- * The latest a polling caller is answered, counted from when it called run().
35
+ * The latest any caller is answered, counted from when it called run().
35
36
  * Browser launch and the SSO pages can take half a minute before the
36
- * challenge even appears, so the poll is cut short to fit this budget. It
37
- * stays under the 60-second request timeout MCP clients commonly apply, so a
38
- * client that never shows the mid-call notice still gets the number in the
39
- * answer.
37
+ * challenge even appears, so the poll is cut short to fit this budget, and a
38
+ * sign-in that has not reached MFA by then answers "still signing in" while
39
+ * it keeps running. It stays under the 60-second request timeout MCP clients
40
+ * commonly apply, so a client that never shows the mid-call notice still gets
41
+ * the number in the answer.
40
42
  */
41
43
  const CALL_BUDGET_MS = 55000;
42
44
  /** How long to poll after a challenge seen elapsedMs into the call. */
@@ -44,11 +46,12 @@ function pollWindowMs(elapsedMs) {
44
46
  return Math.min(MFA_POLL_MS, CALL_BUDGET_MS - elapsedMs);
45
47
  }
46
48
  /**
47
- * The only two lines auth-cli.ts is allowed to hand back as structured data.
48
- * Deliberately strict (whole line, 1-3 digits or the literal word) so this
49
- * can never become a channel for arbitrary child-process text to reach a
50
- * tool response — anything that doesn't match exactly is just another log
51
- * line.
49
+ * The only two lines auth-cli.ts is allowed to hand back as structured data
50
+ * for a tool response; parsePhaseMarker adds stage timings for the dev
51
+ * activity log only. Deliberately strict (whole line, 1-3 digits or the
52
+ * literal word) so this can never become a channel for arbitrary
53
+ * child-process text to reach a tool response — anything that doesn't match
54
+ * exactly is just another log line.
52
55
  */
53
56
  const MFA_NUMBER_MARKER = /^MFA_NUMBER:(\d{1,3})$/;
54
57
  const MFA_PENDING_MARKER = /^MFA_PENDING$/;
@@ -75,6 +78,19 @@ export class AuthProcessError extends AuthError {
75
78
  this.name = "AuthProcessError";
76
79
  }
77
80
  }
81
+ /**
82
+ * Settle with work's outcome, or with inProgress once CALL_BUDGET_MS has
83
+ * passed since startedAt. Only the caller stops waiting: work carries on.
84
+ */
85
+ function withinCallBudget(work, startedAt) {
86
+ return new Promise((resolve, reject) => {
87
+ const budgetTimer = setTimeout(() => {
88
+ reject(new AuthProcessError("inProgress", "Sign-in is still running in the background. Try again."));
89
+ }, Math.max(0, CALL_BUDGET_MS - (Date.now() - startedAt)));
90
+ budgetTimer.unref?.();
91
+ work.then((value) => { clearTimeout(budgetTimer); resolve(value); }, (error) => { clearTimeout(budgetTimer); reject(error); });
92
+ });
93
+ }
78
94
  /** Chromium can lead a separate process group, so a forced stop needs its PID. */
79
95
  function descendantPids(parentPid) {
80
96
  const rows = execFileSync("ps", ["-eo", "pid=,ppid="], {
@@ -185,12 +201,14 @@ export class AuthRunner {
185
201
  * and the first call itself keeps waiting the same way. Either way an
186
202
  * approval within the window completes the caller's original request; past
187
203
  * it, the caller gets the same mfaPending answer again and is expected to
188
- * call once more.
204
+ * call once more. No caller waits past CALL_BUDGET_MS: a sign-in still
205
+ * short of its challenge by then answers inProgress and keeps running for
206
+ * the next call to join.
189
207
  */
190
208
  async run(onChallenge) {
191
209
  const startedAt = Date.now();
192
210
  try {
193
- return await this.runOnce(startedAt);
211
+ return await withinCallBudget(this.runOnce(startedAt), startedAt);
194
212
  }
195
213
  catch (error) {
196
214
  const childDone = this.childDone;
@@ -440,7 +458,11 @@ export class AuthRunner {
440
458
  // immediately, without killing the child — see the class doc comment.
441
459
  forwardLines(child.stdout, (line) => {
442
460
  const numberMarker = MFA_NUMBER_MARKER.exec(line);
443
- if (numberMarker) {
461
+ const phaseMarker = parsePhaseMarker(line);
462
+ if (phaseMarker) {
463
+ devActivity("auth_phase", phaseMarker);
464
+ }
465
+ else if (numberMarker) {
444
466
  numberMatch = numberMarker[1];
445
467
  publishChallenge(numberMatch);
446
468
  }
@@ -51,12 +51,37 @@ export class BrowserAuth {
51
51
  ssoFlow;
52
52
  stateStore;
53
53
  cooldown;
54
+ onPhase;
55
+ /** When the current login first reported an MFA challenge; splits credentials from approvalWait. */
56
+ challengeSeenAt;
54
57
  constructor(config, options = {}) {
55
58
  this.config = config;
56
- this.ssoFlow = createSSOFlow(config, options.requestMfaCode, options.onMfaChallenge);
59
+ this.onPhase = options.onPhase;
60
+ const { onMfaChallenge } = options;
61
+ this.ssoFlow = createSSOFlow(config, options.requestMfaCode, onMfaChallenge && ((number) => {
62
+ this.challengeSeenAt ??= Date.now();
63
+ onMfaChallenge(number);
64
+ }));
57
65
  this.stateStore = new BrowserStateStore(config.sessionDir);
58
66
  this.cooldown = new AuthCooldown(config.sessionDir);
59
67
  }
68
+ /** Report a stage that ran from startedAt to endedAt; a listener failure never interrupts sign-in. */
69
+ phaseEnded(phase, startedAt, endedAt = Date.now()) {
70
+ try {
71
+ this.onPhase?.(phase, endedAt - startedAt);
72
+ }
73
+ catch { /* Reporting must not interrupt authentication. */ }
74
+ }
75
+ /** Run one stage and report its duration however it ends. */
76
+ async timed(phase, work) {
77
+ const startedAt = Date.now();
78
+ try {
79
+ return await work();
80
+ }
81
+ finally {
82
+ this.phaseEnded(phase, startedAt);
83
+ }
84
+ }
60
85
  static isWSLOrDocker() {
61
86
  try {
62
87
  if (/microsoft|wsl/i.test(readFileSync("/proc/version", "utf8")))
@@ -100,6 +125,7 @@ export class BrowserAuth {
100
125
  let interrupted = false;
101
126
  let state;
102
127
  let stateSaved = false;
128
+ let tokenStartedAt;
103
129
  const closeOnSignal = () => {
104
130
  interrupted = true;
105
131
  void browser?.close().catch(() => { });
@@ -112,7 +138,7 @@ export class BrowserAuth {
112
138
  args.push("--no-sandbox", "--disable-setuid-sandbox");
113
139
  // Use Playwright's own timeout, which cleans up an unsuccessful launch.
114
140
  try {
115
- browser = await chromium.launch({ headless: this.config.headless, timeout: 60000, args });
141
+ browser = await this.timed("launch", () => chromium.launch({ headless: this.config.headless, timeout: 60000, args }));
116
142
  }
117
143
  catch (launchError) {
118
144
  // Keep the remedy attached to the failure. Without this the hint is
@@ -142,6 +168,7 @@ export class BrowserAuth {
142
168
  // away newly renewed Entra or Brightspace cookies.
143
169
  await this.saveBrowserState(await context.storageState());
144
170
  stateSaved = true;
171
+ tokenStartedAt = Date.now();
145
172
  const material = await this.harvestSessionMaterial(page, context);
146
173
  let token = null;
147
174
  if (material.cookieHeader && material.csrfToken) {
@@ -182,6 +209,8 @@ export class BrowserAuth {
182
209
  throw error;
183
210
  }
184
211
  finally {
212
+ if (tokenStartedAt !== undefined)
213
+ this.phaseEnded("token", tokenStartedAt);
185
214
  process.removeListener("SIGINT", closeOnSignal);
186
215
  process.removeListener("SIGTERM", closeOnSignal);
187
216
  if (page && listener)
@@ -327,10 +356,10 @@ export class BrowserAuth {
327
356
  let navigationError;
328
357
  let response;
329
358
  try {
330
- response = await page.goto(`${this.config.baseUrl}/d2l/home`, {
359
+ response = await this.timed("navigation", () => page.goto(`${this.config.baseUrl}/d2l/home`, {
331
360
  waitUntil: "domcontentloaded",
332
361
  timeout: INITIAL_NAVIGATION_TIMEOUT_MS,
333
- });
362
+ }));
334
363
  }
335
364
  catch (error) {
336
365
  // Brightspace Bar tolerates a navigation timeout because SAML may keep
@@ -347,7 +376,7 @@ export class BrowserAuth {
347
376
  if (response && (response.status() >= 500 || response.status() === 429)) {
348
377
  throw new BrowserAuthTransportError(`Brightspace temporarily returned HTTP ${response.status()}. Saved state is preserved.`);
349
378
  }
350
- if (await this.awaitSilentSSO(page)) {
379
+ if (await this.timed("silentSso", () => this.awaitSilentSSO(page))) {
351
380
  log("INFO", "Saved session is active");
352
381
  return true;
353
382
  }
@@ -368,6 +397,8 @@ export class BrowserAuth {
368
397
  }
369
398
  throw new BrowserAuthTransportError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
370
399
  }
400
+ const loginStartedAt = Date.now();
401
+ this.challengeSeenAt = undefined;
371
402
  try {
372
403
  if (!await this.ssoFlow.login(page)) {
373
404
  throw new UnsupportedAuthenticationError("The identity provider could not complete automatic sign-in.");
@@ -378,6 +409,14 @@ export class BrowserAuth {
378
409
  await this.cooldown.recordMfaFailure();
379
410
  throw error;
380
411
  }
412
+ finally {
413
+ if (this.challengeSeenAt === undefined)
414
+ this.phaseEnded("credentials", loginStartedAt);
415
+ else {
416
+ this.phaseEnded("credentials", loginStartedAt, this.challengeSeenAt);
417
+ this.phaseEnded("approvalWait", this.challengeSeenAt);
418
+ }
419
+ }
381
420
  if (!await this.hasLiveSession(page)) {
382
421
  throw new BrowserAuthError("Sign-in did not produce a verified Brightspace session.", "session_validation");
383
422
  }
package/build/auth-cli.js CHANGED
@@ -10,6 +10,7 @@ import { BrowserAuth, TokenManager } from "./auth/index.js";
10
10
  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
+ import { formatPhaseMarker } from "./auth/auth-phases.js";
13
14
  import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
14
15
  import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
15
16
  import { reexecLatestIfStale } from "./utils/self-update.js";
@@ -63,7 +64,11 @@ async function main() {
63
64
  const onMfaChallenge = automatic
64
65
  ? (number) => console.log(number ? `MFA_NUMBER:${number}` : "MFA_PENDING")
65
66
  : undefined;
66
- await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge }).authenticate({
67
+ // Stage timings for the server's dev activity log, same stdout channel.
68
+ const onPhase = automatic
69
+ ? (phase, elapsedMs) => console.log(formatPhaseMarker(phase, elapsedMs))
70
+ : undefined;
71
+ await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase }).authenticate({
67
72
  automatic,
68
73
  onAuthenticated: async (token) => {
69
74
  await tokenManager.setToken(token);
@@ -102,6 +102,10 @@ const AUTH_FAILURE_GUIDANCE = {
102
102
  "Tell the user that, then call this tool again right away without waiting for them to " +
103
103
  "confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
104
104
  "as the sign-in completes. Keep calling until it succeeds or reports a different error.",
105
+ inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
106
+ "school's login pages). Call this tool again right away: the next call joins the same " +
107
+ "sign-in, so it sends no second MFA prompt, and returns the number to approve as soon as " +
108
+ "one appears. Don't ask the user to do anything yet.",
105
109
  };
106
110
  /**
107
111
  * mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { appendFileSync, mkdirSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
+ import { AUTH_PHASES } from "../auth/auth-phases.js";
5
6
  const calls = new AsyncLocalStorage();
6
7
  const runId = randomUUID();
7
8
  const MAX_BYTES = 5 * 1024 * 1024;
@@ -23,7 +24,7 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
23
24
  if (!directory)
24
25
  return;
25
26
  if (!["tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
26
- "token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished"].includes(event))
27
+ "token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished", "auth_phase"].includes(event))
27
28
  return;
28
29
  const at = new Date().toISOString();
29
30
  const record = { at, runId, pid: process.pid, event };
@@ -37,6 +38,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
37
38
  record.outcome = fields.outcome;
38
39
  if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending"].includes(fields.reason ?? ""))
39
40
  record.reason = fields.reason;
41
+ if (AUTH_PHASES.includes(fields.phase ?? ""))
42
+ record.phase = fields.phase;
40
43
  try {
41
44
  mkdirSync(directory, { recursive: true, mode: 0o700 });
42
45
  const file = join(directory, `activity-${at.slice(0, 10)}-${process.pid}-${runId}.jsonl`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.11.6",
3
+ "version": "3.11.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",