brightspace-mcp-server 3.7.14 → 3.8.1

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
@@ -99,7 +99,7 @@ There's no login step — asking a question signs you in.
99
99
 
100
100
  **Normal days:** tokens renew over HTTPS, and a background browser silently replays your saved Microsoft session (silent SSO) if it lapses.
101
101
 
102
- **When MFA is asked:** the number to approve shows up right in the tool's response, and sign-in finishes in the background — approve it, call the tool again, and use the newest number if one goes stale. TOTP apps (Google Authenticator, etc.) get prompted via the terminal; visible-browser mode opens a window instead, though automatic recovery during a tool call still runs headless unless you set `D2L_HEADLESS=false`. On Duo, sign-in auto-answers "Is this your device?" with **yes**, since a headless run has nobody to click it — this also makes Duo remember the device, so skip it on shared machines. Set `D2L_DUO_PASSCODE` to swap the push for a typed passcode.
102
+ **When MFA is asked:** the number to approve shows up right in the tool's response, and sign-in finishes in the background — approve it, call the tool again, and use the newest number if one goes stale. TOTP apps (Google Authenticator, etc.) get prompted via the terminal; visible-browser mode opens a window instead, though automatic recovery during a tool call still runs headless unless you set `D2L_HEADLESS=false`. On Duo, sign-in auto-answers "Is this your device?" with **yes**, since a headless run has nobody to click it — this also makes Duo remember the device, so skip it on shared machines. Set `D2L_DUO_PASSCODE` to swap the push for a typed passcode. On Microsoft's MFA page the server ticks "Don't ask again" when your school offers it, so later sign-ins can skip the second factor; how long that lasts is the school's setting, not the server's (`D2L_REMEMBER_MFA=false` turns this off, and `get_server_info` shows whether it was offered).
103
103
 
104
104
  **If it gets stuck:** a missed MFA approval pauses automatic sign-in for 5 minutes. This retries immediately, takes over a stuck sign-in, or asks for a code:
105
105
 
@@ -123,7 +123,7 @@ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or
123
123
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
124
124
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
125
125
  | Video transcripts | "What did the professor say about pinch-off in Tuesday's lecture recording?" · "Summarize last week's BoilerCast video" — works for Kaltura and YouTube embeds; other platforms report that they aren't supported yet |
126
- | Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, and whether a credential is stored, without contacting Brightspace or revealing secrets |
126
+ | Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, whether a credential is stored, and what Microsoft remembered (`microsoftSession`: stay-signed-in and its expiry, plus whether "Don't ask again" was ticked, already on, or not offered), without contacting Brightspace or revealing secrets |
127
127
  | Calendar | "When is my midterm?" · "What's on my calendar this week?" · "Is lab cancelled on Thursday?" — reads exams, labs, review sessions, and deadlines instructors put only on the course calendar |
128
128
  | Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, graded discussion topics (any topic with a due date), and course calendar events such as exams and labs |
129
129
 
@@ -10,6 +10,7 @@ import { log } from "../utils/logger.js";
10
10
  import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError } from "./sso-flow.js";
11
11
  import { isDuoPrompt } from "./duo-mfa.js";
12
12
  import { BrowserStateStore } from "./browser-state-store.js";
13
+ import { hasNewerEntraState, recordMicrosoftSession } from "./microsoft-session.js";
13
14
  import { acquireProcessLock } from "./auth-lock.js";
14
15
  import { AuthCooldown } from "./auth-cooldown.js";
15
16
  import { mintAccessToken } from "./token-mint.js";
@@ -95,12 +96,14 @@ export class BrowserAuth {
95
96
  let page;
96
97
  let listener;
97
98
  let interrupted = false;
99
+ let state;
100
+ let stateSaved = false;
98
101
  const closeOnSignal = () => {
99
102
  interrupted = true;
100
103
  void browser?.close().catch(() => { });
101
104
  };
102
105
  try {
103
- const state = await this.stateStore.load();
106
+ state = await this.stateStore.load();
104
107
  const { chromium } = await import("playwright");
105
108
  const args = ["--disable-blink-features=AutomationControlled"];
106
109
  if (BrowserAuth.isWSLOrDocker())
@@ -135,7 +138,8 @@ export class BrowserAuth {
135
138
  // Persist the verified browser state before token acquisition. Token
136
139
  // minting can fail independently, and a temporary outage must not throw
137
140
  // away newly renewed Entra or Brightspace cookies.
138
- await this.stateStore.save(await context.storageState());
141
+ await this.saveBrowserState(await context.storageState());
142
+ stateSaved = true;
139
143
  const material = await this.harvestSessionMaterial(page, context);
140
144
  let token = null;
141
145
  if (material.cookieHeader && material.csrfToken) {
@@ -161,6 +165,11 @@ export class BrowserAuth {
161
165
  log("INFO", "Browser authentication complete");
162
166
  return { ...token, ...material, tenantOrigin: new URL(this.config.baseUrl).origin };
163
167
  }
168
+ catch (error) {
169
+ if (context && !stateSaved)
170
+ await this.keepNewerMicrosoftState(context, state);
171
+ throw error;
172
+ }
164
173
  finally {
165
174
  process.removeListener("SIGINT", closeOnSignal);
166
175
  process.removeListener("SIGTERM", closeOnSignal);
@@ -170,6 +179,30 @@ export class BrowserAuth {
170
179
  await browser?.close().catch(() => { });
171
180
  }
172
181
  }
182
+ /** Save the browser state and what Microsoft remembered in it. */
183
+ async saveBrowserState(state) {
184
+ await this.stateStore.save(state);
185
+ await recordMicrosoftSession(this.config.sessionDir, state, this.ssoFlow.rememberMfaResult?.())
186
+ .catch(() => log("WARN", "Could not record the Microsoft sign-in summary; saved sign-in state is unaffected."));
187
+ }
188
+ /**
189
+ * A run can fail after Entra already renewed its cookies (an MFA timeout,
190
+ * a token-mint outage, a Brightspace redirect stall). Keep that renewal
191
+ * when it is strictly newer than the saved jar; otherwise the saved state
192
+ * is left untouched. Never masks the original failure.
193
+ */
194
+ async keepNewerMicrosoftState(context, previous) {
195
+ try {
196
+ const current = await context.storageState();
197
+ if (!hasNewerEntraState(previous, current))
198
+ return;
199
+ await this.saveBrowserState(current);
200
+ log("INFO", "Sign-in failed, but Microsoft's renewed sign-in state was kept");
201
+ }
202
+ catch {
203
+ log("DEBUG", "Could not keep Microsoft's renewed sign-in state after a failed sign-in");
204
+ }
205
+ }
173
206
  /**
174
207
  * Run the token extraction strategy chain, cheapest to most invasive.
175
208
  * Each strategy validates against /users/whoami before returning.
@@ -8,6 +8,7 @@ import * as path from "node:path";
8
8
  import { SessionStoreError } from "../utils/errors.js";
9
9
  import { NativeCredentialStoreError } from "./credential-store.js";
10
10
  import { readEncryptedRecord, saveEncryptedRecord, trashFile } from "./encrypted-store.js";
11
+ export const BROWSER_STATE_FILE = "storage-state.encrypted.json";
11
12
  function validState(value) {
12
13
  const state = value;
13
14
  return !!state && Array.isArray(state.cookies) && Array.isArray(state.origins)
@@ -25,7 +26,7 @@ export class BrowserStateStore {
25
26
  constructor(sessionDir, options = {}) {
26
27
  this.sessionDir = sessionDir;
27
28
  this.options = options;
28
- this.file = path.join(sessionDir, "storage-state.encrypted.json");
29
+ this.file = path.join(sessionDir, BROWSER_STATE_FILE);
29
30
  this.legacyFile = path.join(sessionDir, "storage-state.json");
30
31
  }
31
32
  storeError(action, error) {
@@ -0,0 +1,83 @@
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
+ import * as fs from "node:fs/promises";
7
+ import * as path from "node:path";
8
+ import { writeFileAtomic } from "../utils/atomic-write.js";
9
+ import { BROWSER_STATE_FILE } from "./browser-state-store.js";
10
+ const SUMMARY_FILE = "microsoft-session.json";
11
+ const ENTRA_HOST = "login.microsoftonline.com";
12
+ const ENTRA_COOKIES = new Set(["ESTSAUTH", "ESTSAUTHPERSISTENT", "ESTSAUTHLIGHT"]);
13
+ const OUTCOMES = ["ticked", "already", "absent", "unknown"];
14
+ /** Latest expiry of each Entra sign-in cookie in a jar. */
15
+ function entraExpiries(state) {
16
+ const expiries = new Map();
17
+ for (const cookie of state?.cookies ?? []) {
18
+ if (!ENTRA_COOKIES.has(cookie.name) || cookie.domain.replace(/^\./, "") !== ENTRA_HOST)
19
+ continue;
20
+ expiries.set(cookie.name, Math.max(cookie.expires, expiries.get(cookie.name) ?? -Infinity));
21
+ }
22
+ return expiries;
23
+ }
24
+ /**
25
+ * True when the current jar holds an Entra sign-in cookie the previous one
26
+ * lacked, or one that now expires later. A jar with no Entra cookies is never
27
+ * newer, so a failed run cannot replace saved state with an empty one.
28
+ */
29
+ export function hasNewerEntraState(previous, current) {
30
+ const before = entraExpiries(previous);
31
+ return [...entraExpiries(current)].some(([name, expires]) => {
32
+ const old = before.get(name);
33
+ return old === undefined || expires > old;
34
+ });
35
+ }
36
+ function validSummary(value) {
37
+ const summary = value;
38
+ return !!summary && summary.version === 1
39
+ && (summary.persistentExpires === null || Number.isFinite(summary.persistentExpires))
40
+ && (summary.rememberMfa === null || (OUTCOMES.includes(summary.rememberMfa?.outcome) && typeof summary.rememberMfa.at === "string"));
41
+ }
42
+ async function readSummary(sessionDir) {
43
+ try {
44
+ const summary = JSON.parse(await fs.readFile(path.join(sessionDir, SUMMARY_FILE), "utf8"));
45
+ return validSummary(summary) ? summary : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ /**
52
+ * Record what Microsoft remembered in a browser state that was just saved.
53
+ * A login that never reached the MFA page keeps the last recorded outcome.
54
+ */
55
+ export async function recordMicrosoftSession(sessionDir, state, rememberMfa) {
56
+ const summary = {
57
+ version: 1,
58
+ persistentExpires: entraExpiries(state).get("ESTSAUTHPERSISTENT") ?? null,
59
+ rememberMfa: rememberMfa ?? (await readSummary(sessionDir))?.rememberMfa ?? null,
60
+ };
61
+ await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
62
+ await writeFileAtomic(path.join(sessionDir, SUMMARY_FILE), JSON.stringify(summary), { mode: 0o600 });
63
+ }
64
+ /** The summary for saved browser state, or undefined when there is none. */
65
+ export async function readMicrosoftSession(sessionDir, now = Date.now()) {
66
+ try {
67
+ await fs.access(path.join(sessionDir, BROWSER_STATE_FILE));
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ const summary = await readSummary(sessionDir);
73
+ if (!summary)
74
+ return undefined;
75
+ const expires = summary.persistentExpires;
76
+ const staySignedIn = expires !== null && (expires === -1 || expires * 1000 > now);
77
+ return {
78
+ staySignedIn,
79
+ staySignedInExpires: staySignedIn && expires > 0 ? new Date(expires * 1000).toISOString() : null,
80
+ rememberMfa: summary.rememberMfa?.outcome ?? "unknown",
81
+ rememberMfaAt: summary.rememberMfa?.at ?? null,
82
+ };
83
+ }
@@ -24,6 +24,22 @@ const FIELD_POLL_MS = 250;
24
24
  const NUMBER_MATCH_SELECTOR = "#idRichContext_DisplaySign";
25
25
  const MFA_CODE_SELECTORS = ["#idTxtBx_SAOTCC_OTC", 'input[name="otc"]'];
26
26
  const MFA_CODE_SUBMIT_SELECTORS = ["#idSubmit_SAOTCC_Continue", "#idSIButton9"];
27
+ /**
28
+ * Entra's "Don't ask again for N days" checkbox: the number-match page's id
29
+ * first, then the verification-code page's, then the label text for a tenant
30
+ * that renames both. "Stay signed in?" only keeps the session; this box is
31
+ * what lets the tenant skip the second factor, and the page navigates the
32
+ * instant the phone approves, so it is ticked before the challenge is
33
+ * announced or a code is asked for.
34
+ */
35
+ const REMEMBER_MFA_SELECTORS = ["#idChkBx_SAOTCAS_TD", "#idChkBx_SAOTCC_TD"];
36
+ const REMEMBER_MFA_LABEL = /don.t ask again/i;
37
+ const REMEMBER_MFA_LOG = {
38
+ ticked: "ticked",
39
+ already: "already checked",
40
+ absent: "not offered by tenant",
41
+ unknown: "could not be ticked",
42
+ };
27
43
  /** How often to look for the number while waiting on MFA. */
28
44
  const NUMBER_MATCH_POLL_MS = 2000;
29
45
  /** A person has to find their phone, unlock it, and read a prompt. */
@@ -80,6 +96,8 @@ export class PurdueSSOFlow {
80
96
  accountHintSubmitted = false;
81
97
  /** One authenticator code per login. See submitMfaCode. */
82
98
  mfaCodeSubmitted = false;
99
+ /** Set once per login, the first time Entra's MFA page is handled. */
100
+ rememberMfa;
83
101
  duoMfa;
84
102
  constructor(config) {
85
103
  this.config = config;
@@ -91,6 +109,10 @@ export class PurdueSSOFlow {
91
109
  hasCredentials() {
92
110
  return Boolean(this.config.username && this.config.password);
93
111
  }
112
+ /** What Entra's "Don't ask again" checkbox did, once its MFA page has appeared. */
113
+ rememberMfaResult() {
114
+ return this.rememberMfa;
115
+ }
94
116
  async prepareLogin(page) {
95
117
  await this.handleCampusSelector(page);
96
118
  }
@@ -306,6 +328,7 @@ export class PurdueSSOFlow {
306
328
  await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
307
329
  await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false);
308
330
  if (challengeVisible && !challenged) {
331
+ await this.rememberMfaDevice(page);
309
332
  challenged = true;
310
333
  log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
311
334
  this.config.onMfaChallenge?.(number);
@@ -430,6 +453,7 @@ export class PurdueSSOFlow {
430
453
  if (!this.config.requestMfaCode) {
431
454
  throw new UnsupportedAuthenticationError(`This MFA method requires a code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
432
455
  }
456
+ await this.rememberMfaDevice(page);
433
457
  this.mfaCodeSubmitted = true;
434
458
  const code = await this.config.requestMfaCode();
435
459
  if (!/^\d{6,8}$/.test(code))
@@ -443,6 +467,39 @@ export class PurdueSSOFlow {
443
467
  log("INFO", "Authenticator code submitted");
444
468
  return true;
445
469
  }
470
+ /**
471
+ * Tick Entra's "Don't ask again" box, once per login and never in a loop.
472
+ * An already-checked box is left alone so this can never untick it.
473
+ */
474
+ async rememberMfaDevice(page) {
475
+ if (this.rememberMfa || this.config.rememberMfa === false)
476
+ return;
477
+ if (new URL(page.url()).hostname !== "login.microsoftonline.com")
478
+ return;
479
+ // A nicety, never a reason to fail sign-in: any surprise is "unknown".
480
+ let outcome;
481
+ try {
482
+ let box = await this.firstVisible(page, REMEMBER_MFA_SELECTORS);
483
+ if (!box) {
484
+ const labelled = page.getByLabel(REMEMBER_MFA_LABEL).first();
485
+ if (await labelled.isVisible().catch(() => false))
486
+ box = labelled;
487
+ }
488
+ if (!box)
489
+ outcome = "absent";
490
+ else if (await box.isChecked())
491
+ outcome = "already";
492
+ else {
493
+ await box.check({ timeout: 5_000 });
494
+ outcome = "ticked";
495
+ }
496
+ }
497
+ catch {
498
+ outcome = "unknown";
499
+ }
500
+ this.rememberMfa = { outcome, at: new Date().toISOString() };
501
+ log("INFO", `Entra remember-MFA checkbox: ${REMEMBER_MFA_LOG[outcome]}`);
502
+ }
446
503
  async firstVisible(page, selectors) {
447
504
  for (const selector of selectors) {
448
505
  const target = page.locator(selector).first();
@@ -42,6 +42,7 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
42
42
  password: config.password,
43
43
  baseUrl: config.baseUrl,
44
44
  headless: config.headless,
45
+ rememberMfa: config.rememberMfa,
45
46
  requestMfaCode,
46
47
  onMfaChallenge,
47
48
  };
@@ -55,6 +55,7 @@ export class SunySSOFlow {
55
55
  password: config.password,
56
56
  baseUrl: `https://${SUNY_BRIGHTSPACE_HOST}`,
57
57
  headless: config.headless,
58
+ rememberMfa: config.rememberMfa,
58
59
  requestMfaCode: config.requestMfaCode,
59
60
  onMfaChallenge: config.onMfaChallenge,
60
61
  });
@@ -62,6 +63,9 @@ export class SunySSOFlow {
62
63
  hasCredentials() {
63
64
  return this.defaultFlow.hasCredentials();
64
65
  }
66
+ rememberMfaResult() {
67
+ return this.defaultFlow.rememberMfaResult();
68
+ }
65
69
  async prepareLogin(page) {
66
70
  await this.startSamlLogin(page);
67
71
  await this.selectCampus(page);
@@ -19,6 +19,9 @@ export class WesternSSOFlow {
19
19
  hasCredentials() {
20
20
  return this.common.hasCredentials();
21
21
  }
22
+ rememberMfaResult() {
23
+ return this.common.rememberMfaResult();
24
+ }
22
25
  async prepareLogin(page) {
23
26
  await this.startWesternLogin(page);
24
27
  }
@@ -3,6 +3,7 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
+ import { readMicrosoftSession } from "../auth/microsoft-session.js";
6
7
  import { getConfigStorePath } from "../utils/config-store.js";
7
8
  import { GetServerInfoSchema } from "./schemas.js";
8
9
  import { toolResponse } from "./tool-helpers.js";
@@ -11,22 +12,28 @@ import { toolResponse } from "./tool-helpers.js";
11
12
  *
12
13
  * Answers from the startup config alone: no network call, so it can never
13
14
  * trigger a sign-in. hasStoredCredential reflects the keychain lookup made at
14
- * startup, which is the credential this process actually holds. Nothing
15
- * secret — password, username, token, cookie — is ever included.
15
+ * startup, which is the credential this process actually holds.
16
+ * microsoftSession comes from the plain summary saved beside the browser
17
+ * state and is omitted when there is no saved state. Nothing secret —
18
+ * password, username, token, cookie — is ever included.
16
19
  */
17
20
  export function registerGetServerInfo(server, config, version) {
18
21
  server.registerTool("get_server_info", {
19
22
  title: "Get Server Info",
20
- description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, and whether a credential is stored. Use this for troubleshooting or when the user asks which version they have. Never contacts Brightspace and never returns secrets.",
23
+ description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, and (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox. Use this for troubleshooting or when the user asks which version they have. Never contacts Brightspace and never returns secrets.",
21
24
  inputSchema: GetServerInfoSchema,
22
- }, async () => toolResponse({
23
- version,
24
- node: process.version,
25
- platform: process.platform,
26
- arch: process.arch,
27
- configPath: getConfigStorePath(),
28
- sessionStatePath: config.sessionDir,
29
- schoolUrl: config.baseUrl,
30
- hasStoredCredential: config.password !== undefined,
31
- }));
25
+ }, async () => {
26
+ const microsoftSession = await readMicrosoftSession(config.sessionDir);
27
+ return toolResponse({
28
+ version,
29
+ node: process.version,
30
+ platform: process.platform,
31
+ arch: process.arch,
32
+ configPath: getConfigStorePath(),
33
+ sessionStatePath: config.sessionDir,
34
+ schoolUrl: config.baseUrl,
35
+ hasStoredCredential: config.password !== undefined,
36
+ ...(microsoftSession && { microsoftSession }),
37
+ });
38
+ });
32
39
  }
@@ -29,6 +29,8 @@ export async function loadConfig() {
29
29
  const headless = envBoolean(process.env.D2L_HEADLESS, "D2L_HEADLESS")
30
30
  ?? store?.headless
31
31
  ?? true;
32
+ // Ask Microsoft to skip the second factor for its "Don't ask again" window.
33
+ const rememberMfa = envBoolean(process.env.D2L_REMEMBER_MFA, "D2L_REMEMBER_MFA") ?? true;
32
34
  // Resolve tokenTtl: env > store > default (3600)
33
35
  const tokenTtl = positiveSeconds(process.env.D2L_TOKEN_TTL, "D2L_TOKEN_TTL")
34
36
  ?? positiveSeconds(store?.tokenTtl, "tokenTtl in config.json")
@@ -62,6 +64,7 @@ export async function loadConfig() {
62
64
  legacyBrowserStateMigrated: legacyMigration?.browserState === "encrypted",
63
65
  tokenTtl,
64
66
  headless,
67
+ rememberMfa,
65
68
  username,
66
69
  password,
67
70
  campus: process.env.D2L_CAMPUS || store?.campus,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.7.14",
3
+ "version": "3.8.1",
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",