brightspace-mcp-server 3.11.6 → 3.11.8

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);
@@ -10,6 +10,18 @@ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable,
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { matchesModifiedSince } from "../utils/modified-since.js";
13
+ function pageInfo(offset, count, returned, total, unavailableCourseIds = []) {
14
+ const nextOffset = total !== null && offset + returned < total ? offset + returned : null;
15
+ return { offset, count, returned, total, nextOffset, truncated: nextOffset !== null,
16
+ coverageComplete: total !== null && unavailableCourseIds.length === 0, unavailableCourseIds };
17
+ }
18
+ // Preserve the original array in content[0] and existing auth notices, while
19
+ // exposing machine-readable continuation metadata in a separate text block.
20
+ function arrayPage(items, pagination, notice) {
21
+ const result = notice ? toolResponseWithNotice(items, notice) : toolResponse(items);
22
+ result.content.push({ type: "text", text: JSON.stringify({ pagination }) });
23
+ return result;
24
+ }
13
25
  /**
14
26
  * A date the runtime can actually order, or null. Unreadable and absent are the
15
27
  * same answer, so a caller can fall through to the next best.
@@ -87,13 +99,13 @@ export function mapNewsItem(item) {
87
99
  export function registerGetAnnouncements(server, apiClient, config) {
88
100
  server.registerTool("get_announcements", {
89
101
  title: "Get Announcements",
90
- description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
102
+ description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
91
103
  inputSchema: GetAnnouncementsSchema,
92
104
  }, async (args) => {
93
105
  try {
94
106
  log("DEBUG", "get_announcements tool called", { args });
95
107
  // Parse and validate input
96
- const { courseId, count, modifiedSince } = GetAnnouncementsSchema.parse(args);
108
+ const { courseId, count, offset, modifiedSince } = GetAnnouncementsSchema.parse(args);
97
109
  const cutoff = modifiedSince ? new Date(modifiedSince) : null;
98
110
  // Single course case
99
111
  if (courseId) {
@@ -104,16 +116,18 @@ export function registerGetAnnouncements(server, apiClient, config) {
104
116
  const matched = cutoff
105
117
  ? published.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
106
118
  : published;
107
- const announcements = matched.sort(newestFirst).slice(0, count);
119
+ const announcements = matched.sort(newestFirst).slice(offset, offset + count);
120
+ const pagination = pageInfo(offset, count, announcements.length, matched.length);
108
121
  log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
109
- return toolResponse(modifiedSince
110
- ? {
122
+ return modifiedSince
123
+ ? toolResponse({
111
124
  announcements,
125
+ pagination,
112
126
  modifiedSince,
113
127
  returned: announcements.length,
114
128
  filteredOut: published.length - matched.length,
115
- }
116
- : announcements);
129
+ })
130
+ : arrayPage(announcements, pagination);
117
131
  }
118
132
  catch (error) {
119
133
  // A pending sign-in is not an empty course: the route never
@@ -137,10 +151,11 @@ export function registerGetAnnouncements(server, apiClient, config) {
137
151
  returned: 0,
138
152
  filteredOut: 0,
139
153
  authPending: true,
154
+ pagination: pageInfo(offset, count, 0, null, [courseId]),
140
155
  notice,
141
156
  });
142
157
  }
143
- return toolResponseWithNotice([], notice);
158
+ return arrayPage([], pageInfo(offset, count, 0, null, [courseId]), notice);
144
159
  }
145
160
  throw error;
146
161
  }
@@ -181,7 +196,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
181
196
  // 403 means no access (past course, etc) - log and skip
182
197
  if (error?.status === 403) {
183
198
  log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
184
- return [];
199
+ return { unavailable: true, courseId: item.OrgUnit.Id };
185
200
  }
186
201
  // A pending sign-in only means this course's route never
187
202
  // answered — it says nothing about the other courses, whose
@@ -201,13 +216,16 @@ export function registerGetAnnouncements(server, apiClient, config) {
201
216
  .map((r) => r.value);
202
217
  const pending = settled.filter((v) => !Array.isArray(v) && v?.authPending === true);
203
218
  const allAnnouncements = settled.filter((v) => Array.isArray(v)).flat();
219
+ const unavailableCourseIds = results.flatMap((result, index) => result.status === "rejected" || (!Array.isArray(result.value) && result.value?.courseId)
220
+ ? [filteredEnrollments[index].OrgUnit.Id] : []);
204
221
  const allMatched = cutoff
205
222
  ? allAnnouncements.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
206
223
  : allAnnouncements;
207
224
  // Sort by the scheduled date and slice to count
208
225
  const announcements = allMatched
209
226
  .sort(newestFirst)
210
- .slice(0, count);
227
+ .slice(offset, offset + count);
228
+ const pagination = pageInfo(offset, count, announcements.length, allMatched.length, unavailableCourseIds);
211
229
  log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total ` +
212
230
  `across ${enrollmentItems.length} courses${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
213
231
  // With modifiedSince the response is already an object (the shape
@@ -218,14 +236,15 @@ export function registerGetAnnouncements(server, apiClient, config) {
218
236
  // the notice instead of changing content[0].
219
237
  if (!modifiedSince) {
220
238
  if (pending.length === 0)
221
- return toolResponse(announcements);
222
- return toolResponseWithNotice(announcements, "Sign-in to Brightspace is still in progress, so announcements for " +
239
+ return arrayPage(announcements, pagination);
240
+ return arrayPage(announcements, pagination, "Sign-in to Brightspace is still in progress, so announcements for " +
223
241
  `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
224
242
  `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
225
243
  "sign-in finishes.");
226
244
  }
227
245
  const response = {
228
246
  announcements,
247
+ pagination,
229
248
  modifiedSince,
230
249
  returned: announcements.length,
231
250
  filteredOut: allAnnouncements.length - allMatched.length,
@@ -37,7 +37,8 @@ export const GetMyGradesSchema = z.object({
37
37
  });
38
38
  export const GetAnnouncementsSchema = z.object({
39
39
  courseId: z.coerce.number().int().positive().optional().describe("Course ID to get announcements for. If omitted, returns recent announcements across all courses."),
40
- count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return, newest first (1-50, default 10). There is no pagination or offset: when more announcements exist than count, only the newest are returned. To get fuller coverage, call once per course with courseId so courses do not compete for the same slots, and use modifiedSince to return only announcements changed since a cutoff."),
40
+ count: z.coerce.number().int().min(1).max(50).default(10).describe("Announcements per page: 1-50 (default 10)."),
41
+ offset: z.coerce.number().int().min(0).default(0).describe("Announcement offset after filtering and sorting. Continue with pagination.nextOffset; restart at 0 after a failed course becomes available."),
41
42
  modifiedSince: z.string().datetime({ offset: true, message: "modifiedSince must be an ISO 8601 datetime, e.g. 2026-01-15T00:00:00Z" }).optional()
42
43
  .describe("Only return announcements last modified at or after this ISO 8601 datetime (e.g. 2026-01-15T00:00:00Z). Announcements with no modified timestamp are always included. When set, the response reports how many announcements were filtered out."),
43
44
  });
@@ -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.8",
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",