brightspace-mcp-server 3.7.6 → 3.7.10

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 and SUNY campus selection. 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, and TU Delft NetID via SURFconext. 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 if I'm at Purdue, or --suny if I'm at a SUNY campus).
31
+ (use --purdue at Purdue, --suny at SUNY, or --tudelft at TU Delft).
32
32
  ```
33
33
 
34
34
  **Option 2: Run it yourself**
@@ -50,6 +50,14 @@ you're at and skips SUNY's campus picker when you sign in:
50
50
  npx -y brightspace-mcp-server@latest setup --suny
51
51
  ```
52
52
 
53
+ TU Delft students can use `--tudelft` to select the Brightspace URL and NetID login:
54
+
55
+ ```bash
56
+ npx -y brightspace-mcp-server@latest setup --tudelft
57
+ ```
58
+
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
+
53
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.
54
62
 
55
63
  Any other D2L school: run `setup` without a flag and paste your Brightspace URL (for example `https://yourschool.brightspace.com`).
@@ -6,6 +6,7 @@
6
6
  export { BrowserAuth } from "./browser-auth.js";
7
7
  export { PurdueSSOFlow } from "./purdue-sso.js";
8
8
  export { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
9
+ export { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
9
10
  export { createSSOFlow } from "./sso-flow.js";
10
11
  export { TokenManager } from "./token-manager.js";
11
12
  export { SessionStore } from "./session-store.js";
@@ -6,6 +6,7 @@
6
6
  import { PurdueSSOFlow } from "./purdue-sso.js";
7
7
  import { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
8
8
  import { WesternSSOFlow, isWesternBrightspace } from "./western-sso.js";
9
+ import { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
9
10
  import { BrowserAuthError } from "../utils/errors.js";
10
11
  import { AUTH_COMMAND } from "../utils/commands.js";
11
12
  export class UnsupportedAuthenticationError extends BrowserAuthError {
@@ -44,6 +45,9 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
44
45
  requestMfaCode,
45
46
  onMfaChallenge,
46
47
  };
48
+ if (isTUDelftBrightspace(config.baseUrl)) {
49
+ return new TUDelftSSOFlow(credentials);
50
+ }
47
51
  if (isSunyBrightspace(config.baseUrl)) {
48
52
  return new SunySSOFlow({ ...credentials, campus: config.campus });
49
53
  }
@@ -0,0 +1,144 @@
1
+ /** TU Delft NetID login via SURFconext. Licensed under MIT; see LICENSE. */
2
+ import { UnsupportedAuthenticationError } from "./sso-flow.js";
3
+ import { BrowserAuthError } from "../utils/errors.js";
4
+ import { log } from "../utils/logger.js";
5
+ const BRIGHTSPACE_HOST = "brightspace.tudelft.nl";
6
+ const NETID_HOST = "login.tudelft.nl";
7
+ const SURFCONEXT_HOST = "engine.surfconext.nl";
8
+ const LOGIN_TIMEOUT_MS = 60_000;
9
+ // Give the login page time to settle after submission before trusting any
10
+ // alert banner as a rejection: a 1 second margin, or two polls, whichever a
11
+ // given test/runtime environment can observe.
12
+ const REJECTION_SETTLE_MS = 1_000;
13
+ const REJECTION_SETTLE_POLLS = 2;
14
+ const REJECTION_TEXT_PATTERN = /invalid|incorrect|rejected|wrong|failed|onjuist|ongeldig/i;
15
+ export function isTUDelftBrightspace(baseUrl) {
16
+ try {
17
+ return new URL(baseUrl).hostname.toLowerCase() === BRIGHTSPACE_HOST;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ async function firstVisible(page, selectors) {
24
+ for (const selector of selectors) {
25
+ const locator = page.locator(selector).first();
26
+ if (await locator.isVisible().catch(() => false))
27
+ return locator;
28
+ }
29
+ return null;
30
+ }
31
+ function isNetID(url) {
32
+ return url.protocol === "https:" && url.hostname.toLowerCase() === NETID_HOST;
33
+ }
34
+ /**
35
+ * Only a specific error container counts as a rejection on its own. A bare
36
+ * `[role="alert"]` is also common for benign, standing banners (maintenance
37
+ * notices, cookie prompts) that have nothing to do with the credentials just
38
+ * submitted, so it only counts when its text reads like a rejection.
39
+ */
40
+ async function findRejectionAlert(page) {
41
+ const specific = await firstVisible(page, ['.form-error', '.login-error', '.alert-danger', '#error', '[role="alert"].error']);
42
+ if (specific)
43
+ return specific;
44
+ const generic = await firstVisible(page, ['[role="alert"]']);
45
+ if (generic) {
46
+ const text = (await generic.textContent().catch(() => null)) ?? "";
47
+ if (REJECTION_TEXT_PATTERN.test(text))
48
+ return generic;
49
+ }
50
+ return null;
51
+ }
52
+ /** NetID uses a simultaneous username/password form and normally requires no MFA. */
53
+ export class TUDelftSSOFlow {
54
+ config;
55
+ constructor(config) {
56
+ this.config = config;
57
+ }
58
+ hasCredentials() {
59
+ return Boolean(this.config.username && this.config.password);
60
+ }
61
+ async login(page) {
62
+ if (!this.hasCredentials())
63
+ return false;
64
+ log("INFO", "Starting TU Delft NetID sign-in");
65
+ let submitted = false;
66
+ let submittedAt = null;
67
+ let pollsSinceSubmit = 0;
68
+ let consentSubmitted = false;
69
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
70
+ try {
71
+ do {
72
+ let current;
73
+ try {
74
+ current = new URL(page.url());
75
+ }
76
+ catch {
77
+ // Not a parseable URL (e.g. a transient navigation state). Give it
78
+ // another poll instead of treating it as a fatal unknown host.
79
+ await page.waitForTimeout(250);
80
+ continue;
81
+ }
82
+ if (current.protocol !== "https:" && current.protocol !== "http:") {
83
+ // about:blank and similar interstitial pages show up between
84
+ // navigations; they're not an identity-provider page to evaluate.
85
+ await page.waitForTimeout(250);
86
+ continue;
87
+ }
88
+ if (current.origin === `https://${BRIGHTSPACE_HOST}` && /^\/d2l\/home(?:\/|$)/.test(current.pathname)) {
89
+ return true;
90
+ }
91
+ if (isNetID(current)) {
92
+ if (!submitted) {
93
+ const username = await firstVisible(page, ['input#username', 'input[name="username"]', 'input[name="j_username"]']);
94
+ const password = await firstVisible(page, ['input#password', 'input[name="password"]', 'input[name="j_password"]']);
95
+ const submit = await firstVisible(page, ['#submit_button', 'button[name="_eventId_proceed"]', 'button[type="submit"]', 'input[type="submit"]']);
96
+ if (username && password && submit) {
97
+ await username.fill(this.config.username);
98
+ // Recheck after the account field: a navigation must never send
99
+ // the password to a different origin using a stale locator.
100
+ if (!isNetID(new URL(page.url()))) {
101
+ throw new UnsupportedAuthenticationError("TU Delft sign-in left the trusted NetID page before password entry.");
102
+ }
103
+ await password.fill(this.config.password);
104
+ await submit.click();
105
+ submitted = true;
106
+ submittedAt = Date.now();
107
+ }
108
+ }
109
+ else {
110
+ pollsSinceSubmit++;
111
+ const settled = pollsSinceSubmit >= REJECTION_SETTLE_POLLS
112
+ || (submittedAt !== null && Date.now() - submittedAt >= REJECTION_SETTLE_MS);
113
+ // Recheck the host: the alert lookup is async and must not act on
114
+ // a page that has since navigated away from NetID.
115
+ if (settled && isNetID(new URL(page.url())) && await findRejectionAlert(page)) {
116
+ throw new UnsupportedAuthenticationError("TU Delft NetID sign-in was rejected. Check the saved NetID and password.");
117
+ }
118
+ }
119
+ }
120
+ else if (current.protocol === "https:" && current.hostname.toLowerCase() === SURFCONEXT_HOST) {
121
+ // A user's first visit may require consent to share information with
122
+ // Brightspace. No credentials are entered on SURFconext itself.
123
+ if (!consentSubmitted) {
124
+ const consent = await firstVisible(page, ['#consent_accept', 'button[name="confirm"]', 'input[name="confirm"]', 'input[name="yes"]']);
125
+ if (consent) {
126
+ await consent.click();
127
+ consentSubmitted = true;
128
+ }
129
+ }
130
+ }
131
+ else if (current.origin !== `https://${BRIGHTSPACE_HOST}`) {
132
+ throw new UnsupportedAuthenticationError("TU Delft sign-in reached an unsupported identity-provider page. No credentials were entered there.");
133
+ }
134
+ await page.waitForTimeout(250);
135
+ } while (Date.now() < deadline);
136
+ throw new UnsupportedAuthenticationError("TU Delft NetID sign-in did not reach Brightspace within 60 seconds. Check the saved credentials or retry with D2L_HEADLESS=false for an interactive sign-in.");
137
+ }
138
+ catch (error) {
139
+ if (error instanceof BrowserAuthError)
140
+ throw error;
141
+ throw new UnsupportedAuthenticationError("TU Delft NetID sign-in could not complete automatically.", error);
142
+ }
143
+ }
144
+ }
package/build/setup.js CHANGED
@@ -24,6 +24,13 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
24
24
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
25
25
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
26
26
  export const SCHOOL_PRESETS = {
27
+ tudelft: {
28
+ name: "TU Delft",
29
+ baseUrl: "https://brightspace.tudelft.nl",
30
+ usernameLabel: "TU Delft NetID",
31
+ mfaNote: "NetID sign-in normally runs headlessly without MFA.",
32
+ usernameHint: "Use your NetID, not your student email address.",
33
+ },
27
34
  purdue: {
28
35
  name: "Purdue University",
29
36
  baseUrl: "https://purdue.brightspace.com",
@@ -18,6 +18,10 @@
18
18
  * from inside a live process, which is the thing this package deliberately
19
19
  * does not do.
20
20
  *
21
+ * Automatic sign-in (`--automatic`, spawned by the server's AuthRunner) never
22
+ * re-execs: it must run the auth CLI shipped alongside that server, not
23
+ * whatever npm calls latest. Only a user-run `auth` self-updates.
24
+ *
21
25
  * Opt out with D2L_NO_UPDATE_CHECK.
22
26
  */
23
27
  import { spawn } from "node:child_process";
@@ -61,10 +65,12 @@ export async function reexecLatestIfStale(deps = {}) {
61
65
  const { env = process.env, argv = process.argv, installedVersion, fetchImpl = fetch, runningFromNpxCache = ownNpxCacheDir() !== null, platform = process.platform, spawnImpl = spawn, } = deps;
62
66
  if (installedVersion === undefined)
63
67
  return null;
64
- // Cheap local checks first, so an opted-out or npx-launched run never even
65
- // touches the network.
68
+ // Cheap local checks first, so an opted-out, npx-launched, or automatic run
69
+ // never even touches the network.
66
70
  if (env[REEXEC_SENTINEL] || env.D2L_NO_UPDATE_CHECK || runningFromNpxCache)
67
71
  return null;
72
+ if (argv.includes("--automatic"))
73
+ return null; // stay on the server's sibling version
68
74
  const latest = await fetchLatestVersion(fetchImpl);
69
75
  if (!shouldReexec({ env, latest, installedVersion, runningFromNpxCache }))
70
76
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.7.6",
3
+ "version": "3.7.10",
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",