brightspace-mcp-server 3.8.2 → 3.8.4
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 +12 -2
- package/build/auth/browser-auth.js +3 -1
- package/build/auth/cuny-sso.js +216 -0
- package/build/auth/index.js +1 -0
- package/build/auth/purdue-sso.js +6 -4
- package/build/auth/sso-flow.js +4 -0
- package/build/setup.js +8 -1
- 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,
|
|
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.
|
|
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,
|
|
31
|
+
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, or --cuny at CUNY).
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
**Option 2: Run it yourself**
|
|
@@ -58,6 +58,16 @@ 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
|
+
CUNY students can add `--cuny`. Sign in with your full CUNY Login address
|
|
62
|
+
(`firstname.lastname01@login.cuny.edu`); authentication asks in the terminal
|
|
63
|
+
for the 6-digit code from your authenticator app. CUNY asks for that code on
|
|
64
|
+
every full sign-in, so when your Brightspace session ends, run
|
|
65
|
+
`npx -y brightspace-mcp-server@latest auth` in a terminal again:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
npx -y brightspace-mcp-server@latest setup --cuny
|
|
69
|
+
```
|
|
70
|
+
|
|
61
71
|
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
72
|
|
|
63
73
|
Any other D2L school: run `setup` without a flag and paste your Brightspace URL (for example `https://yourschool.brightspace.com`).
|
|
@@ -26,7 +26,9 @@ const SILENT_SSO = {
|
|
|
26
26
|
// get past this gate otherwise.
|
|
27
27
|
emailFields: ["input[type=email]", "input[name=loginfmt]", "input[name=j_username]", "input#signinid"],
|
|
28
28
|
credentialFields: ['input#username', 'input#userName', 'input[type="password"]'],
|
|
29
|
-
|
|
29
|
+
// Entra's number match and code prompts, then CUNY Login's authenticator-code
|
|
30
|
+
// field (cuny-sso.ts CUNY_TOTP_SELECTOR).
|
|
31
|
+
mfaChallenges: ["#idRichContext_DisplaySign", "#idDiv_SAOTCAS_Title", "#idDiv_SAOTCC_Title", '[id="otpValue|input"]'],
|
|
30
32
|
campusSaml: 'a[href*="/d2l/lp/auth/saml/initiate-login"]',
|
|
31
33
|
kmsiCheckbox: "#KmsiCheckboxField",
|
|
32
34
|
kmsiTitle: "Stay signed in?",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** CUNY login entry point. Licensed under MIT; see LICENSE. */
|
|
2
|
+
import { BrowserAuthError } from "../utils/errors.js";
|
|
3
|
+
import { log } from "../utils/logger.js";
|
|
4
|
+
import { AUTH_COMMAND, SETUP_COMMAND } from "../utils/commands.js";
|
|
5
|
+
import { MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
6
|
+
/** All CUNY campuses share one Brightspace tenant behind CUNY Login (Oracle OAM). */
|
|
7
|
+
const CUNY_BRIGHTSPACE_HOST = "brightspace.cuny.edu";
|
|
8
|
+
const CUNY_LOGIN_HOST = "ssologin.cuny.edu";
|
|
9
|
+
/**
|
|
10
|
+
* Oracle JET generates the authenticator-code field's id with a pipe in it,
|
|
11
|
+
* which is not valid in an `#id` selector, so match the attribute instead.
|
|
12
|
+
* browser-auth.ts lists the same selector so a run that resumes on this page
|
|
13
|
+
* is recognized as an MFA challenge.
|
|
14
|
+
*/
|
|
15
|
+
export const CUNY_TOTP_SELECTOR = '[id="otpValue|input"]';
|
|
16
|
+
const SELECTORS = {
|
|
17
|
+
// The visible field takes the full @login.cuny.edu address. CUNY's own
|
|
18
|
+
// submit handler copies the short name into a hidden #CUNYLoginUsername,
|
|
19
|
+
// so that hidden field is left for the page to fill.
|
|
20
|
+
username: "#CUNYLoginUsernameDisplay",
|
|
21
|
+
password: "#CUNYLoginPassword",
|
|
22
|
+
submit: "#submit",
|
|
23
|
+
// Only rendered on the reload OAM serves after rejecting the password.
|
|
24
|
+
serverError: "#serverError",
|
|
25
|
+
totp: CUNY_TOTP_SELECTOR,
|
|
26
|
+
};
|
|
27
|
+
const POLL_MS = 500;
|
|
28
|
+
/** CUNY parks on a processing page for about ten seconds before the code challenge. */
|
|
29
|
+
const CHALLENGE_TIMEOUT_MS = 60_000;
|
|
30
|
+
/** A person has to find their authenticator and read a code. */
|
|
31
|
+
const MFA_TIMEOUT_MS = 5 * 60 * 1000;
|
|
32
|
+
/** True for the shared CUNY Brightspace instance. */
|
|
33
|
+
export function isCunyBrightspace(baseUrl) {
|
|
34
|
+
try {
|
|
35
|
+
return new URL(baseUrl).hostname.toLowerCase() === CUNY_BRIGHTSPACE_HOST;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Compare hosts, not substrings: the SAML round trip carries other hosts in its query. */
|
|
42
|
+
function isCunyLogin(url) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = new URL(url);
|
|
45
|
+
return parsed.protocol === "https:" && parsed.hostname.toLowerCase() === CUNY_LOGIN_HOST;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** A rejected code reloads the challenge with `?emsg=Entered+TOTP+is+incorrect.` */
|
|
52
|
+
function hasErrorMessage(url) {
|
|
53
|
+
try {
|
|
54
|
+
return new URL(url).searchParams.has("emsg");
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Login flow for CUNY Brightspace.
|
|
62
|
+
*
|
|
63
|
+
* Brightspace federates by SAML to CUNY Login at ssologin.cuny.edu, an Oracle
|
|
64
|
+
* Access Manager page rather than Entra, Shibboleth, or Duo:
|
|
65
|
+
*
|
|
66
|
+
* /d2l/login → /oamfed/idp/samlv20 (username + password)
|
|
67
|
+
* → /oaa/authnui (a "Processing Request" page, roughly ten seconds)
|
|
68
|
+
* → /oaa-totp-factor/rui/index.html (authenticator code) → /d2l/home
|
|
69
|
+
*
|
|
70
|
+
* Its username field is a plain text input, which none of the default flow's
|
|
71
|
+
* username selectors match, and its code challenge is an Oracle JET page the
|
|
72
|
+
* default MFA loop does not know. The code itself still comes from a person,
|
|
73
|
+
* through the same terminal prompt or visible browser every other code-based
|
|
74
|
+
* MFA uses.
|
|
75
|
+
*/
|
|
76
|
+
export class CunySSOFlow {
|
|
77
|
+
config;
|
|
78
|
+
constructor(config) {
|
|
79
|
+
this.config = config;
|
|
80
|
+
}
|
|
81
|
+
hasCredentials() {
|
|
82
|
+
return Boolean(this.config.username && this.config.password);
|
|
83
|
+
}
|
|
84
|
+
async login(page) {
|
|
85
|
+
try {
|
|
86
|
+
log("INFO", "Starting CUNY Login sign-in flow");
|
|
87
|
+
await this.completeLogin(page);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof BrowserAuthError)
|
|
92
|
+
throw error;
|
|
93
|
+
throw new UnsupportedAuthenticationError("CUNY Login could not complete automatic sign-in. Check saved credentials and your authenticator settings.", error);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Poll the page and answer whichever CUNY Login step is on screen. A run
|
|
98
|
+
* can hand over mid-flow (awaitSilentSSO also stops on the code page), so
|
|
99
|
+
* no step is assumed to come first.
|
|
100
|
+
*/
|
|
101
|
+
async completeLogin(page) {
|
|
102
|
+
// A visible browser may be answering a different CUNY MFA method by hand.
|
|
103
|
+
let deadline = Date.now() + (this.config.headless === false ? MFA_TIMEOUT_MS : CHALLENGE_TIMEOUT_MS);
|
|
104
|
+
let credentialsSubmitted = false;
|
|
105
|
+
let challenged = false;
|
|
106
|
+
/** The challenge URL a code was submitted from; a reload away from it with ?emsg= is a rejection. */
|
|
107
|
+
let codeSubmittedFrom = null;
|
|
108
|
+
let announced = false;
|
|
109
|
+
while (Date.now() < deadline) {
|
|
110
|
+
if (await this.isAuthenticated(page)) {
|
|
111
|
+
log("INFO", "Login successful - verified Brightspace home");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
// Never type the CUNY password into anything but CUNY Login itself.
|
|
115
|
+
if (isCunyLogin(page.url())) {
|
|
116
|
+
if (await this.isVisible(page, SELECTORS.serverError)) {
|
|
117
|
+
throw new BrowserAuthError(`CUNY Login rejected the saved username or password. Run \`${SETUP_COMMAND} --cuny\` to update them.`, "credentials");
|
|
118
|
+
}
|
|
119
|
+
if (await this.isVisible(page, SELECTORS.totp)) {
|
|
120
|
+
if (!challenged) {
|
|
121
|
+
challenged = true;
|
|
122
|
+
deadline = Date.now() + MFA_TIMEOUT_MS;
|
|
123
|
+
}
|
|
124
|
+
if (this.config.headless === false) {
|
|
125
|
+
// The person at the window types the code and can retry a typo
|
|
126
|
+
// there, so a rejection is theirs to answer, not a failure here.
|
|
127
|
+
if (!announced) {
|
|
128
|
+
announced = true;
|
|
129
|
+
log("WARN", "Enter the code from your authenticator app in the CUNY Login window. Waiting up to 5 minutes.");
|
|
130
|
+
this.config.onMfaChallenge?.(null);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
const url = page.url();
|
|
135
|
+
if (codeSubmittedFrom !== null && url !== codeSubmittedFrom && hasErrorMessage(url)) {
|
|
136
|
+
throw new MfaApprovalError(new Error("CUNY Login rejected the authenticator code."));
|
|
137
|
+
}
|
|
138
|
+
if (codeSubmittedFrom === null) {
|
|
139
|
+
codeSubmittedFrom = url;
|
|
140
|
+
await this.submitCode(page);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
else if (!credentialsSubmitted && await this.isVisible(page, SELECTORS.username)) {
|
|
145
|
+
// CUNY Login asks for a code on every full sign-in and does not
|
|
146
|
+
// re-federate Brightspace from its own cookies, so a headless run with
|
|
147
|
+
// no terminal (the MCP server's background re-authentication) can
|
|
148
|
+
// never finish. Stop before the password is sent: otherwise every tool
|
|
149
|
+
// call after the session ends posts it again, and a stale saved
|
|
150
|
+
// password would keep counting toward a CUNY lockout.
|
|
151
|
+
if (this.config.headless !== false && !this.config.requestMfaCode)
|
|
152
|
+
throw this.codePromptUnavailable();
|
|
153
|
+
credentialsSubmitted = true;
|
|
154
|
+
await this.enterCredentials(page);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await page.waitForTimeout(POLL_MS);
|
|
158
|
+
}
|
|
159
|
+
if (challenged)
|
|
160
|
+
throw new MfaApprovalError();
|
|
161
|
+
throw new UnsupportedAuthenticationError("CUNY Login did not reach the authenticator-code challenge or Brightspace. If your default CUNY MFA method is not an authenticator app, set D2L_HEADLESS=false and finish sign-in in the browser.");
|
|
162
|
+
}
|
|
163
|
+
async enterCredentials(page) {
|
|
164
|
+
if (!this.config.username)
|
|
165
|
+
throw new BrowserAuthError("Username is required for SSO login", "credentials");
|
|
166
|
+
if (!this.config.password)
|
|
167
|
+
throw new BrowserAuthError("Password is required for SSO login", "credentials");
|
|
168
|
+
log("INFO", "Entering CUNY Login credentials");
|
|
169
|
+
await page.locator(SELECTORS.username).first().fill(this.config.username);
|
|
170
|
+
await page.locator(SELECTORS.password).first().fill(this.config.password);
|
|
171
|
+
// OAM answers the POST with a full navigation, which can detach the button.
|
|
172
|
+
await page.locator(SELECTORS.submit).first().click().catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
codePromptUnavailable() {
|
|
175
|
+
return new UnsupportedAuthenticationError(`CUNY Login requires an authenticator code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
|
|
176
|
+
}
|
|
177
|
+
/** Feed CUNY's code challenge from the terminal prompt. */
|
|
178
|
+
async submitCode(page) {
|
|
179
|
+
if (!this.config.requestMfaCode)
|
|
180
|
+
throw this.codePromptUnavailable();
|
|
181
|
+
const code = (await this.config.requestMfaCode()).replace(/\s/g, "");
|
|
182
|
+
if (!/^\d{6,8}$/.test(code))
|
|
183
|
+
throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
|
|
184
|
+
await page.locator(SELECTORS.totp).first().fill(code);
|
|
185
|
+
// Oracle JET gives the Verify button no id; its label is the stable handle.
|
|
186
|
+
const verify = page.getByRole("button", { name: /^\s*verify\s*$/i }).first();
|
|
187
|
+
if (await verify.isVisible().catch(() => false))
|
|
188
|
+
await verify.click().catch(() => { });
|
|
189
|
+
else
|
|
190
|
+
await page.locator(SELECTORS.totp).first().press("Enter");
|
|
191
|
+
log("INFO", "Authenticator code submitted");
|
|
192
|
+
}
|
|
193
|
+
async isVisible(page, selector) {
|
|
194
|
+
return page.locator(selector).first().isVisible().catch(() => false);
|
|
195
|
+
}
|
|
196
|
+
/** The login shell also exposes D2L.LP, so verify origin and home as well. */
|
|
197
|
+
async isAuthenticated(page) {
|
|
198
|
+
try {
|
|
199
|
+
const expected = new URL(this.config.baseUrl ?? `https://${CUNY_BRIGHTSPACE_HOST}`);
|
|
200
|
+
const current = new URL(page.url());
|
|
201
|
+
if (current.origin !== expected.origin || !/^\/d2l\/home(?:\/|$)/.test(current.pathname))
|
|
202
|
+
return false;
|
|
203
|
+
const cookies = await page.context().cookies(expected.origin);
|
|
204
|
+
if (!cookies.some(cookie => cookie.name === "d2lSessionVal" && Boolean(cookie.value)))
|
|
205
|
+
return false;
|
|
206
|
+
return await page.evaluate(() => {
|
|
207
|
+
const d2l = window.D2L;
|
|
208
|
+
return Boolean(d2l?.LP);
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
// Redirects can replace the execution context mid-check. Keep polling.
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
package/build/auth/index.js
CHANGED
|
@@ -7,6 +7,7 @@ export { BrowserAuth } from "./browser-auth.js";
|
|
|
7
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
|
+
export { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
|
|
10
11
|
export { createSSOFlow } from "./sso-flow.js";
|
|
11
12
|
export { TokenManager } from "./token-manager.js";
|
|
12
13
|
export { SessionStore } from "./session-store.js";
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -318,6 +318,12 @@ export class PurdueSSOFlow {
|
|
|
318
318
|
let resendNotFoundWarned = false;
|
|
319
319
|
try {
|
|
320
320
|
while (Date.now() < deadline) {
|
|
321
|
+
// A verified session outranks whatever challenge controls linger on
|
|
322
|
+
// screen: answering them would prompt or announce for nothing.
|
|
323
|
+
if (await this.isAuthenticated(page)) {
|
|
324
|
+
log("INFO", "Login successful - verified Brightspace home");
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
321
327
|
if (await this.duoMfa.handle(page))
|
|
322
328
|
challenged = true;
|
|
323
329
|
if (await this.submitMfaCode(page))
|
|
@@ -349,10 +355,6 @@ export class PurdueSSOFlow {
|
|
|
349
355
|
numberVanishedAt = null;
|
|
350
356
|
resendNotFoundWarned = false;
|
|
351
357
|
}
|
|
352
|
-
if (await this.isAuthenticated(page)) {
|
|
353
|
-
log("INFO", "Login successful - verified Brightspace home");
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
358
|
// Fix 2: Entra's number-match request itself times out (or the user
|
|
357
359
|
// taps Deny) well before the 5-minute budget above. Once that has
|
|
358
360
|
// happened, ask Entra for another one instead of polling a dead
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -7,6 +7,7 @@ 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
9
|
import { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
|
|
10
|
+
import { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
|
|
10
11
|
import { BrowserAuthError } from "../utils/errors.js";
|
|
11
12
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
12
13
|
export class UnsupportedAuthenticationError extends BrowserAuthError {
|
|
@@ -55,5 +56,8 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
|
|
|
55
56
|
if (isWesternBrightspace(config.baseUrl)) {
|
|
56
57
|
return new WesternSSOFlow(credentials);
|
|
57
58
|
}
|
|
59
|
+
if (isCunyBrightspace(config.baseUrl)) {
|
|
60
|
+
return new CunySSOFlow(credentials);
|
|
61
|
+
}
|
|
58
62
|
return new PurdueSSOFlow(credentials);
|
|
59
63
|
}
|
package/build/setup.js
CHANGED
|
@@ -52,9 +52,16 @@ export const SCHOOL_PRESETS = {
|
|
|
52
52
|
mfaNote: "Approve the sign-in request from your MFA app.",
|
|
53
53
|
usernameHint: "Use your full sign-in address if your Western account requires it.",
|
|
54
54
|
},
|
|
55
|
+
cuny: {
|
|
56
|
+
name: "CUNY",
|
|
57
|
+
baseUrl: "https://brightspace.cuny.edu",
|
|
58
|
+
usernameLabel: "CUNY Login username",
|
|
59
|
+
mfaNote: "Type the 6-digit code from your authenticator app when prompted.",
|
|
60
|
+
usernameHint: "Use your full CUNY Login address, e.g. firstname.lastname01@login.cuny.edu",
|
|
61
|
+
},
|
|
55
62
|
};
|
|
56
63
|
/**
|
|
57
|
-
* Pick the school preset named by `--purdue`, `--suny`, `--western`, etc.
|
|
64
|
+
* Pick the school preset named by `--purdue`, `--suny`, `--western`, `--cuny`, etc.
|
|
58
65
|
*
|
|
59
66
|
* Own properties only: a bare index would make `--constructor` or
|
|
60
67
|
* `--__proto__` resolve to something off `Object.prototype` and hand the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.4",
|
|
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",
|