brightspace-mcp-server 3.8.2 → 3.8.3

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, and TU Delft NetID via SURFconext. 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, 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, or --tudelft at TU Delft).
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
- mfaChallenges: ["#idRichContext_DisplaySign", "#idDiv_SAOTCAS_Title", "#idDiv_SAOTCC_Title"],
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
+ }
@@ -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";
@@ -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.2",
3
+ "version": "3.8.3",
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",