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 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 CUNY Login. Other schools need a compatible automated sign-in flow; unsupported login pages return an actionable error.
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 --cuny at CUNY).
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
@@ -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
+ }
@@ -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
- /** One authenticator code per login. See submitMfaCode. */
99
- mfaCodeSubmitted = false;
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 once per login. This runs on every two-second poll, and Microsoft
450
- // commonly leaves the field on screen while it validates, so without this
451
- // a correct code gets a second prompt on the next tick. That prompt blocks
452
- // on stdin, and the deadline is only checked between iterations, so the
453
- // five-minute budget can never fire while parked there.
454
- if (this.mfaCodeSubmitted)
455
- return false;
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.mfaCodeSubmitted = true;
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.");
@@ -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.11",
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",