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 +8 -16
- package/build/auth/browser-auth.js +35 -2
- package/build/auth/browser-state-store.js +2 -1
- package/build/auth/microsoft-session.js +83 -0
- package/build/auth/purdue-sso.js +57 -0
- package/build/auth/sso-flow.js +1 -0
- package/build/auth/suny-sso.js +4 -0
- package/build/auth/western-sso.js +3 -0
- package/build/tools/get-server-info.js +20 -13
- package/build/utils/config.js +3 -0
- package/package.json +1 -1
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
|
|
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
|
|
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
|
|
98
|
+
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
+
}
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -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();
|
package/build/auth/sso-flow.js
CHANGED
package/build/auth/suny-sso.js
CHANGED
|
@@ -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);
|
|
@@ -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.
|
|
15
|
-
*
|
|
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,
|
|
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 () =>
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
}
|
package/build/utils/config.js
CHANGED
|
@@ -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.
|
|
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",
|