brightspace-mcp-server 3.7.13 → 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
@@ -19,7 +19,7 @@ Connects to D2L Brightspace. Automatic login supports Purdue's Microsoft Entra f
19
19
 
20
20
  ## Install
21
21
 
22
- **You need:** [Node.js 20+](https://nodejs.org/) and an available native credential store: macOS Keychain, Windows Credential Manager, or Linux Secret Service. Linux requires `secret-tool` and an unlocked desktop keyring. Install `libsecret-tools` on Debian/Ubuntu, or the package providing `secret-tool` on your distribution. A container or SSH session without Secret Service cannot persist authentication in v2.
22
+ **You need:** [Node.js 20+](https://nodejs.org/) and a native credential store: macOS Keychain, Windows Credential Manager, or Linux Secret Service (requires `secret-tool` and an unlocked keyring — install `libsecret-tools` on Debian/Ubuntu, or your distribution's `secret-tool` package). A container or SSH session without Secret Service can't persist authentication in v2.
23
23
 
24
24
  **Option 1: Let your AI do it**
25
25
 
@@ -58,7 +58,7 @@ npx -y brightspace-mcp-server@latest setup --tudelft
58
58
 
59
59
  Use your NetID rather than your student email address. The TU Delft flow is headless NetID username and password sign-in only, including automatic re-authentication when the saved session expires; it does not support MFA or any other interactive step. If your account requires one, [open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) — that tenant isn't supported yet.
60
60
 
61
- The wizard saves your password in the native credential store and asks how you complete MFA. Authentication can wait for approval or number matching, prompt in the terminal for a code from Google Authenticator or another app, or open a visible browser for other interactive methods. The wizard can configure Claude Desktop, Cursor, Codex Desktop and CLI, and Claude Code when they are installed. Restart your AI client when it finishes.
61
+ The wizard saves your password in the native credential store and asks how you'll complete MFA: wait for approval or number matching, enter a terminal code from Google Authenticator or another app, or use a visible browser for other interactive methods. It can also configure Claude Desktop, Cursor, Codex Desktop and CLI, and Claude Code when installed — restart your AI client when it finishes.
62
62
 
63
63
  Any other D2L school: run `setup` without a flag and paste your Brightspace URL (for example `https://yourschool.brightspace.com`).
64
64
 
@@ -95,27 +95,19 @@ Claude Desktop uses a separate configuration, which the setup wizard can update
95
95
 
96
96
  ## Session Expired?
97
97
 
98
- There is nothing to log into first. Ask for your grades and the sign-in happens as part of that request, so the assistant never has to check whether you are authenticated before it can answer. Starting your AI client touches Brightspace not at all: a restart on its own will never set off an MFA prompt.
98
+ There's no login step — asking a question signs you in.
99
99
 
100
- Returning the next day normally requires no action. The server renews short-lived API tokens over HTTPS using the saved Brightspace session. If that session ends, a browser restores your saved Microsoft session and tries silent SSO. Approval and code-based modes stay headless; when an automatic run needs a code, run the auth command below to enter it securely in the terminal.
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
- If visible-browser mode is configured, the auth command opens a window that stays open for up to five minutes so you can finish credentials and MFA manually when automatic sign-in cannot continue. Background recovery during a tool call always runs headless so it never opens a browser over your work; set `D2L_HEADLESS=false` in the server's environment to let it open one too. Rerunning setup preserves your previous hidden or visible choice as the prompt default.
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
- Your school's policy controls when MFA is required. There is no local 24-hour cutoff, and the server no longer discards browser state after one hour. A network outage preserves the saved session and returns a temporary error.
105
-
106
- If you miss an MFA request, automatic browser authentication pauses for five minutes before trying again. Existing tokens and HTTP token renewal still work. Browser-based SSO also pauses because Microsoft can send another phone prompt during a redirect, even without a password submission. Run this command in a terminal to retry immediately, see a number match, or enter an authenticator code:
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:
107
105
 
108
106
  ```bash
109
107
  npx -y brightspace-mcp-server@latest auth
110
108
  ```
111
109
 
112
- Run it from your home folder. On macOS, a terminal that lacks Files and Folders permission (the terminal panel inside Claude Desktop, or a fresh editor terminal) cannot start `npx` from inside Documents, Desktop, or Downloads — it fails with `EPERM: process.cwd failed … uv_cwd` before the server runs. The same applies if your AI client launches the server with one of those folders as its working directory; grant the app access under System Settings → Privacy & Security → Files and Folders, or start the server elsewhere.
113
-
114
- **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.
115
-
116
- 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.
117
-
118
- **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.
110
+ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or Downloads without Files and Folders permission (`EPERM`). Grant access in System Settings → Privacy & Security → Files and Folders, or run elsewhere.
119
111
 
120
112
  ## What You Can Ask About
121
113
 
@@ -131,7 +123,7 @@ The number in that response can go stale if the request times out or you tap Den
131
123
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
132
124
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
133
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 |
134
- | 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 |
135
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 |
136
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 |
137
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.13",
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",