brightspace-mcp-server 3.14.0 → 3.15.1

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.
@@ -327,11 +327,20 @@ export class D2LApiClient {
327
327
  * read by a read-only tool is not worth an MFA prompt.
328
328
  */
329
329
  async getPage(path) {
330
+ const response = await this.getPageRaw(path);
331
+ return response ? await response.text() : null;
332
+ }
333
+ /**
334
+ * Like getPage, but returns the response itself so a binary body (a course
335
+ * file reached through a quickLink or /content/enforced path) is not decoded
336
+ * as text. Same cookie, same origin check, same null on a dead session.
337
+ */
338
+ async getPageRaw(path) {
330
339
  // The cookie belongs to this Brightspace alone. A path that would make
331
340
  // `${baseUrl}${path}` name another host (`@evil.example/`, `.evil.example/`,
332
341
  // `//evil.example/`) is refused before the cookie is ever read.
333
342
  if (!this.isSameOriginPath(path)) {
334
- log("WARN", "getPage refused a path outside the Brightspace origin");
343
+ log("WARN", "getPageRaw refused a path outside the Brightspace origin");
335
344
  return null;
336
345
  }
337
346
  const token = await this.tokenManager.getToken();
@@ -339,8 +348,7 @@ export class D2LApiClient {
339
348
  return null;
340
349
  const cookieToken = { ...token, accessToken: `cookie:${token.cookieHeader}` };
341
350
  try {
342
- const response = await this.retrying(() => this.throttled(() => this.makeRawRequest(path, cookieToken)));
343
- return await response.text();
351
+ return await this.retrying(() => this.throttled(() => this.makeRawRequest(path, cookieToken)));
344
352
  }
345
353
  catch (error) {
346
354
  if (error instanceof ApiError && error.status === 401)
@@ -15,6 +15,8 @@ import { checkTopicAvailability } from "./topic-availability.js";
15
15
  import { validateContentId, validateFileType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
16
16
  import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
17
17
  import { DownloadError } from "../utils/download-errors.js";
18
+ import { AUTH_COMMAND } from "../utils/commands.js";
19
+ import { parseCoursePath, QUICKLINK_PATH } from "../utils/course-files.js";
18
20
  import { extractPdfText } from "../utils/pdf-extractor.js";
19
21
  import { officeDocumentText } from "../utils/zip-extract.js";
20
22
  import fs from "node:fs/promises";
@@ -247,13 +249,16 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
247
249
  export function registerDownloadFile(server, apiClient) {
248
250
  server.registerTool("download_file", {
249
251
  title: "Download File",
250
- description: "Download a file from course content, instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
252
+ description: "Download a file from course content, files linked by path inside a module description (coursePath, from get_course_content's quickLink URLs with type=coursefile), instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
251
253
  inputSchema: DownloadFileSchema,
252
254
  }, async (args) => {
253
255
  try {
254
256
  log("DEBUG", "download_file tool called", { args });
255
257
  // Parse and validate input
256
- const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
258
+ const { courseId, topicId, coursePath, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
259
+ if (coursePath !== undefined && (topicId !== undefined || folderId !== undefined || newsId !== undefined || fileId !== undefined || source !== undefined)) {
260
+ return errorResponse("coursePath cannot be combined with topicId, folderId, newsId, fileId, or source.");
261
+ }
257
262
  if (source !== undefined && (topicId !== undefined || newsId !== undefined || folderId === undefined || fileId === undefined)) {
258
263
  return errorResponse("source requires folderId and fileId and cannot be combined with topicId or newsId.");
259
264
  }
@@ -282,7 +287,13 @@ export function registerDownloadFile(server, apiClient) {
282
287
  }
283
288
  }
284
289
  // Determine download source
285
- if (topicId !== undefined) {
290
+ if (coursePath !== undefined) {
291
+ const filePath = parseCoursePath(coursePath, courseId);
292
+ if (typeof filePath !== "string")
293
+ return errorResponse(filePath.error);
294
+ return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename);
295
+ }
296
+ else if (topicId !== undefined) {
286
297
  // Content file download
287
298
  validateContentId(topicId);
288
299
  return await downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename);
@@ -308,6 +319,22 @@ export function registerDownloadFile(server, apiClient) {
308
319
  }
309
320
  });
310
321
  }
322
+ /**
323
+ * Download a file from the course file store. Instructors often link these
324
+ * from a module or page description instead of adding them as content
325
+ * topics, so they have no topicId. Brightspace serves them to the signed-in
326
+ * web session, not to the API token, so this goes through the session cookie.
327
+ */
328
+ async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename) {
329
+ log("INFO", `Downloading course file: courseId=${courseId}, path=${filePath}`);
330
+ const response = await apiClient.getPageRaw(`${QUICKLINK_PATH}?ou=${courseId}&type=coursefile&fileId=${encodeURIComponent(filePath)}`);
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.`);
333
+ }
334
+ const disposition = response.headers.get("Content-Disposition") ?? "";
335
+ const filename = parseContentDispositionFilename(disposition) ?? filePath.split("/").pop() ?? "download";
336
+ return finishDownload(response, filename, downloadPath, customFilename, "Course file");
337
+ }
311
338
  /** Download an instructor-provided handout, independently of student submissions. */
312
339
  async function downloadAssignmentAttachment(apiClient, courseId, folderId, fileId, downloadPath, customFilename) {
313
340
  const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"));
@@ -37,6 +37,7 @@ export function registerGetCalendarEvents(server, apiClient, config) {
37
37
  // have gone out independently. Collect it rather than failing the
38
38
  // whole call and losing every course that *did* answer.
39
39
  const pendingCourseIds = [];
40
+ const failedCourseIds = [];
40
41
  let firstAuthError = null;
41
42
  const events = results
42
43
  .flatMap((result, i) => {
@@ -48,23 +49,37 @@ export function registerGetCalendarEvents(server, apiClient, config) {
48
49
  log("DEBUG", `get_calendar_events: sign-in pending for course ${courses[i].id}`, result.reason);
49
50
  return [];
50
51
  }
51
- log("DEBUG", `get_calendar_events: skipping course ${courses[i].id} after fetch failure`, result.reason);
52
+ // Anything else (a 404, a 5xx, a dropped connection) still must
53
+ // not make the course vanish: an exam missing from a list that
54
+ // looks complete reads as "no exam" (#262).
55
+ failedCourseIds.push(courses[i].id);
56
+ log("DEBUG", `get_calendar_events: calendar unavailable for course ${courses[i].id}`, result.reason);
52
57
  return [];
53
58
  })
54
59
  .filter((event) => includeGenerated || !event.generatedFrom)
55
60
  .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
56
61
  log("INFO", `get_calendar_events: Retrieved ${events.length} events across ${courses.length} courses` +
57
- (pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : ""));
62
+ (pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : "") +
63
+ (failedCourseIds.length > 0 ? ` (${failedCourseIds.length} failed)` : ""));
58
64
  // The response is always a bare array, pending sign-in or not — a
59
- // shape change would break every existing caller. A pending course
60
- // instead adds a second content block carrying the notice, which
61
- // names which course ids are unavailable.
62
- if (pendingCourseIds.length === 0) {
65
+ // shape change would break every existing caller. A pending or failed
66
+ // course instead adds a second content block carrying the notice,
67
+ // which names which course ids are unavailable.
68
+ const notices = [];
69
+ if (pendingCourseIds.length > 0) {
70
+ notices.push("Sign-in to Brightspace is still in progress, so calendar events for " +
71
+ `${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
72
+ `yet. ${authPendingNotice(firstAuthError)} Call get_calendar_events again once sign-in finishes.`);
73
+ }
74
+ if (failedCourseIds.length > 0) {
75
+ notices.push(`Calendar events for ${failedCourseIds.length} course(s) (${failedCourseIds.join(", ")}) ` +
76
+ "could not be read from Brightspace, so they are missing from this list. Do not report " +
77
+ "these courses as having no events: call get_calendar_events again with that courseId.");
78
+ }
79
+ if (notices.length === 0) {
63
80
  return toolResponse(events);
64
81
  }
65
- return toolResponseWithNotice(events, "Sign-in to Brightspace is still in progress, so calendar events for " +
66
- `${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
67
- `yet. ${authPendingNotice(firstAuthError)} Call get_calendar_events again once sign-in finishes.`);
82
+ return toolResponseWithNotice(events, notices.join(" "));
68
83
  }
69
84
  catch (error) {
70
85
  return sanitizeError(error);
@@ -9,6 +9,7 @@ import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
9
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
10
  import { matchesModifiedSince } from "../utils/modified-since.js";
11
11
  import { log } from "../utils/logger.js";
12
+ import { extractCourseFilePaths } from "../utils/course-files.js";
12
13
  import { contentAvailability } from "./content-availability.js";
13
14
  // Topic type mapping
14
15
  const TOPIC_TYPE_MAP = {
@@ -108,6 +109,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
108
109
  else if (currentDepth >= MAX_CONTENT_DEPTH) {
109
110
  log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
110
111
  }
112
+ const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId) : [];
111
113
  // Only include module if it has matching children (or filter is 'all')
112
114
  if (typeFilter === 'all' || processedChildren.length > 0) {
113
115
  tree.push({
@@ -117,6 +119,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
117
119
  description: item.Description?.Html?.trim()
118
120
  ? convertHtmlToMarkdown(item.Description.Html).markdown
119
121
  : item.Description?.Text ?? null,
122
+ ...(courseFiles.length > 0 ? { courseFiles } : {}),
120
123
  ...(item.ModuleDueDate ? { dueDate: item.ModuleDueDate } : {}),
121
124
  ...(item.IsHidden ? { isHidden: item.IsHidden } : {}),
122
125
  ...(item.IsLocked ? { isLocked: item.IsLocked } : {}),
@@ -69,6 +69,8 @@ export const DownloadFileSchema = z.object({
69
69
  .describe("Course ID the file belongs to."),
70
70
  topicId: z.coerce.number().int().positive().optional()
71
71
  .describe("Content topic ID to download (for course content files)."),
72
+ coursePath: z.string().min(1).max(1024).optional()
73
+ .describe("A file in the course's file store, for files an instructor linked inside a module or page description instead of adding as a content topic (get_course_content lists them as courseFiles on the module). Pass one of those paths (e.g. 'Notes/Lecture 1.pdf') or the whole quickLink URL. Not combined with topicId, folderId, or newsId."),
72
74
  folderId: z.coerce.number().int().positive().optional()
73
75
  .describe("Dropbox folder ID. Downloads your submission by default; set source to assignmentAttachment for an instructor-provided handout."),
74
76
  fileId: z.coerce.number().int().positive().optional()
@@ -0,0 +1,56 @@
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
+ export const QUICKLINK_PATH = "/d2l/common/dialogs/quickLink/quickLink.d2l";
7
+ /**
8
+ * Reduce a coursePath argument to a plain course-file path ("Notes/a.pdf").
9
+ * Accepts that path or the quickLink URL a module description carries, and
10
+ * refuses anything that could name a file outside the course file store, so
11
+ * the argument can only ever steer the request inside this course's files.
12
+ */
13
+ export function parseCoursePath(input, courseId) {
14
+ 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." };
25
+ }
26
+ const ou = link.searchParams.get("ou");
27
+ if (ou !== null && ou !== String(courseId)) {
28
+ return { error: `This quickLink belongs to course ${ou}, not course ${courseId}.` };
29
+ }
30
+ filePath = link.searchParams.get("fileId") ?? "";
31
+ }
32
+ const segments = filePath.split("/");
33
+ if (filePath === "" ||
34
+ filePath.startsWith("/") ||
35
+ filePath.includes("\\") ||
36
+ filePath.includes("://") ||
37
+ segments.some(segment => segment === ".." || segment === "." || segment === "")) {
38
+ return { error: "coursePath must be a relative path inside the course files (e.g. 'Notes/Lecture 1.pdf'), without '..' segments." };
39
+ }
40
+ return filePath;
41
+ }
42
+ /**
43
+ * The course file paths a module description links through
44
+ * `quickLink.d2l?type=coursefile`, in page order, once each. Read from the raw
45
+ * HTML because the Markdown conversion escapes parentheses in file names.
46
+ * Anything parseCoursePath would refuse, and links to other courses, are left out.
47
+ */
48
+ export function extractCourseFilePaths(html, courseId) {
49
+ const found = [];
50
+ for (const [, href] of html.matchAll(/href\s*=\s*["']([^"']*quickLink\.d2l[^"']*)["']/gi)) {
51
+ const parsed = parseCoursePath(href.replace(/&/g, "&"), courseId);
52
+ if (typeof parsed === "string" && !found.includes(parsed))
53
+ found.push(parsed);
54
+ }
55
+ return found;
56
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.14.0",
3
+ "version": "3.15.1",
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",