brightspace-mcp-server 3.4.0 → 3.5.0

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.
@@ -50,12 +50,22 @@ async function buildContentTree(apiClient, courseId, modules, progressMap, typeF
50
50
  // Module: fetch children recursively (unless the depth limit is reached)
51
51
  let processedChildren = [];
52
52
  if (currentDepth < depthLimit) {
53
- let children = [];
53
+ // The parent listing already embeds this module's immediate children
54
+ // in Structure. The dedicated endpoint is still asked first because it
55
+ // is the authoritative copy, but a module whose structure call fails —
56
+ // or answers with something that is not an array — must fall back to
57
+ // what the parent already handed us. Falling through to an empty list
58
+ // made a locked or erroring module look like an empty one, and under a
59
+ // typeFilter it dropped the module from the tree with no trace.
60
+ let children = null;
54
61
  try {
55
62
  children = await apiClient.get(apiClient.le(courseId, `/content/modules/${item.Id}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
56
63
  }
57
64
  catch (e) {
58
- log('DEBUG', `Failed to fetch children for module ${item.Id}: skipping`);
65
+ log('DEBUG', `Failed to fetch children for module ${item.Id}: falling back to the embedded structure`);
66
+ }
67
+ if (!Array.isArray(children)) {
68
+ children = Array.isArray(item.Structure) ? item.Structure : [];
59
69
  }
60
70
  processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1);
61
71
  }
@@ -4,6 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { fetchAllItems } from "../api/paginate.js";
7
8
  import { GetMyGradesSchema, } from "./schemas.js";
8
9
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
10
  import { log } from "../utils/logger.js";
@@ -42,11 +43,16 @@ export function registerGetMyGrades(server, apiClient, config) {
42
43
  return toolResponse({ courseId, grades });
43
44
  }
44
45
  // All courses case
45
- // First, fetch enrolled courses
46
- const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
47
- const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
46
+ // First, fetch enrolled courses. isActive=true has to track the
47
+ // configured policy rather than being pinned on: a user who set
48
+ // activeOnly:false is asking to see archived courses, and a query that
49
+ // withholds them leaves applyCourseFilter nothing to let through.
50
+ const enrollmentPath = apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`);
51
+ // Enrollments arrive one page at a time; follow the bookmark chain so a
52
+ // long enrollment history does not silently lose its later courses.
53
+ const enrollmentItems = await fetchAllItems(apiClient, enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
48
54
  // Apply course filter
49
- const filteredEnrollments = applyCourseFilter(enrollmentResponse.Items.map(item => ({
55
+ const filteredEnrollments = applyCourseFilter(enrollmentItems.map(item => ({
50
56
  id: item.OrgUnit.Id,
51
57
  name: item.OrgUnit.Name,
52
58
  code: item.OrgUnit.Code,
@@ -90,7 +96,7 @@ export function registerGetMyGrades(server, apiClient, config) {
90
96
  const courses = results
91
97
  .filter((r) => r.status === "fulfilled" && r.value !== null)
92
98
  .map((r) => r.value);
93
- log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses (out of ${enrollmentResponse.Items.length} enrolled)`);
99
+ log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses (out of ${enrollmentItems.length} enrolled)`);
94
100
  return toolResponse({ courses });
95
101
  }
96
102
  catch (error) {
@@ -4,6 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { fetchAllItems } from "../api/paginate.js";
7
8
  import { GetUpcomingDueDatesSchema, } from "./schemas.js";
8
9
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
10
  import { log } from "../utils/logger.js";
@@ -22,8 +23,13 @@ function unwrapList(raw) {
22
23
  async function resolveCourses(apiClient, config, courseId) {
23
24
  let items = [];
24
25
  try {
25
- const response = await apiClient.get(apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true"), { ttl: DEFAULT_CACHE_TTLS.enrollments });
26
- items = response.Items ?? [];
26
+ // isActive=true tracks the configured policy rather than being pinned on:
27
+ // a user who set activeOnly:false is asking to see archived courses, and a
28
+ // query that withholds them leaves applyCourseFilter nothing to let
29
+ // through. Enrollments are paged, so follow the bookmark chain — a long
30
+ // enrollment history would otherwise lose every course past the first page,
31
+ // and every deadline in those courses with it.
32
+ items = await fetchAllItems(apiClient, apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`), { ttl: DEFAULT_CACHE_TTLS.enrollments });
27
33
  }
28
34
  catch (error) {
29
35
  // Without enrollments there is no course list to walk, so only the explicit
@@ -48,14 +54,18 @@ async function resolveCourses(apiClient, config, courseId) {
48
54
  /**
49
55
  * Collect every graded, dated discussion topic for one course.
50
56
  *
51
- * Forums carry no due date themselves; it lives on each topic. A forum whose
52
- * topics fail to load (e.g. no access) is skipped rather than failing the
53
- * whole course, matching `getForumsOverview` in get-discussions.ts.
57
+ * Forums carry no due date themselves; it lives on each topic. A hidden forum
58
+ * hides everything inside it, however visible its topics claim to be, so it is
59
+ * skipped without asking for its topics at all. A forum whose topics fail to
60
+ * load (e.g. no access) is skipped rather than failing the whole course,
61
+ * matching `getForumsOverview` in get-discussions.ts.
54
62
  */
55
63
  async function fetchDiscussionDueTopics(apiClient, courseId) {
56
64
  const forums = await apiClient.get(apiClient.le(courseId, "/discussions/forums/"), { ttl: DEFAULT_CACHE_TTLS.assignments });
57
65
  const topics = [];
58
66
  for (const forum of unwrapList(forums)) {
67
+ if (forum.IsHidden === true)
68
+ continue;
59
69
  try {
60
70
  const forumTopics = await apiClient.get(apiClient.le(courseId, `/discussions/forums/${forum.ForumId}/topics/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
61
71
  topics.push(...unwrapList(forumTopics));
@@ -59,19 +59,20 @@ export function errorResponse(message) {
59
59
  const AUTH_FAILURE_GUIDANCE = {
60
60
  busy: "A sign-in is already running in another process. Let it finish, then try again.",
61
61
  cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
62
- `Run \`${AUTH_COMMAND}\` in a terminal to retry now and see the number to enter.`,
62
+ `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to retry now and see the number to enter.`,
63
63
  unsupported: "This login needs something your AI client cannot supply, usually a code from an "
64
64
  + "authenticator app. " +
65
- `Run \`${AUTH_COMMAND}\` in a terminal and sign in there.`,
65
+ `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) and sign in there.`,
66
66
  secureStorage: "The operating system credential store is locked or unavailable, so the saved " +
67
67
  "password could not be read. Unlock your keychain or keyring, then try again.",
68
68
  transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
69
69
  "Check your connection and try again in a few minutes.",
70
70
  timeout: "The sign-in did not finish in time, usually a missed MFA prompt. " +
71
- `Run \`${AUTH_COMMAND}\` in a terminal to complete it with the number visible.`,
72
- failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal to see why, ` +
71
+ `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
72
+ failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
73
73
  "or `brightspace-setup` if your saved school or username is wrong.",
74
- mfaPending: "A Microsoft Authenticator approval was not completed in time. Try again.",
74
+ mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo), " +
75
+ "then call this tool again — the sign-in is finishing in the background.",
75
76
  };
76
77
  /**
77
78
  * mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
@@ -82,7 +83,8 @@ const AUTH_FAILURE_GUIDANCE = {
82
83
  */
83
84
  function authFailureMessage(error) {
84
85
  if (error.kind === "mfaPending" && error.numberMatch) {
85
- return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes, then run this again.`;
86
+ return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes, ` +
87
+ "then call this tool again — the sign-in is finishing in the background.";
86
88
  }
87
89
  return AUTH_FAILURE_GUIDANCE[error.kind];
88
90
  }
package/build/update.js CHANGED
@@ -7,8 +7,8 @@
7
7
  * https://github.com/rohanmuppa/brightspace-mcp-server
8
8
  */
9
9
  import { execSync } from "node:child_process";
10
- import { readFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
10
+ import { readFileSync, realpathSync } from "node:fs";
11
+ import { dirname, join, resolve } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import dotenv from "dotenv";
14
14
  dotenv.config({ quiet: true });
@@ -36,18 +36,46 @@ function getVersion() {
36
36
  const pkg = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf-8"));
37
37
  return pkg.version || "unknown";
38
38
  }
39
- function main() {
39
+ /** Compare two directories as the filesystem sees them. */
40
+ export function samePath(a, b) {
41
+ const canonical = (p) => {
42
+ let full = resolve(p);
43
+ try {
44
+ full = realpathSync(full);
45
+ }
46
+ catch {
47
+ // A path that cannot be resolved is compared as written.
48
+ }
49
+ return process.platform === "win32" ? full.toLowerCase() : full;
50
+ };
51
+ return canonical(a) === canonical(b);
52
+ }
53
+ export function main() {
40
54
  console.log("");
41
55
  console.log(bold("=== Brightspace MCP Server — Update ==="));
42
56
  console.log("");
43
57
  // Check if we're in a git repo
58
+ let toplevel;
44
59
  try {
45
- run("git rev-parse --is-inside-work-tree", { silent: true });
60
+ toplevel = run("git rev-parse --show-toplevel", { silent: true });
46
61
  }
47
62
  catch {
48
63
  console.error(red("Error: Not a git repository. Cannot update."));
49
64
  console.error("Make sure you cloned this project with git.");
50
65
  process.exit(1);
66
+ return;
67
+ }
68
+ // Git answers for the nearest enclosing repository, which for an installed
69
+ // copy under node_modules is the *user's own* project. Fetching and pulling
70
+ // origin/main there would rewrite a repository that has nothing to do with
71
+ // this package, so only a checkout that is itself the repository root is
72
+ // updated.
73
+ if (!samePath(toplevel, projectRoot)) {
74
+ console.error(red("Error: This copy is not a git checkout of brightspace-mcp-server."));
75
+ console.error(` It lives in ${projectRoot}, inside the repository at ${toplevel}.`);
76
+ console.error(" Update an installed copy with npm instead; this command is for a git clone.");
77
+ process.exit(1);
78
+ return;
51
79
  }
52
80
  // Show current version
53
81
  const currentVersion = getVersion();
@@ -126,4 +154,9 @@ function main() {
126
154
  console.log(" Restart your MCP client to use the latest version.");
127
155
  console.log("");
128
156
  }
129
- main();
157
+ // `npm run update` runs this file directly. VITEST is set only by the test
158
+ // runner, which imports the module to exercise main() against a stubbed git;
159
+ // no user environment sets it.
160
+ if (!process.env.VITEST) {
161
+ main();
162
+ }
@@ -9,12 +9,15 @@ import * as fsSync from "node:fs";
9
9
  /**
10
10
  * Write a file so that a reader never sees a half-written one.
11
11
  *
12
- * The content is staged to a sibling temp file and then renamed over the
13
- * target. Rename is atomic on the same filesystem, so a crash, a signal, or
12
+ * The content is staged to a sibling temp file, flushed, and then renamed over
13
+ * the target. Rename is atomic on the same filesystem, so a crash, a signal, or
14
14
  * a second writer mid-way leaves either the old file or the new one, never a
15
15
  * truncated mix. The session store and the config store both hold secrets
16
16
  * and are both written by more than one process, which is why they use this.
17
17
  *
18
+ * A staging write that fails takes its temp file with it: the names are random,
19
+ * so an orphan is never reused and would sit next to the secret it half-wrote.
20
+ *
18
21
  * On Windows a rename can fail transiently while antivirus or an indexer
19
22
  * holds the target open, so those errors are retried a few times.
20
23
  */
@@ -31,12 +34,22 @@ function isTransient(error) {
31
34
  export async function writeFileAtomic(target, data, options = {}) {
32
35
  const { mode, renameImpl = (from, to) => fs.rename(from, to), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), } = options;
33
36
  const tmp = tempPathFor(target);
34
- await fs.writeFile(tmp, data, mode === undefined ? {} : { mode });
35
- // writeFile's mode is subject to the umask; chmod is not.
36
- if (mode !== undefined && process.platform !== "win32") {
37
- await fs.chmod(tmp, mode);
38
- }
39
37
  try {
38
+ const handle = await fs.open(tmp, "wx", mode);
39
+ try {
40
+ await handle.writeFile(data);
41
+ // open's mode is subject to the umask; chmod is not.
42
+ if (mode !== undefined && process.platform !== "win32") {
43
+ await handle.chmod(mode);
44
+ }
45
+ // Flush before the rename. A rename that reaches disk ahead of the bytes
46
+ // it points at leaves a truncated file, which is the one outcome this
47
+ // module exists to rule out.
48
+ await handle.sync();
49
+ }
50
+ finally {
51
+ await handle.close();
52
+ }
40
53
  for (let attempt = 1;; attempt++) {
41
54
  try {
42
55
  await renameImpl(tmp, target);
@@ -58,11 +71,18 @@ export async function writeFileAtomic(target, data, options = {}) {
58
71
  export function writeFileAtomicSync(target, data, options = {}) {
59
72
  const { mode } = options;
60
73
  const tmp = tempPathFor(target);
61
- fsSync.writeFileSync(tmp, data, mode === undefined ? {} : { mode });
62
- if (mode !== undefined && process.platform !== "win32") {
63
- fsSync.chmodSync(tmp, mode);
64
- }
65
74
  try {
75
+ const fd = fsSync.openSync(tmp, "wx", mode);
76
+ try {
77
+ fsSync.writeFileSync(fd, data);
78
+ if (mode !== undefined && process.platform !== "win32") {
79
+ fsSync.fchmodSync(fd, mode);
80
+ }
81
+ fsSync.fsyncSync(fd);
82
+ }
83
+ finally {
84
+ fsSync.closeSync(fd);
85
+ }
66
86
  for (let attempt = 1;; attempt++) {
67
87
  try {
68
88
  fsSync.renameSync(tmp, target);
@@ -77,7 +97,13 @@ export function writeFileAtomicSync(target, data, options = {}) {
77
97
  }
78
98
  }
79
99
  catch (error) {
80
- fsSync.rmSync(tmp, { force: true });
100
+ // Cleanup must not replace the failure the caller needs to see.
101
+ try {
102
+ fsSync.rmSync(tmp, { force: true });
103
+ }
104
+ catch {
105
+ // The temp file outliving a failed write is the lesser problem.
106
+ }
81
107
  throw error;
82
108
  }
83
109
  }
@@ -6,8 +6,16 @@
6
6
  /**
7
7
  * Deep links into the Brightspace web UI.
8
8
  *
9
- * The templates were harvested from live Brightspace markup: they are the same
10
- * URLs the course pages themselves link to, so they open the item directly.
9
+ * The dropbox, quiz and gradebook templates were harvested from live
10
+ * Brightspace markup: they are the same URLs the course pages themselves link
11
+ * to, so they open the item directly.
12
+ *
13
+ * `discussionUrl` was NOT -- it was written from the shape of the others
14
+ * without live markup to check it against, and no one has confirmed it against
15
+ * a real tenant since. Treat it as unverified: if a student reports that a
16
+ * discussion link lands on the wrong page or a "not found", harvest the real
17
+ * URL from a course's own discussion list and fix the template here rather
18
+ * than assuming this one is right.
11
19
  */
12
20
  /** Drop trailing slashes so the templates below join cleanly. */
13
21
  function trimBaseUrl(baseUrl) {
@@ -28,7 +36,13 @@ export function quizUrl(baseUrl, courseId, quizId) {
28
36
  export function gradebookUrl(baseUrl, courseId) {
29
37
  return `${trimBaseUrl(baseUrl)}/d2l/lms/grades/my_grades/main.d2l?ou=${courseId}`;
30
38
  }
31
- /** Link to the thread list of a discussion topic. */
39
+ /**
40
+ * Link to the thread list of a discussion topic.
41
+ *
42
+ * Unverified against a live tenant -- see the note at the top of this file.
43
+ * The topic's forum id is available at every call site, so if the real
44
+ * template turns out to need one, it can be threaded through.
45
+ */
32
46
  export function discussionUrl(baseUrl, courseId, topicId) {
33
47
  return `${trimBaseUrl(baseUrl)}/d2l/lms/discussions/threadlist.d2l?ou=${courseId}&tId=${topicId}`;
34
48
  }
@@ -71,15 +71,21 @@ export async function secureDownload(options) {
71
71
  throw new Error(`File size (${size} bytes) exceeds maximum allowed (${MAX_FILE_SIZE} bytes)`);
72
72
  }
73
73
  log("DEBUG", `secureDownload: file size ${size} bytes (within limit)`);
74
+ // Validate download path (prevent path traversal) and keep the name it
75
+ // sanitized. validateDownloadPath used to be called for its throw alone while
76
+ // the write below still used the raw filename, so a Brightspace-supplied
77
+ // "../../name.pdf" resolved cleanly through validation and was then written
78
+ // two directories above targetDir. Everything past this point — type
79
+ // detection included — uses the name the file actually gets on disk.
80
+ const validatedPath = validateDownloadPath(targetDir, filename);
81
+ const safeFilename = path.basename(validatedPath);
82
+ log("DEBUG", `secureDownload: path validated as ${validatedPath}`);
74
83
  // Validate file type via magic bytes
75
84
  // The filename decides which legacy Office format a CFB container is.
76
- const { mime } = await validateFileType(data, allowedTypes, filename);
85
+ const { mime } = await validateFileType(data, allowedTypes, safeFilename);
77
86
  log("DEBUG", `secureDownload: file type validated as ${mime}`);
78
- // Validate download path (prevent path traversal)
79
- const validatedPath = validateDownloadPath(targetDir, filename);
80
- log("DEBUG", `secureDownload: path validated as ${validatedPath}`);
81
87
  // Resolve filename conflicts
82
- const resolvedFilename = await resolveFilenameConflict(targetDir, filename);
88
+ const resolvedFilename = await resolveFilenameConflict(targetDir, safeFilename);
83
89
  const finalPath = path.join(targetDir, resolvedFilename);
84
90
  log("DEBUG", `secureDownload: resolved filename to ${resolvedFilename}`);
85
91
  // Write file to disk
@@ -38,6 +38,20 @@ const CFB_EXTENSION_MIMES = {
38
38
  ".pot": "application/vnd.ms-powerpoint",
39
39
  ".pps": "application/vnd.ms-powerpoint",
40
40
  };
41
+ /**
42
+ * The same dead-allowlist-entry problem as CFB, one format over.
43
+ *
44
+ * file-type has no SVG detector: an SVG carrying the usual `<?xml ...?>`
45
+ * prolog is reported as application/xml, which is not in the allowlist, so
46
+ * every ordinary .svg download failed and the image/svg+xml entry below was
47
+ * unreachable. Reconcile against the declared extension exactly as CFB does --
48
+ * that admits only the format the allowlist already intended and still refuses
49
+ * every other flavour of XML.
50
+ */
51
+ const XML_MIME = "application/xml";
52
+ const XML_EXTENSION_MIMES = {
53
+ ".svg": "image/svg+xml",
54
+ };
41
55
  /**
42
56
  * Maximum file size for downloads (50 MB).
43
57
  * Prevents memory exhaustion from malicious large file requests.
@@ -120,10 +134,26 @@ export function validateDownloadPath(baseDir, filename) {
120
134
  * @throws Error if file type not allowed
121
135
  */
122
136
  export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES, filename) {
137
+ // An empty body is not a text file. It reaches here when a fetch was
138
+ // truncated or the server answered with nothing, and the UTF-8 fallback
139
+ // below would otherwise wave it through as text/plain and write a zero-byte
140
+ // file to disk under whatever name the download was given.
141
+ if (buffer.length === 0) {
142
+ throw new DownloadError("undetectableType", "File is empty (0 bytes)");
143
+ }
123
144
  // Try magic byte detection first
124
145
  const fileTypeFromBuffer = await getFileTypeFromBuffer();
125
146
  const detected = await fileTypeFromBuffer(buffer);
126
147
  if (detected) {
148
+ if (detected.mime === XML_MIME) {
149
+ const ext = filename ? path.extname(filename).toLowerCase() : "";
150
+ const resolved = XML_EXTENSION_MIMES[ext];
151
+ if (resolved && allowedTypes.includes(resolved)) {
152
+ return { mime: resolved, ext: ext.slice(1) };
153
+ }
154
+ // Anything else falls through to the allowlist check, which refuses
155
+ // application/xml the way it always has.
156
+ }
127
157
  if (detected.mime === CFB_MIME) {
128
158
  const ext = filename ? path.extname(filename).toLowerCase() : "";
129
159
  const resolved = CFB_EXTENSION_MIMES[ext];
@@ -150,9 +180,19 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
150
180
  if (decoded !== null) {
151
181
  const noBom = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded;
152
182
  const head = noBom.trimStart().toLowerCase();
153
- const isHtml = head.startsWith("<!doctype html") || head.startsWith("<html");
154
- const mime = isHtml ? "text/html" : "text/plain";
155
- const ext = isHtml ? "html" : "txt";
183
+ let mime = "text/plain";
184
+ let ext = "txt";
185
+ if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
186
+ mime = "text/html";
187
+ ext = "html";
188
+ }
189
+ else if (head.startsWith("<svg") || head.startsWith("<!doctype svg")) {
190
+ // An SVG without the XML prolog reaches the fallback instead of being
191
+ // detected. Naming it text/plain told the caller the wrong type for a
192
+ // file the allowlist has an entry for.
193
+ mime = "image/svg+xml";
194
+ ext = "svg";
195
+ }
156
196
  if (allowedTypes.includes(mime)) {
157
197
  return { mime, ext };
158
198
  }
@@ -178,15 +218,41 @@ export function validateContentId(id) {
178
218
  return id;
179
219
  }
180
220
  /**
181
- * Validate URL starts with expected D2L base URL.
221
+ * Validate a URL belongs to the expected D2L origin.
182
222
  * Prevents SSRF attacks via user-controlled URLs.
183
223
  *
224
+ * A plain string prefix test is not enough: "https://purdue.brightspace.com"
225
+ * is a prefix of "https://purdue.brightspace.com.attacker.example/steal", so
226
+ * an attacker registering a hostname that starts with the school's own passes
227
+ * it. Compare parsed origins instead, and require any expected path prefix to
228
+ * end on a "/" so /d2lXXX cannot satisfy a prefix of /d2l.
229
+ *
184
230
  * @param url - URL to validate
185
231
  * @param expectedBaseUrl - Expected D2L base URL (e.g., "https://purdue.brightspace.com")
186
232
  * @throws Error if URL doesn't match expected base
187
233
  */
188
234
  export function validateBaseUrl(url, expectedBaseUrl) {
189
- if (!url.startsWith(expectedBaseUrl)) {
235
+ const reject = () => {
190
236
  throw new Error(`URL must start with ${expectedBaseUrl}, got: ${url.substring(0, 50)}...`);
237
+ };
238
+ let target;
239
+ let expected;
240
+ try {
241
+ target = new URL(url);
242
+ expected = new URL(expectedBaseUrl);
243
+ }
244
+ catch {
245
+ return reject();
246
+ }
247
+ // Opaque origins serialize to "null", so two unrelated file: or data: URLs
248
+ // would compare equal. Only a real, comparable origin counts.
249
+ if (target.origin === "null" || target.origin !== expected.origin) {
250
+ return reject();
251
+ }
252
+ const basePath = expected.pathname.replace(/\/+$/, "");
253
+ if (basePath &&
254
+ target.pathname !== basePath &&
255
+ !target.pathname.startsWith(`${basePath}/`)) {
256
+ return reject();
191
257
  }
192
258
  }
@@ -15,9 +15,75 @@ function getTurndownService() {
15
15
  headingStyle: "atx",
16
16
  codeBlockStyle: "fenced",
17
17
  });
18
+ // Turndown has no rule for <script>, <style> or <noscript>, so it falls
19
+ // back to emitting their text: a D2L description carrying an inline
20
+ // stylesheet or an analytics snippet put raw CSS and JavaScript source
21
+ // into the markdown the model reads. None of it is content.
22
+ turndownService.remove(["script", "style", "noscript"]);
23
+ // Turndown has no table rules either, and its fallback treats every cell
24
+ // as a standalone block -- a syllabus grading table came out as a flat
25
+ // run of paragraphs ("Homework", "30%", "Exams", "70%") with nothing
26
+ // left to say which weight belonged to which item. Emit GFM pipe tables
27
+ // so the pairing survives into the markdown a model is asked to read.
28
+ // Rules are registered once here (not at module scope) because the
29
+ // service itself is now lazily constructed on first use.
30
+ turndownService.addRule("tableCell", {
31
+ filter: ["th", "td"],
32
+ replacement: (content) => ` ${cellText(content)} |`,
33
+ });
34
+ turndownService.addRule("tableRow", {
35
+ filter: "tr",
36
+ replacement: (content, node) => {
37
+ const row = `|${content}`;
38
+ const table = closestTable(node);
39
+ const rows = table
40
+ ? Array.from(table.querySelectorAll("tr")).filter((candidate) => closestTable(candidate) === table)
41
+ : [];
42
+ // GFM needs a delimiter line after the first row, header row or not:
43
+ // without one the whole block renders as a paragraph and the
44
+ // columns are lost again.
45
+ if (rows.length > 0 && rows[0] === node) {
46
+ const columns = cellsOf(node).length || 1;
47
+ return `\n${row}\n|${" --- |".repeat(columns)}`;
48
+ }
49
+ return `\n${row}`;
50
+ },
51
+ });
52
+ turndownService.addRule("tableSection", {
53
+ filter: ["thead", "tbody", "tfoot"],
54
+ replacement: (content) => content,
55
+ });
56
+ turndownService.addRule("table", {
57
+ filter: "table",
58
+ replacement: (content) => {
59
+ const body = content.trim();
60
+ return body ? `\n\n${body}\n\n` : "";
61
+ },
62
+ });
18
63
  }
19
64
  return turndownService;
20
65
  }
66
+ /** A cell's text, flattened so one table cell stays one table cell. */
67
+ function cellText(content) {
68
+ return content
69
+ .replace(/\|/g, "\\|")
70
+ .replace(/\s*\n+\s*/g, " ")
71
+ .trim();
72
+ }
73
+ /** The nearest enclosing <table>, so a nested table is scoped to itself. */
74
+ function closestTable(node) {
75
+ let current = node.parentNode;
76
+ while (current) {
77
+ if (current.nodeName === "TABLE")
78
+ return current;
79
+ current = current.parentNode;
80
+ }
81
+ return null;
82
+ }
83
+ /** Direct <th>/<td> children of a row. */
84
+ function cellsOf(row) {
85
+ return Array.from(row.childNodes).filter((child) => child.nodeName === "TH" || child.nodeName === "TD");
86
+ }
21
87
  /**
22
88
  * Convert D2L HTML content to clean markdown.
23
89
  * Returns both markdown (for LLM readability) and raw HTML (for fallback).
@@ -19,16 +19,32 @@ export function setLogLevel(level) {
19
19
  *
20
20
  * Deliberately no blanket uppercase or base32 rule: it would eat course codes
21
21
  * like ECE264, which are exactly what a useful log line contains.
22
+ *
23
+ * Order matters: the JSON-shaped rules run before the free-text ones, so a
24
+ * serialized object is redacted by key and the looser rules never see the
25
+ * value they would otherwise re-match across the quoting.
22
26
  */
23
27
  function redact(value) {
24
28
  // JSON web tokens, wherever they appear
25
29
  value = value.replace(/eyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]*)?/g, "eyJ...REDACTED");
26
30
  // Redact Bearer tokens
27
31
  value = value.replace(/Bearer\s+([A-Za-z0-9._~+/=-]{8})[A-Za-z0-9._~+/=-]*/g, "Bearer $1...REDACTED");
28
- // Redact cookie: prefixed tokens
29
- value = value.replace(/cookie:([^\s]{8})[^\s]*/g, "cookie:$1...REDACTED");
30
- // JSON-serialized header fields: {"Authorization":"..."}, {"Cookie":"..."}
32
+ // JSON-serialized header fields: {"Authorization":"..."}, {"Cookie":"..."}.
33
+ // Runs before the raw-header rule below so a serialized object is redacted
34
+ // as JSON and the raw-header rule cannot then chew through the quoting.
31
35
  value = value.replace(/("(?:authorization|cookie|set-cookie)"\s*:\s*")[^"]*(")/gi, "$1...REDACTED$2");
36
+ // JSON-serialized secret fields: {"password":"..."}, {"accessToken":"..."},
37
+ // {"refresh_token":"..."}, {"xsrfToken":"..."}. Only string values match, so
38
+ // a numeric field like "tokenExpiry": 1750000000 is left readable.
39
+ value = value.replace(/("[^"]*(?:password|passwd|secret|token|credential|api[_-]?key)[^"]*"\s*:\s*")[^"]*(")/gi, "$1...REDACTED$2");
40
+ // Raw header lines: "Cookie: d2lSessionVal=..." / "Set-Cookie: ...".
41
+ // The whole remainder of the line goes, because a cookie header carries
42
+ // every cookie for the host, not just the first pair. Case-insensitive and
43
+ // tolerant of the space HTTP actually puts after the colon -- without both,
44
+ // this rule only ever fired on a form no real header takes.
45
+ value = value.replace(/(^|[\s{[(,;])((?:set-)?cookie)\s*:[ \t]*([^\r\n]+)/gi, (_match, prefix, name, secret) => `${prefix}${name}: ${secret.slice(0, 8)}...REDACTED`);
46
+ // password=... / secret=... in a query string, form body, or free text.
47
+ value = value.replace(/\b(password|passwd|secret|api[_-]?key|access[_-]?token|refresh[_-]?token)\b(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s&;,"']+)/gi, (_match, key, separator) => `${key}${separator}...REDACTED`);
32
48
  // Credentials embedded in a URL: scheme://user:pass@host
33
49
  value = value.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi, "$1***:***@");
34
50
  // Redact anything that looks like a long token (40+ chars of base64-like)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
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",