brightspace-mcp-server 3.15.1 → 3.15.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
@@ -57,7 +57,7 @@ If your school uses D2L Brightspace, yes. These have automatic sign-in built in:
57
57
  | McGill University | Microsoft Entra | `--mcgill` |
58
58
  | Javeriana Cali | OneGate (password + authenticator code) | `--javeriana` |
59
59
  | Ngee Ann Polytechnic | Microsoft Entra | `--ngeeann` |
60
- | Any other D2L school | Paste your Brightspace address; if the login page isn't recognized, a browser window opens so you can sign in by hand | none |
60
+ | Any other D2L school | Paste your Brightspace address. If the login page isn't recognized, pick option 2 at setup's sign-in question so the page opens for you to finish by hand | none |
61
61
 
62
62
  School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
63
63
 
@@ -97,7 +97,6 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
97
97
  | Setting | What it does |
98
98
  |---------|--------------|
99
99
  | `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this as option 2 of its one "how often do you want to approve sign-ins" question and saves it as `rememberMfa` in `config.json`; the env var overrides it ([details](docs/sign-in.md#fewer-2fa-prompts)) |
100
- | `D2L_PASSWORDLESS=true` | Sign in with Microsoft Entra's passwordless phone approval, so no password is saved on the computer (off by default; Entra schools only). Every sign-in, including automatic ones, then waits for you to approve on your phone, so it suits interactive use, not unattended or scheduled jobs. `setup` asks this and saves it as `passwordless` in `config.json`; the env var overrides it ([details](docs/sign-in.md#passwordless-sign-in)) |
101
100
  | `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup` (option 3), which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
102
101
  | `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
103
102
  | `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
@@ -12,6 +12,20 @@ import { withRetry, isRetryableFailure, retryAfterMsFrom, parseRetryAfter, } fro
12
12
  import { log } from "../utils/logger.js";
13
13
  import { AUTH_COMMAND } from "../utils/commands.js";
14
14
  import { devActivity } from "../utils/dev-activity.js";
15
+ /**
16
+ * True when a followed redirect ended on this Brightspace's sign-in page. A
17
+ * web route asked with a dead cookie 302s there rather than serving the
18
+ * expired-session stub, and fetch follows the redirect to an HTTP 200.
19
+ */
20
+ function landedOnLogin(responseUrl, baseUrl) {
21
+ try {
22
+ const url = new URL(responseUrl);
23
+ return url.origin === new URL(baseUrl).origin && url.pathname.toLowerCase() === "/d2l/login";
24
+ }
25
+ catch {
26
+ return false;
27
+ }
28
+ }
15
29
  /** An ordinary course HTML link to the login page is not an expired session. */
16
30
  function isExpiredSessionRedirect(body, baseUrl) {
17
31
  const expiredTarget = (target) => {
@@ -575,8 +589,8 @@ export class D2LApiClient {
575
589
  const contentType = (response.headers.get("content-type") ?? "").toLowerCase();
576
590
  if (contentType.startsWith("text/html")) {
577
591
  const body = await response.text();
578
- if (isExpiredSessionRedirect(body, this.baseUrl)) {
579
- log("DEBUG", "File download answered with the session-expired stub, treating it as a 401");
592
+ if (isExpiredSessionRedirect(body, this.baseUrl) || landedOnLogin(response.url, this.baseUrl)) {
593
+ log("DEBUG", "File download answered with the sign-in page, treating it as a 401");
580
594
  throw new ApiError(401, path, `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
581
595
  }
582
596
  // A legitimate HTML page: hand back an equivalent response with the
package/build/setup.js CHANGED
@@ -361,7 +361,7 @@ export function buildConfigToSave(existing, answers) {
361
361
  username: answers.username,
362
362
  // Always the freshly typed one: a carried v1 plaintext password would
363
363
  // otherwise be the value written to the native store.
364
- password: answers.passwordless ? undefined : answers.password,
364
+ password: answers.password,
365
365
  // Only ever the freshly typed one. Never read back out of `carried`: the
366
366
  // enrollment lives in the credential store, never in config.json, so a
367
367
  // value here could only have come from this run.
@@ -370,8 +370,10 @@ export function buildConfigToSave(existing, answers) {
370
370
  };
371
371
  if (answers.rememberMfa !== undefined)
372
372
  config.rememberMfa = answers.rememberMfa;
373
- if (answers.passwordless !== undefined)
374
- config.passwordless = answers.passwordless;
373
+ // Setup no longer offers passwordless sign-in and always saves a password,
374
+ // so a carried `passwordless: true` would make sign-in ignore that password.
375
+ // D2L_PASSWORDLESS still turns it on for anyone who wants it.
376
+ delete config.passwordless;
375
377
  if (answers.campus)
376
378
  config.campus = answers.campus;
377
379
  return config;
@@ -438,6 +440,10 @@ async function main() {
438
440
  }
439
441
  console.log(dim(" By Rohan Muppa — github.com/rohanmuppa/brightspace-mcp-server"));
440
442
  console.log("");
443
+ // An assistant often opens this wizard in a side panel the user has never
444
+ // used, and their typing keeps going to the chat box until they click here.
445
+ console.log(" Click in this window, then type your answer to each question and press Enter.");
446
+ console.log("");
441
447
  const rl = readline.createInterface({
442
448
  input: process.stdin,
443
449
  output: process.stdout,
@@ -496,44 +502,20 @@ async function main() {
496
502
  }
497
503
  console.log("");
498
504
  // ── Step 3: Password (hidden) ────────────────────────────────────
499
- // Opt-in only, asked the way remember-MFA is: passwordless trades a saved
500
- // password for a phone approval on every sign-in, so it is never the default.
501
- let savedPasswordless;
502
- try {
503
- savedPasswordless = configStoreExists() ? loadConfigStore().passwordless : undefined;
504
- }
505
- catch {
506
- // An invalid old config is replaced by the setup values below.
507
- }
508
- console.log(dim(" Microsoft Entra schools can sign you in with only a phone approval, so no password is saved on this computer."));
509
- console.log(dim(" The cost: every sign-in, including automatic ones after a session expires, waits until you approve on your phone."));
510
- console.log(dim(" Fine for using an assistant at your desk; not for unattended or scheduled use. It needs passwordless phone sign-in"));
511
- console.log(dim(" turned on in Microsoft Authenticator first, or Microsoft will still ask for a password and sign-in will stop."));
512
- const defaultPasswordless = savedPasswordless === true ? "yes" : "no";
513
- let passwordlessAnswer = "";
514
- while (!/^(y(es)?|no?)$/i.test(passwordlessAnswer)) {
515
- passwordlessAnswer = await ask(rl, ` Sign in without a saved password (Microsoft Entra only)? (yes/no) [${defaultPasswordless}]: `) || defaultPasswordless;
516
- if (!/^(y(es)?|no?)$/i.test(passwordlessAnswer))
517
- console.log(yellow(" Please enter yes or no."));
518
- }
519
- const passwordless = /^y/i.test(passwordlessAnswer);
520
- console.log("");
521
505
  // Close the rl temporarily since askPassword manages its own
522
506
  rl.close();
523
507
  let password = "";
524
- if (!passwordless) {
525
- console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
526
- const passwordPrompt = preset
527
- ? `What is your ${preset.name} password? `
528
- : "What is your Brightspace password? ";
529
- while (!password) {
530
- password = await askPassword(passwordPrompt);
531
- if (!password) {
532
- console.log(yellow(" Password is required."));
533
- }
508
+ console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
509
+ const passwordPrompt = preset
510
+ ? `What is your ${preset.name} password? `
511
+ : "What is your Brightspace password? ";
512
+ while (!password) {
513
+ password = await askPassword(passwordPrompt);
514
+ if (!password) {
515
+ console.log(yellow(" Password is required."));
534
516
  }
535
- console.log("");
536
517
  }
518
+ console.log("");
537
519
  // Re-open readline for remaining prompts
538
520
  let rl2 = readline.createInterface({
539
521
  input: process.stdin,
@@ -553,9 +535,9 @@ async function main() {
553
535
  // MFA you have is detected at sign-in time, so offering "approve a prompt"
554
536
  // and "type a code" as separate choices would be a distinction the code does
555
537
  // not make, and picking between them would change nothing on disk.
556
- console.log(" When your school asks you to approve the sign-in, how will you do it?");
557
- console.log(" 1. I approve on my phone, or I type a code from an authenticator app (recommended)");
558
- console.log(" 2. Something else — open a browser window I can use");
538
+ console.log(" How do you confirm it's you when you sign in?");
539
+ console.log(" 1. With a phone app: I tap Approve or type a code (recommended)");
540
+ console.log(" 2. Another way, like a text message: show me the sign-in page");
559
541
  let savedHeadless;
560
542
  let savedRememberMfa;
561
543
  try {
@@ -575,8 +557,8 @@ async function main() {
575
557
  }
576
558
  const headless = mfaChoice !== "2";
577
559
  console.log(dim(headless
578
- ? " Authentication will run without a browser window."
579
- : " A browser window will open when authentication is needed."));
560
+ ? " Sign-in will run in the background."
561
+ : " The sign-in page will open when you sign in here, so you can finish it yourself."));
580
562
  console.log("");
581
563
  // ── Step 4b: How often to approve (the one 2FA question) ─────────
582
564
  // Opt-in only: the default asks every time, and a shared computer must never
@@ -621,13 +603,10 @@ async function main() {
621
603
  totpUri,
622
604
  headless,
623
605
  rememberMfa,
624
- passwordless,
625
606
  campus: campus || undefined,
626
607
  });
627
608
  await saveSecureConfig(config);
628
- console.log(green(passwordless
629
- ? " No password saved: each sign-in will wait for your phone approval."
630
- : " Password saved in your operating system credential store."));
609
+ console.log(green(" Password saved in your operating system credential store."));
631
610
  if (totpUri)
632
611
  console.log(green(" Authenticator enrollment saved in your operating system credential store."));
633
612
  if (shortcuts?.removeKey) {
@@ -664,7 +643,7 @@ async function main() {
664
643
  // ── Step 7: Claude Desktop auto-config ───────────────────────────
665
644
  const claudePath = getClaudeDesktopConfigPath();
666
645
  if (claudePath) {
667
- const configClaude = await ask(rl2, "Would you like to automatically configure Claude Desktop? (yes/no): ");
646
+ const configClaude = await ask(rl2, "Would you like to automatically configure Claude Desktop? Highly recommended. (yes/no): ");
668
647
  if (/^y(es)?$/i.test(configClaude) &&
669
648
  await registerWithClient(rl2, "Claude Desktop", inspectMcpClient(claudePath), () => configureMcpClient(claudePath))) {
670
649
  configuredClients.push("Claude Desktop");
@@ -696,7 +675,7 @@ async function main() {
696
675
  for (const client of cliMcpClients()) {
697
676
  if (!isCliAvailable(client))
698
677
  continue;
699
- const configureClient = await ask(rl2, `${client.displayName} detected. Configure it automatically? (yes/no): `);
678
+ const configureClient = await ask(rl2, `${client.displayName} detected. Configure it automatically? Highly recommended. (yes/no): `);
700
679
  if (/^y(es)?$/i.test(configureClient) &&
701
680
  await registerWithClient(rl2, client.displayName, inspectCliMcpClient(client), (replace) => registerCliMcpClient(client, { replace }))) {
702
681
  configuredClients.push(client.displayName);
@@ -288,7 +288,7 @@ export function registerDownloadFile(server, apiClient) {
288
288
  }
289
289
  // Determine download source
290
290
  if (coursePath !== undefined) {
291
- const filePath = parseCoursePath(coursePath, courseId);
291
+ const filePath = parseCoursePath(coursePath, courseId, apiClient.origin);
292
292
  if (typeof filePath !== "string")
293
293
  return errorResponse(filePath.error);
294
294
  return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename);
@@ -311,7 +311,7 @@ export function registerDownloadFile(server, apiClient) {
311
311
  return await downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename);
312
312
  }
313
313
  else {
314
- return errorResponse("Either topicId (for content files), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
314
+ return errorResponse("Either topicId (for content files), coursePath (for course files linked from a module description), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
315
315
  }
316
316
  }
317
317
  catch (error) {
@@ -329,7 +329,15 @@ async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, c
329
329
  log("INFO", `Downloading course file: courseId=${courseId}, path=${filePath}`);
330
330
  const response = await apiClient.getPageRaw(`${QUICKLINK_PATH}?ou=${courseId}&type=coursefile&fileId=${encodeURIComponent(filePath)}`);
331
331
  if (!response) {
332
- return errorResponse(`Course files need a Brightspace web session cookie, and none is available. Sign in with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser.`);
332
+ return errorResponse(`Course files are served only to a signed-in Brightspace web session, and there is no valid one: the session cookie is missing or has expired. Sign in again with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser.`);
333
+ }
334
+ // A missing file or a refused one can come back as an ordinary Brightspace
335
+ // web page with HTTP 200. That page is not the file, so it is never saved
336
+ // or shown under the file's name.
337
+ const contentType = (response.headers.get("Content-Type") ?? "").toLowerCase();
338
+ if (contentType.startsWith("text/html") && !/\.html?$/i.test(filePath)) {
339
+ await response.body?.cancel();
340
+ return errorResponse(`Brightspace answered with a web page instead of the course file "${filePath}". The file may have been moved or deleted, or this account may not be allowed to open it.`);
333
341
  }
334
342
  const disposition = response.headers.get("Content-Disposition") ?? "";
335
343
  const filename = parseContentDispositionFilename(disposition) ?? filePath.split("/").pop() ?? "download";
@@ -109,9 +109,9 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
109
109
  else if (currentDepth >= MAX_CONTENT_DEPTH) {
110
110
  log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
111
111
  }
112
- const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId) : [];
113
112
  // Only include module if it has matching children (or filter is 'all')
114
113
  if (typeFilter === 'all' || processedChildren.length > 0) {
114
+ const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId, apiClient.origin) : [];
115
115
  tree.push({
116
116
  type: 'module',
117
117
  id: item.Id,
@@ -3,25 +3,26 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
+ import { tagAttributes } from "./html-attributes.js";
7
+ import { toBrightspacePath } from "./transcript/lti.js";
6
8
  export const QUICKLINK_PATH = "/d2l/common/dialogs/quickLink/quickLink.d2l";
7
9
  /**
8
10
  * Reduce a coursePath argument to a plain course-file path ("Notes/a.pdf").
9
11
  * Accepts that path or the quickLink URL a module description carries, and
10
12
  * refuses anything that could name a file outside the course file store, so
11
13
  * the argument can only ever steer the request inside this course's files.
14
+ * An absolute quickLink URL must be on `origin`; without one, only a
15
+ * root-relative link is accepted.
12
16
  */
13
- export function parseCoursePath(input, courseId) {
17
+ export function parseCoursePath(input, courseId, origin) {
14
18
  let filePath = input.trim();
15
- if (filePath.includes("quickLink.d2l")) {
16
- let link;
17
- try {
18
- link = new URL(filePath, "https://placeholder.invalid");
19
- }
20
- catch {
21
- return { error: "coursePath is not a valid quickLink URL." };
22
- }
23
- if (link.pathname !== QUICKLINK_PATH || link.searchParams.get("type") !== "coursefile") {
24
- return { error: "coursePath must be a course file path or a quickLink URL with type=coursefile." };
19
+ // A plain course-file path never starts with "/" or names a scheme, so
20
+ // anything that does is a link, however its file name reads.
21
+ if (filePath.startsWith("/") || /^[a-z][a-z\d+.-]*:\/\//i.test(filePath)) {
22
+ const brightspacePath = toBrightspacePath(filePath, origin);
23
+ const link = brightspacePath === null ? null : new URL(brightspacePath, "https://placeholder.invalid");
24
+ if (link === null || link.pathname !== QUICKLINK_PATH || link.searchParams.get("type") !== "coursefile") {
25
+ return { error: "coursePath must be a course file path or a quickLink URL on this Brightspace with type=coursefile." };
25
26
  }
26
27
  const ou = link.searchParams.get("ou");
27
28
  if (ou !== null && ou !== String(courseId)) {
@@ -45,10 +46,13 @@ export function parseCoursePath(input, courseId) {
45
46
  * HTML because the Markdown conversion escapes parentheses in file names.
46
47
  * Anything parseCoursePath would refuse, and links to other courses, are left out.
47
48
  */
48
- export function extractCourseFilePaths(html, courseId) {
49
+ export function extractCourseFilePaths(html, courseId, origin) {
49
50
  const found = [];
50
- for (const [, href] of html.matchAll(/href\s*=\s*["']([^"']*quickLink\.d2l[^"']*)["']/gi)) {
51
- const parsed = parseCoursePath(href.replace(/&/g, "&"), courseId);
51
+ for (const anchor of tagAttributes(html, "a")) {
52
+ const href = anchor.get("href");
53
+ if (!href?.includes("quickLink.d2l"))
54
+ continue;
55
+ const parsed = parseCoursePath(href, courseId, origin);
52
56
  if (typeof parsed === "string" && !found.includes(parsed))
53
57
  found.push(parsed);
54
58
  }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ /** Decode the character references Brightspace's HTML editor writes into attribute values. */
7
+ export function decodeEntities(value) {
8
+ return value
9
+ .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
10
+ .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
11
+ .replace(/"/g, '"')
12
+ .replace(/&lt;/g, "<")
13
+ .replace(/&gt;/g, ">")
14
+ .replace(/&amp;/g, "&");
15
+ }
16
+ /**
17
+ * The attributes of every `<tag>` element in `html`, names lowercased and
18
+ * values entity-decoded. A value ends only at its own closing quote, so an
19
+ * apostrophe inside a double-quoted href stays part of it.
20
+ */
21
+ export function tagAttributes(html, tag) {
22
+ return [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "gi"))].map(element => new Map([...element[0].matchAll(/([\w-]+)\s*=\s*(["'])([\s\S]*?)\2/g)].map(match => [
23
+ match[1].toLowerCase(),
24
+ decodeEntities(match[3]),
25
+ ])));
26
+ }
@@ -12,6 +12,7 @@
12
12
  * page; fetching it is the caller's job.
13
13
  */
14
14
  import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId } from "./platform.js";
15
+ import { tagAttributes } from "../html-attributes.js";
15
16
  /** A link into the user's own Brightspace, which only an authenticated request can follow. */
16
17
  export function isBrightspaceRelativeLink(url) {
17
18
  return url.startsWith("/d2l/");
@@ -50,21 +51,6 @@ export function toBrightspacePath(url, origin) {
50
51
  return null;
51
52
  return `${parsed.pathname}${parsed.search}`;
52
53
  }
53
- function decodeEntities(value) {
54
- return value
55
- .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
56
- .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
57
- .replace(/&quot;/g, '"')
58
- .replace(/&lt;/g, "<")
59
- .replace(/&gt;/g, ">")
60
- .replace(/&amp;/g, "&");
61
- }
62
- function tagAttributes(html, tag) {
63
- return [...html.matchAll(new RegExp(`<${tag}\\b[^>]*>`, "gi"))].map(element => new Map([...element[0].matchAll(/([\w-]+)\s*=\s*(["'])([\s\S]*?)\2/g)].map(match => [
64
- match[1].toLowerCase(),
65
- decodeEntities(match[3]),
66
- ])));
67
- }
68
54
  /** True when an adapter could fetch a transcript from `url`, or name the platform it can't yet. */
69
55
  function namesVideo(url) {
70
56
  switch (detectVideoPlatform(url)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.15.1",
3
+ "version": "3.15.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",