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 +10 -2
- package/build/auth/index.js +1 -0
- package/build/auth/sso-flow.js +4 -0
- package/build/auth/tudelft-sso.js +144 -0
- package/build/setup.js +7 -0
- package/build/utils/self-update.js +8 -2
- 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
|
|
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
|
|
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`).
|
package/build/auth/index.js
CHANGED
|
@@ -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";
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -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
|
|
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.
|
|
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",
|