brightspace-mcp-server 3.8.12 → 3.8.14

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`).
@@ -143,5 +151,6 @@ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or
143
151
 
144
152
  When a content-file download returns 403 or 404, `download_file` checks topic metadata and the course table of contents. Confirmed restrictions return `{ success: false, available: false, reason, message, startDate, endDate }` so the assistant can explain when content opens or why it has closed. Unexplained 404s and server/network failures retain their original errors.
145
153
 
154
+ When course content or announcements are converted to markdown, `javascript:`/`data:` links are rendered as plain text (the link itself is dropped, not followed) and D2L's per-session query parameters (`d2lSessionVal`, `d2lSecureSessionVal`, and the cache-busting `_`) are stripped from any remaining links and images before they reach the assistant.
146
155
 
147
156
  Licensed under the MIT License.
@@ -78,16 +78,47 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
78
78
  }
79
79
  return objects;
80
80
  }
81
- /** A Next value resolved to a request path. */
81
+ /**
82
+ * A Next value resolved to a request path, or null when it should not be
83
+ * followed.
84
+ *
85
+ * Adapted from JhostinAleck/brightspace-mcp (MIT): beyond dropping an
86
+ * absolute Next's host (the client always prefixes its own configured base
87
+ * URL, so a hostile/garbled Next cannot redirect requests elsewhere), anything
88
+ * -- absolute or server-relative -- whose path falls outside `/d2l/api/` is
89
+ * rejected rather than followed, since that is the only space these paginated
90
+ * endpoints legitimately point back into.
91
+ */
82
92
  function nextPath(firstPath, next) {
83
93
  if (/^https?:\/\//i.test(next)) {
84
94
  const url = new URL(next);
95
+ if (!isApiPath(url.pathname)) {
96
+ log("INFO", "Pagination stopped: Next link left the D2L API", {
97
+ firstPath,
98
+ nextPath: url.pathname,
99
+ });
100
+ return null;
101
+ }
85
102
  return `${url.pathname}${url.search}`;
86
103
  }
87
104
  // A Next that begins with a slash is already a path, not a bookmark.
88
105
  // Appending it as one would ask for a page that does not exist.
89
106
  if (next.startsWith("/")) {
107
+ const nextPathOnly = next.split("?")[0] ?? next;
108
+ if (!isApiPath(nextPathOnly)) {
109
+ log("INFO", "Pagination stopped: Next link left the D2L API", {
110
+ firstPath,
111
+ // Log only the path, not the query string: a rejected server-relative
112
+ // Next can carry a `d2lSessionVal=` token in its query.
113
+ nextPath: nextPathOnly,
114
+ });
115
+ return null;
116
+ }
90
117
  return next;
91
118
  }
92
119
  return withBookmark(firstPath, next);
93
120
  }
121
+ /** Whether a Next path stays inside the D2L API namespace these endpoints page through. */
122
+ function isApiPath(pathname) {
123
+ return pathname.startsWith("/d2l/api/");
124
+ }
@@ -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
+ }
@@ -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",
@@ -60,9 +60,70 @@ function getTurndownService() {
60
60
  return body ? `\n\n${body}\n\n` : "";
61
61
  },
62
62
  });
63
+ // D2L appends per-session query params (d2lSessionVal, d2lSecureSessionVal,
64
+ // a `_` cache-buster) to in-content hrefs. Left alone, the markdown we hand
65
+ // the model echoes a live session token into the chat transcript. These
66
+ // two rules are unshifted ahead of Turndown's built-in link/image rules
67
+ // (addRule always inserts at the front of the rule list), so they run
68
+ // first and the defaults never see the raw attribute.
69
+ turndownService.addRule("linkSessionTokens", {
70
+ filter: (node) => node.nodeName === "A" && !!node.getAttribute("href"),
71
+ replacement: (content, node) => {
72
+ const href = node.getAttribute("href") ?? "";
73
+ // javascript:/data: hrefs aren't content links; drop the link but
74
+ // keep whatever text was inside it.
75
+ if (isUnsafeHref(href))
76
+ return content;
77
+ const cleaned = stripSessionParams(href).replace(/([()])/g, "\\$1");
78
+ const title = node.getAttribute("title");
79
+ const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
80
+ return `[${content}](${cleaned}${titlePart})`;
81
+ },
82
+ });
83
+ turndownService.addRule("imageSessionTokens", {
84
+ filter: "img",
85
+ replacement: (_content, node) => {
86
+ const el = node;
87
+ const alt = el.getAttribute("alt") ?? "";
88
+ const src = stripSessionParams(el.getAttribute("src") ?? "");
89
+ const title = el.getAttribute("title");
90
+ const titlePart = title ? ` "${title}"` : "";
91
+ return src ? `![${alt}](${src}${titlePart})` : "";
92
+ },
93
+ });
63
94
  }
64
95
  return turndownService;
65
96
  }
97
+ const SESSION_QUERY_PARAMS = new Set(["d2lsessionval", "d2lsecuresessionval", "_"]);
98
+ /**
99
+ * Strip D2L's per-session query params (`d2lSessionVal`, `d2lSecureSessionVal`,
100
+ * and the `_` cache-buster) from a URL. Everything else about the URL --
101
+ * scheme, path, other params -- is left untouched, including a relative href,
102
+ * which is returned as-is apart from this filtering.
103
+ */
104
+ function stripSessionParams(url) {
105
+ const queryStart = url.indexOf("?");
106
+ if (queryStart < 0)
107
+ return url;
108
+ const earlyHash = url.indexOf("#");
109
+ if (earlyHash >= 0 && earlyHash < queryStart) {
110
+ // The "?" falls inside the fragment (e.g. "#frag?x=1"), not a real query
111
+ // string -- nothing to strip, and treating it as one would mangle the
112
+ // fragment.
113
+ return url;
114
+ }
115
+ const hashStart = url.indexOf("#", queryStart);
116
+ const query = url.slice(queryStart + 1, hashStart < 0 ? undefined : hashStart);
117
+ const hash = hashStart < 0 ? "" : url.slice(hashStart);
118
+ const kept = query
119
+ .split("&")
120
+ .filter((param) => param !== "" && !SESSION_QUERY_PARAMS.has(param.split("=")[0].toLowerCase()));
121
+ return url.slice(0, queryStart) + (kept.length > 0 ? `?${kept.join("&")}` : "") + hash;
122
+ }
123
+ /** True for `javascript:`/`data:` hrefs -- never a legitimate content link. */
124
+ function isUnsafeHref(href) {
125
+ return /^\s*(javascript|data):/i.test(href);
126
+ }
66
127
  /** A cell's text, flattened so one table cell stays one table cell. */
67
128
  function cellText(content) {
68
129
  return content
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.8.12",
3
+ "version": "3.8.14",
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",