brightspace-mcp-server 3.8.11 → 3.8.13
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 +10 -2
- package/build/auth/browser-auth.js +3 -1
- package/build/auth/index.js +1 -0
- package/build/auth/leiden-sso.js +100 -0
- package/build/auth/purdue-sso.js +39 -10
- package/build/auth/sso-flow.js +4 -0
- package/build/setup.js +7 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,7 +6,7 @@ Talk to your Brightspace courses with AI. Ask about grades, due dates, quizzes,
|
|
|
6
6
|
|
|
7
7
|
This is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that connects your AI to D2L Brightspace so it can pull your grades, assignments, syllabus, and course content on demand.
|
|
8
8
|
|
|
9
|
-
Connects to D2L Brightspace. Automatic login supports Purdue's Microsoft Entra flow, SUNY campus selection, Western University, TU Delft NetID via SURFconext, and
|
|
9
|
+
Connects to D2L Brightspace. Automatic login supports Purdue's Microsoft Entra flow, SUNY campus selection, Western University, TU Delft NetID via SURFconext, CUNY Login, and Leiden University via SURFconext and Microsoft Entra. Other schools need a compatible automated sign-in flow; unsupported login pages return an actionable error.
|
|
10
10
|
|
|
11
11
|
<p align="center">
|
|
12
12
|
<img src="https://raw.githubusercontent.com/RohanMuppa/brightspace-mcp-server/main/docs/how-it-works.svg" alt="Architecture diagram" width="100%">
|
|
@@ -28,7 +28,7 @@ Paste this into Claude Code, Cursor, Windsurf, Copilot, Codex, or any AI coding
|
|
|
28
28
|
```
|
|
29
29
|
Install brightspace-mcp-server for me by following
|
|
30
30
|
https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
|
|
31
|
-
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, or --
|
|
31
|
+
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, or --leiden at Leiden).
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
**Option 2: Run it yourself**
|
|
@@ -68,6 +68,14 @@ every full sign-in, so when your Brightspace session ends, run
|
|
|
68
68
|
npx -y brightspace-mcp-server@latest setup --cuny
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
+
Leiden University students can use `--leiden`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
npx -y brightspace-mcp-server@latest setup --leiden
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Sign in with your full Microsoft address (for example `s1234567@vuw.leidenuniv.nl`). The flow picks Leiden University (Entra) on SURFconext's account page, then uses the same Microsoft sign-in as Purdue. Leiden normally asks for a code from your authenticator app, which `auth` prompts for in the terminal.
|
|
78
|
+
|
|
71
79
|
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.
|
|
72
80
|
|
|
73
81
|
Any other D2L school: run `setup` without a flag and paste your Brightspace URL (for example `https://yourschool.brightspace.com`).
|
|
@@ -512,8 +512,10 @@ export class BrowserAuth {
|
|
|
512
512
|
}
|
|
513
513
|
return;
|
|
514
514
|
}
|
|
515
|
-
if (!url.includes("login.microsoftonline.com"))
|
|
515
|
+
if (!url.includes("login.microsoftonline.com")) {
|
|
516
|
+
await this.ssoFlow.selectIdentityProvider?.(page);
|
|
516
517
|
return;
|
|
518
|
+
}
|
|
517
519
|
// The page must PROVE it is the "Stay signed in?" page before this click:
|
|
518
520
|
// #idSIButton9 is Microsoft's id for the primary button on EVERY sign-in
|
|
519
521
|
// page, "Next" on account name and "Sign in" on password, so clicking it
|
package/build/auth/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { PurdueSSOFlow } from "./purdue-sso.js";
|
|
|
8
8
|
export { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
|
|
9
9
|
export { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
|
|
10
10
|
export { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
|
|
11
|
+
export { LeidenSSOFlow, isLeidenBrightspace } from "./leiden-sso.js";
|
|
11
12
|
export { createSSOFlow } from "./sso-flow.js";
|
|
12
13
|
export { TokenManager } from "./token-manager.js";
|
|
13
14
|
export { SessionStore } from "./session-store.js";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Leiden University login via SURFconext and Microsoft Entra. Licensed under MIT; see LICENSE. */
|
|
2
|
+
import { PurdueSSOFlow } from "./purdue-sso.js";
|
|
3
|
+
import { UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
4
|
+
import { log } from "../utils/logger.js";
|
|
5
|
+
const LEIDEN_HOST = "brightspace.universiteitleiden.nl";
|
|
6
|
+
const SURFCONEXT_HOST = "engine.surfconext.nl";
|
|
7
|
+
const ENTRA_HOST = "login.microsoftonline.com";
|
|
8
|
+
// SURFconext lists eduID first and LUMC next to Leiden, so the account is
|
|
9
|
+
// picked by entity ID rather than by position or a "Leiden" text match.
|
|
10
|
+
export const LEIDEN_ENTRA_ENTITY_ID = "https://sts.windows.net/ca2a7f76-dbd7-4ec0-9108-6b3d524fb7c8/";
|
|
11
|
+
// The list wrapper never reports as visible, so the search box marks the picker.
|
|
12
|
+
const WAYF_SEARCH = "#wayf_search";
|
|
13
|
+
const LEIDEN_WAYF_ENTRY = `.wayf__idp[data-entityid="${LEIDEN_ENTRA_ENTITY_ID}"]`;
|
|
14
|
+
// SURFconext's picker can take a poll or two to render the account list
|
|
15
|
+
// (the search box itself appears first), so a miss is not treated as
|
|
16
|
+
// authoritative until it has survived a few polls in a row.
|
|
17
|
+
const MAX_WAYF_ENTRY_MISSES = 3;
|
|
18
|
+
export function isLeidenBrightspace(baseUrl) {
|
|
19
|
+
try {
|
|
20
|
+
return new URL(baseUrl).hostname.toLowerCase() === LEIDEN_HOST;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function isSurfconext(rawUrl) {
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(rawUrl);
|
|
29
|
+
return url.protocol === "https:" && url.hostname.toLowerCase() === SURFCONEXT_HOST;
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function isMicrosoftEntra(rawUrl) {
|
|
36
|
+
try {
|
|
37
|
+
const url = new URL(rawUrl);
|
|
38
|
+
return url.protocol === "https:" && url.hostname.toLowerCase() === ENTRA_HOST;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Brightspace sends Leiden users straight to SURFconext's account picker.
|
|
46
|
+
* Choosing the Entra entry there is the only Leiden-specific step; the
|
|
47
|
+
* Microsoft sign-in after it is the shared flow.
|
|
48
|
+
*/
|
|
49
|
+
export class LeidenSSOFlow {
|
|
50
|
+
common;
|
|
51
|
+
wayfEntryMisses = 0;
|
|
52
|
+
constructor(config) {
|
|
53
|
+
this.common = new PurdueSSOFlow(config);
|
|
54
|
+
}
|
|
55
|
+
hasCredentials() {
|
|
56
|
+
return this.common.hasCredentials();
|
|
57
|
+
}
|
|
58
|
+
rememberMfaResult() {
|
|
59
|
+
return this.common.rememberMfaResult();
|
|
60
|
+
}
|
|
61
|
+
async selectIdentityProvider(page) {
|
|
62
|
+
await this.chooseLeidenAccount(page);
|
|
63
|
+
}
|
|
64
|
+
async identifyAccount(page) {
|
|
65
|
+
return this.common.identifyAccount(page);
|
|
66
|
+
}
|
|
67
|
+
async login(page) {
|
|
68
|
+
await this.chooseLeidenAccount(page);
|
|
69
|
+
// The Microsoft sign-in flow below is shared with Purdue and fills
|
|
70
|
+
// whatever email/password fields it finds without checking where it is.
|
|
71
|
+
// Refuse to hand it the page at all unless SURFconext actually landed on
|
|
72
|
+
// Microsoft Entra: a redirect to a lookalike host must never reach the
|
|
73
|
+
// point where credentials get typed.
|
|
74
|
+
if (!isMicrosoftEntra(page.url())) {
|
|
75
|
+
throw new UnsupportedAuthenticationError("Leiden sign-in reached an unsupported identity-provider page. No credentials were entered there.");
|
|
76
|
+
}
|
|
77
|
+
return this.common.login(page);
|
|
78
|
+
}
|
|
79
|
+
async chooseLeidenAccount(page) {
|
|
80
|
+
if (!isSurfconext(page.url()))
|
|
81
|
+
return;
|
|
82
|
+
// The redirect back to Brightspace also passes through SURFconext.
|
|
83
|
+
if (!await page.locator(WAYF_SEARCH).first().isVisible().catch(() => false))
|
|
84
|
+
return;
|
|
85
|
+
const entry = page.locator(LEIDEN_WAYF_ENTRY).first();
|
|
86
|
+
if (!await entry.isVisible().catch(() => false)) {
|
|
87
|
+
this.wayfEntryMisses++;
|
|
88
|
+
// A lazily rendered account list can miss the first poll or two on a
|
|
89
|
+
// working account; only a miss that survives several polls means
|
|
90
|
+
// SURFconext genuinely stopped offering the Leiden entry.
|
|
91
|
+
if (this.wayfEntryMisses > MAX_WAYF_ENTRY_MISSES) {
|
|
92
|
+
throw new UnsupportedAuthenticationError("SURFconext no longer offers the Leiden University (Entra) account. Sign in with D2L_HEADLESS=false and pick the account manually.");
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
this.wayfEntryMisses = 0;
|
|
97
|
+
log("INFO", "Choosing Leiden University (Entra) on SURFconext");
|
|
98
|
+
await entry.click();
|
|
99
|
+
}
|
|
100
|
+
}
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -24,6 +24,9 @@ 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
|
+
// Entra rejects a wrong code in place: the field stays and this message appears.
|
|
28
|
+
const MFA_CODE_ERROR_SELECTOR = "#idSpan_SAOTCC_Error_OTC";
|
|
29
|
+
const MAX_MFA_CODE_ATTEMPTS = 3;
|
|
27
30
|
/**
|
|
28
31
|
* Entra's "Don't ask again for N days" checkbox: the number-match page's id
|
|
29
32
|
* first, then the verification-code page's, then the label text for a tenant
|
|
@@ -95,8 +98,15 @@ function signInName(username, baseUrl) {
|
|
|
95
98
|
export class PurdueSSOFlow {
|
|
96
99
|
config;
|
|
97
100
|
accountHintSubmitted = false;
|
|
98
|
-
/**
|
|
99
|
-
|
|
101
|
+
/** Authenticator codes asked for during this login. See submitMfaCode. */
|
|
102
|
+
mfaCodeAttempts = 0;
|
|
103
|
+
/**
|
|
104
|
+
* True for exactly one poll after a code is resubmitted in response to a
|
|
105
|
+
* rejection. Entra's rejection span can still be in the DOM on that very
|
|
106
|
+
* next poll - it hasn't re-rendered yet, so what's on screen is still the
|
|
107
|
+
* PREVIOUS attempt's verdict, not this one's. See submitMfaCode.
|
|
108
|
+
*/
|
|
109
|
+
awaitingMfaCodeSettle = false;
|
|
100
110
|
/** Set once per login, the first time Entra's MFA page is handled. */
|
|
101
111
|
rememberMfa;
|
|
102
112
|
duoMfa;
|
|
@@ -446,18 +456,37 @@ export class PurdueSSOFlow {
|
|
|
446
456
|
return false;
|
|
447
457
|
if (this.config.headless === false)
|
|
448
458
|
return false;
|
|
449
|
-
// Ask
|
|
450
|
-
// commonly leaves the field on screen while
|
|
451
|
-
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
|
|
455
|
-
|
|
459
|
+
// Ask again only after Entra's rejection message. This runs on every
|
|
460
|
+
// two-second poll, and Microsoft commonly leaves the field on screen while
|
|
461
|
+
// it validates, so a lingering field alone would give a correct code a
|
|
462
|
+
// second prompt on the next tick. That prompt blocks on stdin, and the
|
|
463
|
+
// deadline is only checked between iterations, so the five-minute budget
|
|
464
|
+
// can never fire while parked there.
|
|
465
|
+
const isRetry = this.mfaCodeAttempts > 0;
|
|
466
|
+
if (isRetry) {
|
|
467
|
+
// The rejection span can still be the stale one from the attempt we
|
|
468
|
+
// just resubmitted in reaction to - Entra hasn't re-rendered yet, so
|
|
469
|
+
// this poll can't tell that span apart from a genuine rejection of the
|
|
470
|
+
// new attempt. Give it exactly one more poll (like cuny-sso.ts requiring
|
|
471
|
+
// the URL to change before trusting its own error state) before trusting
|
|
472
|
+
// the span again.
|
|
473
|
+
if (this.awaitingMfaCodeSettle) {
|
|
474
|
+
this.awaitingMfaCodeSettle = false;
|
|
475
|
+
return false;
|
|
476
|
+
}
|
|
477
|
+
if (!await page.locator(MFA_CODE_ERROR_SELECTOR).first().isVisible().catch(() => false))
|
|
478
|
+
return false;
|
|
479
|
+
if (this.mfaCodeAttempts >= MAX_MFA_CODE_ATTEMPTS) {
|
|
480
|
+
throw new BrowserAuthError(`Microsoft rejected ${this.mfaCodeAttempts} authenticator codes. Run \`${AUTH_COMMAND}\` to try again.`, "mfa_code");
|
|
481
|
+
}
|
|
482
|
+
log("WARN", "Microsoft rejected that code. Enter the current one from your authenticator app.");
|
|
483
|
+
}
|
|
456
484
|
if (!this.config.requestMfaCode) {
|
|
457
485
|
throw new UnsupportedAuthenticationError(`This MFA method requires a code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
|
|
458
486
|
}
|
|
459
487
|
await this.rememberMfaDevice(page);
|
|
460
|
-
this.
|
|
488
|
+
this.mfaCodeAttempts += 1;
|
|
489
|
+
this.awaitingMfaCodeSettle = isRetry;
|
|
461
490
|
const code = await this.config.requestMfaCode();
|
|
462
491
|
if (!/^\d{6,8}$/.test(code))
|
|
463
492
|
throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -8,6 +8,7 @@ import { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
|
|
|
8
8
|
import { WesternSSOFlow, isWesternBrightspace } from "./western-sso.js";
|
|
9
9
|
import { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
|
|
10
10
|
import { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
|
|
11
|
+
import { LeidenSSOFlow, isLeidenBrightspace } from "./leiden-sso.js";
|
|
11
12
|
import { BrowserAuthError } from "../utils/errors.js";
|
|
12
13
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
13
14
|
export class UnsupportedAuthenticationError extends BrowserAuthError {
|
|
@@ -50,6 +51,9 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
|
|
|
50
51
|
if (isTUDelftBrightspace(config.baseUrl)) {
|
|
51
52
|
return new TUDelftSSOFlow(credentials);
|
|
52
53
|
}
|
|
54
|
+
if (isLeidenBrightspace(config.baseUrl)) {
|
|
55
|
+
return new LeidenSSOFlow(credentials);
|
|
56
|
+
}
|
|
53
57
|
if (isSunyBrightspace(config.baseUrl)) {
|
|
54
58
|
return new SunySSOFlow({ ...credentials, campus: config.campus });
|
|
55
59
|
}
|
package/build/setup.js
CHANGED
|
@@ -31,6 +31,13 @@ export const SCHOOL_PRESETS = {
|
|
|
31
31
|
mfaNote: "NetID sign-in normally runs headlessly without MFA.",
|
|
32
32
|
usernameHint: "Use your NetID, not your student email address.",
|
|
33
33
|
},
|
|
34
|
+
leiden: {
|
|
35
|
+
name: "Leiden University",
|
|
36
|
+
baseUrl: "https://brightspace.universiteitleiden.nl",
|
|
37
|
+
usernameLabel: "Leiden University username",
|
|
38
|
+
mfaNote: "Leiden usually asks for a code from your authenticator app, which you can type here.",
|
|
39
|
+
usernameHint: "Use the full address you sign in to Microsoft with, e.g. s1234567@vuw.leidenuniv.nl.",
|
|
40
|
+
},
|
|
34
41
|
purdue: {
|
|
35
42
|
name: "Purdue University",
|
|
36
43
|
baseUrl: "https://purdue.brightspace.com",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.13",
|
|
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",
|