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.
package/build/api/client.js
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
60
|
-
// instead adds a second content block carrying the notice,
|
|
61
|
-
// names which course ids are unavailable.
|
|
62
|
-
|
|
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, "
|
|
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 } : {}),
|
package/build/tools/schemas.js
CHANGED
|
@@ -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.
|
|
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",
|