brightspace-mcp-server 5.1.44 → 5.2.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.
- package/README.md +90 -89
- package/build/api/client.js +32 -13
- package/build/index.js +6 -2
- package/build/runtime-relay.js +24 -1
- package/build/server.js +3 -2
- package/build/sync/cleanup.js +61 -0
- package/build/sync/course-sync.js +649 -0
- package/build/sync/errors.js +8 -0
- package/build/sync/lock.js +123 -0
- package/build/sync/manifest.js +106 -0
- package/build/sync/names.js +158 -0
- package/build/sync/summary.js +70 -0
- package/build/sync/sync-cli.js +190 -0
- package/build/tools/download-course-files.js +86 -0
- package/build/tools/download-dropbox-submission-file.js +7 -1
- package/build/tools/download-file.js +24 -9
- package/build/tools/get-course-content.js +6 -0
- package/build/tools/get-syllabus.js +5 -0
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +18 -0
- package/build/tools/tool-helpers.js +5 -0
- package/build/utils/download-helpers.js +67 -4
- package/build/utils/sensitive-paths.js +237 -0
- package/package.json +1 -1
|
@@ -0,0 +1,86 @@
|
|
|
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
|
+
import { DownloadCourseFilesSchema } from "./schemas.js";
|
|
7
|
+
import { errorResponse, sanitizeError, withUpdateNotice } from "./tool-helpers.js";
|
|
8
|
+
import { log } from "../utils/logger.js";
|
|
9
|
+
import { validateContentId } from "../utils/file-validator.js";
|
|
10
|
+
import { SyncInputError, syncCourse } from "../sync/course-sync.js";
|
|
11
|
+
import { summarizeSync } from "../sync/summary.js";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
/**
|
|
15
|
+
* Register download_course_files tool
|
|
16
|
+
*/
|
|
17
|
+
export function registerDownloadCourseFiles(server, apiClient) {
|
|
18
|
+
server.registerTool("download_course_files", {
|
|
19
|
+
title: "Download Course Files",
|
|
20
|
+
description: "Download every file in a course's Content area to a folder on the host computer, in sub-folders named after the course's modules. Use this when the user wants a whole course (or one module) saved, instead of calling download_file once per file. Ask the user where to save it before calling; never guess a directory. It is safe to run again: files already on disk are skipped by default, so a rerun fetches only what is new and never overwrites the user's annotated copies. Use dryRun first to preview a large course. The answer counts what was downloaded, skipped, failed and not attempted, and what was not a file (web links, embedded videos, broken items, locked items), so do not report those as missing. If the Brightspace session expires mid-run it stops with sign-in instructions and the files already saved are kept; sign in and call it again.",
|
|
21
|
+
inputSchema: DownloadCourseFilesSchema,
|
|
22
|
+
}, async (args, extra) => {
|
|
23
|
+
try {
|
|
24
|
+
log("DEBUG", "download_course_files tool called", { args });
|
|
25
|
+
const { courseId, downloadPath, existing, moduleId, dryRun, maxFiles, maxMinutes, maxTotalMB } = DownloadCourseFilesSchema.parse(args);
|
|
26
|
+
validateContentId(courseId);
|
|
27
|
+
// Same rules as download_file's disk mode: an absolute path to a directory that exists.
|
|
28
|
+
if (!path.isAbsolute(downloadPath)) {
|
|
29
|
+
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)", "invalidInput");
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
if (!(await fs.stat(downloadPath)).isDirectory()) {
|
|
33
|
+
return errorResponse(`Download path is not a directory: ${downloadPath}`, "destination");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error?.code === "ENOENT") {
|
|
38
|
+
return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
|
|
39
|
+
}
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
let result;
|
|
43
|
+
try {
|
|
44
|
+
// A client that sent a progress token sees each file as it is handled, and a
|
|
45
|
+
// client that cancels the call (or a deadline) ends the run cleanly.
|
|
46
|
+
const progressToken = extra?._meta?.progressToken;
|
|
47
|
+
result = await syncCourse(apiClient, {
|
|
48
|
+
courseId,
|
|
49
|
+
destination: downloadPath,
|
|
50
|
+
existing,
|
|
51
|
+
moduleId,
|
|
52
|
+
dryRun,
|
|
53
|
+
maxFiles,
|
|
54
|
+
maxTotalBytes: maxTotalMB * 1024 * 1024,
|
|
55
|
+
deadlineMs: maxMinutes * 60_000,
|
|
56
|
+
signal: extra?.signal,
|
|
57
|
+
onProgress: progressToken === undefined || typeof extra?.sendNotification !== "function"
|
|
58
|
+
? undefined
|
|
59
|
+
: (event) => {
|
|
60
|
+
void extra.sendNotification({
|
|
61
|
+
method: "notifications/progress",
|
|
62
|
+
params: {
|
|
63
|
+
progressToken,
|
|
64
|
+
progress: event.done,
|
|
65
|
+
total: event.total,
|
|
66
|
+
message: `${event.type} ${JSON.stringify(event.path)}`,
|
|
67
|
+
},
|
|
68
|
+
}).catch(() => { });
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof SyncInputError)
|
|
74
|
+
return errorResponse(error.message, "invalidInput");
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
const response = withUpdateNotice([{ type: "text", text: summarizeSync(result) }]);
|
|
78
|
+
if (result.authError)
|
|
79
|
+
response.isError = true;
|
|
80
|
+
return response;
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
return sanitizeError(error);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
import { ApiError } from "../api/index.js";
|
|
7
7
|
import { DownloadDropboxSubmissionFileSchema } from "./schemas.js";
|
|
8
8
|
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
9
|
+
import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
10
11
|
import { validateContentId, DISK_MAX_FILE_SIZE } from "../utils/file-validator.js";
|
|
11
|
-
import { secureStreamDownload } from "../utils/download-helpers.js";
|
|
12
|
+
import { secureStreamDownload, expectedLength } from "../utils/download-helpers.js";
|
|
12
13
|
import { DownloadError } from "../utils/download-errors.js";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
import fs from "node:fs/promises";
|
|
@@ -53,6 +54,10 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
53
54
|
}
|
|
54
55
|
throw error;
|
|
55
56
|
}
|
|
57
|
+
const protectedReason = await sensitiveDestination(downloadPath);
|
|
58
|
+
if (protectedReason) {
|
|
59
|
+
return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
|
|
60
|
+
}
|
|
56
61
|
// Fetch submission metadata to get the file list and validate fileId.
|
|
57
62
|
const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/`);
|
|
58
63
|
let targetFile = null;
|
|
@@ -119,6 +124,7 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
119
124
|
originalFilename,
|
|
120
125
|
body: response.body,
|
|
121
126
|
maxBytes: DISK_MAX_FILE_SIZE,
|
|
127
|
+
expectedBytes: expectedLength(response),
|
|
122
128
|
});
|
|
123
129
|
log("INFO", `download_dropbox_submission_file: saved ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
124
130
|
return toolResponse({
|
|
@@ -13,8 +13,9 @@ import { checkTopicAvailability } from "./topic-availability.js";
|
|
|
13
13
|
// validated anything itself. Inline mode never writes to disk, so it calls
|
|
14
14
|
// validateFileType directly to enforce the same magic-byte allowlist.
|
|
15
15
|
import { validateContentId, validateFileType, nameForType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
|
|
16
|
-
import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
|
|
16
|
+
import { secureStreamDownload, readBodyCapped, expectedLength } from "../utils/download-helpers.js";
|
|
17
17
|
import { DownloadError } from "../utils/download-errors.js";
|
|
18
|
+
import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
|
|
18
19
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
19
20
|
import { parseCoursePath, QUICKLINK_PATH } from "../utils/course-files.js";
|
|
20
21
|
import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
@@ -232,6 +233,7 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
|
|
|
232
233
|
originalFilename,
|
|
233
234
|
body: response.body,
|
|
234
235
|
maxBytes: DISK_MAX_FILE_SIZE,
|
|
236
|
+
expectedBytes: expectedLength(response),
|
|
235
237
|
});
|
|
236
238
|
log("INFO", `${sourceLabel} downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
|
|
237
239
|
return toolResponse({
|
|
@@ -314,6 +316,10 @@ cookieRejectedMessage) {
|
|
|
314
316
|
}
|
|
315
317
|
throw error;
|
|
316
318
|
}
|
|
319
|
+
const protectedReason = await sensitiveDestination(downloadPath);
|
|
320
|
+
if (protectedReason) {
|
|
321
|
+
return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
|
|
322
|
+
}
|
|
317
323
|
}
|
|
318
324
|
// Determine download source
|
|
319
325
|
if (coursePath !== undefined) {
|
|
@@ -416,26 +422,35 @@ export function parseContentDispositionFilename(disposition) {
|
|
|
416
422
|
return null;
|
|
417
423
|
}
|
|
418
424
|
/**
|
|
419
|
-
*
|
|
425
|
+
* Request a content topic's file. Returns the response, or why the topic is
|
|
426
|
+
* not available: unreleased files can answer 403 or 404, and a release
|
|
427
|
+
* restriction is only explained when the metadata supports it, so every other
|
|
428
|
+
* failure is thrown intact. Shared with the course sync.
|
|
420
429
|
*/
|
|
421
|
-
async function
|
|
422
|
-
log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
|
|
430
|
+
export async function openContentTopic(apiClient, courseId, topicId, signal) {
|
|
423
431
|
// Build download URL using D2L API path helper
|
|
424
432
|
const apiPath = apiClient.le(courseId, `/content/topics/${topicId}/file`);
|
|
425
|
-
// Unreleased files can return 404 as well as 403. Only explain a release
|
|
426
|
-
// restriction when the metadata supports it; keep other failures intact.
|
|
427
|
-
let response;
|
|
428
433
|
try {
|
|
429
|
-
response
|
|
434
|
+
return { response: await apiClient.getRaw(apiPath, signal ? { signal } : undefined) };
|
|
430
435
|
}
|
|
431
436
|
catch (error) {
|
|
432
437
|
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
|
|
433
438
|
const unavailable = await checkTopicAvailability(apiClient, courseId, topicId, error.status);
|
|
434
439
|
if (unavailable)
|
|
435
|
-
return
|
|
440
|
+
return { unavailable };
|
|
436
441
|
}
|
|
437
442
|
throw error;
|
|
438
443
|
}
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Download a content file using topicId
|
|
447
|
+
*/
|
|
448
|
+
async function downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename) {
|
|
449
|
+
log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
|
|
450
|
+
const opened = await openContentTopic(apiClient, courseId, topicId);
|
|
451
|
+
if ("unavailable" in opened)
|
|
452
|
+
return toolResponse(opened.unavailable);
|
|
453
|
+
const { response } = opened;
|
|
439
454
|
// Get filename from Content-Disposition header
|
|
440
455
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
441
456
|
const filename = parseContentDispositionFilename(disposition) ?? "download";
|
|
@@ -194,6 +194,12 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
|
|
|
194
194
|
// Link topic — include URL
|
|
195
195
|
topic.url = item.Url ?? null;
|
|
196
196
|
}
|
|
197
|
+
// An embedded video (D2L's ContentService type) has no file behind it.
|
|
198
|
+
// Said outright so it is not taken for a file that is missing.
|
|
199
|
+
if (typeof item.TypeIdentifier === 'string' && item.TypeIdentifier.toLowerCase() === 'contentservice') {
|
|
200
|
+
topic.embeddedMedia = true;
|
|
201
|
+
topic.note = 'Embedded media: download_file cannot fetch it. Open it in Brightspace, or use get_video_transcript for its transcript.';
|
|
202
|
+
}
|
|
197
203
|
// HTML content — include body converted to markdown
|
|
198
204
|
if (item.Description?.Html) {
|
|
199
205
|
topic.content = convertHtmlToMarkdown(item.Description.Html).markdown;
|
|
@@ -13,6 +13,7 @@ import { DownloadError } from "../utils/download-errors.js";
|
|
|
13
13
|
import { MAX_FILE_SIZE } from "../utils/file-validator.js";
|
|
14
14
|
import { extractPdfText } from "../utils/pdf-extractor.js";
|
|
15
15
|
import { parseContentDispositionFilename } from "./download-file.js";
|
|
16
|
+
import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
|
|
16
17
|
import { log } from "../utils/logger.js";
|
|
17
18
|
import path from "node:path";
|
|
18
19
|
import fs from "node:fs/promises";
|
|
@@ -137,6 +138,10 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
137
138
|
}
|
|
138
139
|
throw error;
|
|
139
140
|
}
|
|
141
|
+
const protectedReason = await sensitiveDestination(downloadPath);
|
|
142
|
+
if (protectedReason) {
|
|
143
|
+
return errorResponse(sensitiveDestinationMessage(downloadPath, protectedReason), "destination");
|
|
144
|
+
}
|
|
140
145
|
}
|
|
141
146
|
// Fetch overview text
|
|
142
147
|
let overview = null;
|
package/build/tools/index.js
CHANGED
|
@@ -15,6 +15,7 @@ export { registerGetAssignmentFiles } from "./get-assignment-files.js";
|
|
|
15
15
|
export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
|
|
16
16
|
export { registerGetCourseContent } from "./get-course-content.js";
|
|
17
17
|
export { registerDownloadFile, COURSE_FILE_NEEDS_COOKIE } from "./download-file.js";
|
|
18
|
+
export { registerDownloadCourseFiles } from "./download-course-files.js";
|
|
18
19
|
export { registerGetClasslistEmails } from "./get-classlist-emails.js";
|
|
19
20
|
export { registerGetRoster } from "./get-roster.js";
|
|
20
21
|
export { registerGetMyGroups } from "./get-my-groups.js";
|
package/build/tools/schemas.js
CHANGED
|
@@ -82,6 +82,24 @@ export const DownloadFileSchema = z.object({
|
|
|
82
82
|
customFilename: z.string().max(255).optional()
|
|
83
83
|
.describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
|
|
84
84
|
});
|
|
85
|
+
export const DownloadCourseFilesSchema = z.object({
|
|
86
|
+
courseId: z.coerce.number().int().positive()
|
|
87
|
+
.describe("Course ID to download every file from."),
|
|
88
|
+
downloadPath: z.string().min(1)
|
|
89
|
+
.describe("Absolute path to an existing directory on the HOST filesystem where the course is saved. The directory must already exist; it is not created for you, so create it first if needed. Folders named after the course's modules are created inside it. Ask the user where to save the course before calling this; never guess a directory."),
|
|
90
|
+
existing: z.enum(["skip", "replace", "keep_both"]).default("skip")
|
|
91
|
+
.describe("What to do with a file that is already on disk. skip (default): leave it alone, so running this again only fetches what is new and never overwrites the user's annotated copies. replace: download it again over the old one. keep_both: save the new copy as 'name (1).ext' beside it, unless a copy of the same size is already there."),
|
|
92
|
+
moduleId: z.coerce.number().int().positive().optional()
|
|
93
|
+
.describe("Only download this module and the modules inside it (a module id from get_course_content). Omit to download the whole course."),
|
|
94
|
+
dryRun: z.boolean().default(false)
|
|
95
|
+
.describe("List the files that would be downloaded without downloading anything or creating any folder. Use it first on a large course."),
|
|
96
|
+
maxFiles: z.coerce.number().int().min(1).max(5000).default(300)
|
|
97
|
+
.describe("Most files to download in one call (default 300). Files already on disk do not count. The rest are reported as not attempted: call again to continue, since existing files are skipped."),
|
|
98
|
+
maxMinutes: z.coerce.number().int().min(1).max(120).default(8)
|
|
99
|
+
.describe("Wall-clock limit for this call in minutes (default 8). When it passes, the call stops cleanly and reports the rest as not attempted; call it again to continue, since files already on disk are skipped. Raise it only if the client allows long tool calls."),
|
|
100
|
+
maxTotalMB: z.coerce.number().int().min(1).max(1048576).default(5120)
|
|
101
|
+
.describe("Most megabytes to download in one call (default 5120, i.e. 5 GB). The rest is reported as not attempted; call again to continue."),
|
|
102
|
+
});
|
|
85
103
|
export const GetSyllabusSchema = z.object({
|
|
86
104
|
courseId: z.coerce.number().int().positive()
|
|
87
105
|
.describe("Course ID to get syllabus for."),
|
|
@@ -240,6 +240,11 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
|
|
|
240
240
|
"directory and was refused. Pass customFilename to choose one yourself.",
|
|
241
241
|
tooLarge: "The file is larger than the download size limit. " +
|
|
242
242
|
"Open it from Brightspace in a browser instead.",
|
|
243
|
+
incomplete: "Download incomplete: the connection closed before the whole file arrived, so nothing was saved. " +
|
|
244
|
+
"Try again.",
|
|
245
|
+
empty: "The file is empty (0 bytes) on Brightspace, so nothing was saved.",
|
|
246
|
+
forbiddenDestination: "That folder is not an allowed place to save files: it is the home folder itself, a drive root, a hidden folder, a system folder or an application-data folder. " +
|
|
247
|
+
"Choose a folder such as ~/Documents/Brightspace, or have the person who runs the server add the folder to D2L_DOWNLOAD_ROOTS.",
|
|
243
248
|
};
|
|
244
249
|
/**
|
|
245
250
|
* True when an error means the session itself is unusable, not that one
|
|
@@ -12,6 +12,16 @@ import { pipeline } from "node:stream/promises";
|
|
|
12
12
|
import { validateDownloadPath, validateFileType, validateFileTypeOfFile, nameForType, MAX_FILE_SIZE, } from "./file-validator.js";
|
|
13
13
|
import { DownloadError } from "./download-errors.js";
|
|
14
14
|
import { log } from "./logger.js";
|
|
15
|
+
import { sensitiveDestination } from "./sensitive-paths.js";
|
|
16
|
+
/**
|
|
17
|
+
* Every write to disk starts here, so the protected-destination rule is
|
|
18
|
+
* enforced once for all tools (see sensitiveDestination).
|
|
19
|
+
*/
|
|
20
|
+
async function assertAllowedDestination(targetDir) {
|
|
21
|
+
if (await sensitiveDestination(targetDir)) {
|
|
22
|
+
throw new DownloadError("forbiddenDestination", "Destination folder is not allowed");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
15
25
|
/**
|
|
16
26
|
* Resolve filename conflicts by appending (1), (2), etc.
|
|
17
27
|
*
|
|
@@ -57,6 +67,7 @@ export async function resolveFilenameConflict(dir, filename) {
|
|
|
57
67
|
export async function secureDownload(options) {
|
|
58
68
|
const { targetDir, filename, data, allowedTypes } = options;
|
|
59
69
|
log("DEBUG", `secureDownload: starting download of ${filename} to ${targetDir}`);
|
|
70
|
+
await assertAllowedDestination(targetDir);
|
|
60
71
|
// Validate target directory exists and is a directory
|
|
61
72
|
try {
|
|
62
73
|
const stats = await fs.stat(targetDir);
|
|
@@ -102,6 +113,14 @@ export async function secureDownload(options) {
|
|
|
102
113
|
mime,
|
|
103
114
|
};
|
|
104
115
|
}
|
|
116
|
+
/** A planned name, checked to be exactly one safe path component and nothing else. */
|
|
117
|
+
function verbatimComponent(targetDir, name) {
|
|
118
|
+
const unsafe = name === "" || name === "." || name === ".." || /[/\\\u0000]/.test(name) || path.basename(name) !== name || Buffer.byteLength(name) > 255;
|
|
119
|
+
const rel = path.relative(path.resolve(targetDir), path.resolve(targetDir, name));
|
|
120
|
+
if (unsafe || rel !== name)
|
|
121
|
+
throw new DownloadError("badFilename", "Invalid filename");
|
|
122
|
+
return name;
|
|
123
|
+
}
|
|
105
124
|
/**
|
|
106
125
|
* secureDownload for a response body that is streamed to disk rather than
|
|
107
126
|
* held in memory, so a file's size is bounded by `maxBytes` instead of by
|
|
@@ -109,11 +128,18 @@ export async function secureDownload(options) {
|
|
|
109
128
|
* type-checked there, and only then published under its final name; a refused or
|
|
110
129
|
* failed download leaves nothing behind.
|
|
111
130
|
*
|
|
131
|
+
* With `expectedBytes` (the response's Content-Length, see expectedLength), a
|
|
132
|
+
* body that ends short of it fails and leaves nothing behind: a dropped
|
|
133
|
+
* connection must not publish a truncated file that a later sync would take
|
|
134
|
+
* for the whole one.
|
|
135
|
+
*
|
|
112
136
|
* @throws DownloadError("tooLarge") once the body passes maxBytes
|
|
137
|
+
* @throws DownloadError("incomplete") when fewer than expectedBytes arrive
|
|
113
138
|
*/
|
|
114
139
|
export async function secureStreamDownload(options) {
|
|
115
|
-
const { targetDir, filename, originalFilename, body, maxBytes } = options;
|
|
116
|
-
|
|
140
|
+
const { targetDir, filename, originalFilename, body, maxBytes, expectedBytes, replaceExisting, replaceGuard, signal, verbatimName } = options;
|
|
141
|
+
await assertAllowedDestination(targetDir);
|
|
142
|
+
const safeFilename = verbatimName ? verbatimComponent(targetDir, filename) : path.basename(validateDownloadPath(targetDir, filename));
|
|
117
143
|
const partialPath = path.join(targetDir, `.download-${randomUUID()}.part`);
|
|
118
144
|
let size = 0;
|
|
119
145
|
const limit = new Transform({
|
|
@@ -127,11 +153,34 @@ export async function secureStreamDownload(options) {
|
|
|
127
153
|
}
|
|
128
154
|
},
|
|
129
155
|
});
|
|
156
|
+
const incomplete = () => new DownloadError("incomplete", `Download incomplete: ${size} of ${expectedBytes} bytes arrived`);
|
|
130
157
|
try {
|
|
131
|
-
|
|
158
|
+
try {
|
|
159
|
+
await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }), ...(signal ? [{ signal }] : []));
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
// fetch reports a connection cut mid-body as a bare "terminated".
|
|
163
|
+
if (expectedBytes !== undefined && size < expectedBytes && !(error instanceof DownloadError)) {
|
|
164
|
+
throw incomplete();
|
|
165
|
+
}
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
if (expectedBytes !== undefined && size < expectedBytes)
|
|
169
|
+
throw incomplete();
|
|
170
|
+
if (size === 0)
|
|
171
|
+
throw new DownloadError("empty", "File is empty (0 bytes)");
|
|
132
172
|
const type = await validateFileTypeOfFile(partialPath, undefined, safeFilename, originalFilename);
|
|
133
173
|
const { mime } = type;
|
|
134
|
-
const
|
|
174
|
+
const finalName = nameForType(safeFilename, type);
|
|
175
|
+
let finalPath;
|
|
176
|
+
const replaceTarget = path.join(targetDir, finalName);
|
|
177
|
+
if (replaceExisting && finalName === safeFilename && (!replaceGuard || (await replaceGuard(replaceTarget)))) {
|
|
178
|
+
finalPath = replaceTarget;
|
|
179
|
+
await fs.rename(partialPath, finalPath);
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
finalPath = await publishExclusively(partialPath, targetDir, finalName);
|
|
183
|
+
}
|
|
135
184
|
log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
|
|
136
185
|
return { path: finalPath, size, mime };
|
|
137
186
|
}
|
|
@@ -139,6 +188,20 @@ export async function secureStreamDownload(options) {
|
|
|
139
188
|
await fs.rm(partialPath, { force: true });
|
|
140
189
|
}
|
|
141
190
|
}
|
|
191
|
+
/**
|
|
192
|
+
* The byte count a response promises, or undefined when it promises none we
|
|
193
|
+
* can hold it to: no Content-Length, or a Content-Encoding, where fetch hands
|
|
194
|
+
* back the decoded body and the header counts the encoded one.
|
|
195
|
+
*/
|
|
196
|
+
export function expectedLength(response) {
|
|
197
|
+
const encoding = (response.headers.get("Content-Encoding") ?? "").trim().toLowerCase();
|
|
198
|
+
if (encoding && encoding !== "identity")
|
|
199
|
+
return undefined;
|
|
200
|
+
const header = response.headers.get("Content-Length");
|
|
201
|
+
if (header === null || !/^\d+$/.test(header.trim()))
|
|
202
|
+
return undefined;
|
|
203
|
+
return Number(header);
|
|
204
|
+
}
|
|
142
205
|
/**
|
|
143
206
|
* Give a finished temporary file its final name without ever replacing an
|
|
144
207
|
* existing file. resolveFilenameConflict only reports a name that was free a
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
* Where a download may be written. The destination is chosen through an AI
|
|
7
|
+
* client, and the file and folder names inside it come from a course, so a
|
|
8
|
+
* destination that is the home folder, a hidden folder (where settings and
|
|
9
|
+
* credentials live), an application-data folder or a system folder would let
|
|
10
|
+
* a course plant files where they do real harm. Everything else is allowed:
|
|
11
|
+
* the user's folders, cloud-sync folders, temp folders, /srv, /data,
|
|
12
|
+
* /workspace, /opt, external drives.
|
|
13
|
+
*
|
|
14
|
+
* The rule is judged on the native real path, so a symbolic link into a
|
|
15
|
+
* refused place is refused. The person who runs the server can allow more
|
|
16
|
+
* with D2L_DOWNLOAD_ROOTS (see downloadRootsFromEnv); a tool argument cannot.
|
|
17
|
+
*/
|
|
18
|
+
import { lstat, readlink, realpath } from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { log } from "./logger.js";
|
|
23
|
+
let configured = {};
|
|
24
|
+
/** Set the policy for this process. Only tests call it: production uses the defaults. */
|
|
25
|
+
export function configureDestinationPolicy(policy) {
|
|
26
|
+
configured = policy;
|
|
27
|
+
}
|
|
28
|
+
// The native variant expands Windows 8.3 short names; fs/promises has none.
|
|
29
|
+
const realpathNative = promisify(realpath.native);
|
|
30
|
+
const lstatAsync = promisify(lstat);
|
|
31
|
+
const readlinkAsync = promisify(readlink);
|
|
32
|
+
const caseInsensitive = process.platform === "win32" || process.platform === "darwin";
|
|
33
|
+
const fold = (p) => (caseInsensitive ? p.toLowerCase() : p);
|
|
34
|
+
/** \\?\ and \\.\ paths skip Windows' own name handling, so they are not accepted. */
|
|
35
|
+
const DEVICE_PREFIX = /^[\\/]{2}[?.][\\/]/;
|
|
36
|
+
class LinkLoopError extends Error {
|
|
37
|
+
}
|
|
38
|
+
const MAX_LINK_DEPTH = 40;
|
|
39
|
+
/**
|
|
40
|
+
* The native real path of `target` (8.3 names expanded on Windows). A part
|
|
41
|
+
* that does not exist yet is kept as written, but a dangling symbolic link is
|
|
42
|
+
* followed to where it points, so what is judged is where a write would land.
|
|
43
|
+
*/
|
|
44
|
+
async function resolveReal(target, depth = 0) {
|
|
45
|
+
if (depth > MAX_LINK_DEPTH)
|
|
46
|
+
throw new LinkLoopError();
|
|
47
|
+
let current = path.resolve(target);
|
|
48
|
+
const tail = [];
|
|
49
|
+
for (;;) {
|
|
50
|
+
try {
|
|
51
|
+
return path.join(await realpathNative(current), ...[...tail].reverse());
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
const code = error.code;
|
|
55
|
+
if (code === "ELOOP")
|
|
56
|
+
throw new LinkLoopError();
|
|
57
|
+
// "Not there yet" and "a folder above it that this user may not look into" (/root for a normal user)
|
|
58
|
+
// are judged by the nearest part that can be resolved, plus the rest of the path as written.
|
|
59
|
+
// Anything else is not guessed past: the caller gets the error and refuses.
|
|
60
|
+
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM" && code !== "ENOTDIR")
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
const link = await lstatAsync(current).then((stat) => (stat.isSymbolicLink() ? stat : null), () => null);
|
|
64
|
+
if (link) {
|
|
65
|
+
const pointsAt = path.resolve(path.dirname(current), await readlinkAsync(current));
|
|
66
|
+
return path.join(await resolveReal(pointsAt, depth + 1), ...[...tail].reverse());
|
|
67
|
+
}
|
|
68
|
+
const parent = path.dirname(current);
|
|
69
|
+
if (parent === current)
|
|
70
|
+
return path.resolve(target);
|
|
71
|
+
tail.push(path.basename(current));
|
|
72
|
+
current = parent;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/** The components of `child` below `parent`, or null when it is not inside it. */
|
|
76
|
+
function below(child, parent) {
|
|
77
|
+
const rel = path.relative(fold(parent), fold(child));
|
|
78
|
+
if (rel === "")
|
|
79
|
+
return [];
|
|
80
|
+
if (rel.split(path.sep)[0] === ".." || path.isAbsolute(rel))
|
|
81
|
+
return null;
|
|
82
|
+
// Original case, so a later check sees the real names.
|
|
83
|
+
return path.relative(parent, child).split(path.sep);
|
|
84
|
+
}
|
|
85
|
+
const components = (p) => path.relative(path.parse(p).root, p).split(path.sep).filter(Boolean);
|
|
86
|
+
const lower = (s) => s.toLowerCase();
|
|
87
|
+
const WINDOWS_SYSTEM_FOLDERS = new Set(["windows", "program files", "program files (x86)", "programdata", "system volume information", "$recycle.bin"]);
|
|
88
|
+
const POSIX_SYSTEM_FOLDERS = ["/etc", "/usr", "/bin", "/sbin", "/System", "/Library", "/Applications", "/boot", "/dev", "/proc", "/sys", "/private/etc"];
|
|
89
|
+
/** Temporary folders under /var (macOS keeps its per-user temp there), the only part of it that is allowed. */
|
|
90
|
+
const VAR_TEMP_FOLDERS = ["/var/folders", "/var/tmp", "/private/var/folders", "/private/var/tmp"];
|
|
91
|
+
const REFUSED_DEVICE = "it is a Windows device path (\\\\?\\ or \\\\.\\)";
|
|
92
|
+
const REFUSED_ROOT = "it is the root of a drive";
|
|
93
|
+
const REFUSED_HOME = "it is your home directory itself";
|
|
94
|
+
/**
|
|
95
|
+
* True when any AppData folder in the path is not just the way to its own
|
|
96
|
+
* Local\Temp. AppData holds applications' data and the Startup folder; only
|
|
97
|
+
* Temp is allowed, and an AppData that appears again below a Temp folder (a
|
|
98
|
+
* fake home kept in Temp) is judged on its own.
|
|
99
|
+
*/
|
|
100
|
+
function appDataOutsideTemp(names) {
|
|
101
|
+
return names.some((name, i) => name === "appdata" && !(names[i + 1] === "local" && names[i + 2] === "temp"));
|
|
102
|
+
}
|
|
103
|
+
/** Why a real path is refused by the built-in rules, or null. `home` is a real path. */
|
|
104
|
+
async function refusedByRules(real, home) {
|
|
105
|
+
const parts = components(real);
|
|
106
|
+
const hiddenPart = parts.find((part) => part.startsWith("."));
|
|
107
|
+
if (hiddenPart !== undefined)
|
|
108
|
+
return `its path goes through the hidden folder "${hiddenPart}", where settings and credentials live`;
|
|
109
|
+
const inHome = below(real, home);
|
|
110
|
+
if (process.platform === "darwin" && inHome && inHome.length > 0 && lower(inHome[0]) === "library") {
|
|
111
|
+
const allowed = (inHome.length >= 2 && lower(inHome[1]) === "cloudstorage") || (inHome.length >= 2 && lower(inHome[1]) === "mobile documents");
|
|
112
|
+
if (!allowed)
|
|
113
|
+
return "it is inside ~/Library, where applications keep their data (only ~/Library/CloudStorage and ~/Library/Mobile Documents are allowed)";
|
|
114
|
+
}
|
|
115
|
+
for (const system of POSIX_SYSTEM_FOLDERS) {
|
|
116
|
+
if (below(real, system) || below(real, await resolveReal(system)))
|
|
117
|
+
return `it is inside ${system}, a system folder`;
|
|
118
|
+
}
|
|
119
|
+
const first = parts[0] ?? "";
|
|
120
|
+
if (path.sep === "/" && /^lib(32|64|x32)?$/.test(first))
|
|
121
|
+
return `it is inside /${first}, a system folder`;
|
|
122
|
+
if (path.sep === "/" && (below(real, "/var") || below(real, "/private/var"))) {
|
|
123
|
+
if (!VAR_TEMP_FOLDERS.some((temp) => below(real, temp) || below(real, `/private${temp}`)))
|
|
124
|
+
return "it is inside /var, a system folder (only its temporary folders are allowed)";
|
|
125
|
+
}
|
|
126
|
+
if (path.sep === "/" && below(real, "/root") && !(below(home, "/root")))
|
|
127
|
+
return "it is inside /root, the administrator's home";
|
|
128
|
+
if (process.platform === "win32") {
|
|
129
|
+
const names = parts.map(lower);
|
|
130
|
+
if (WINDOWS_SYSTEM_FOLDERS.has(names[0] ?? ""))
|
|
131
|
+
return `it is inside ${parts[0]}, a Windows system folder`;
|
|
132
|
+
if (appDataOutsideTemp(names))
|
|
133
|
+
return "it is inside AppData, where applications keep their data and the Startup folder";
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
// Windows drives seen from WSL: /mnt/c/Windows, /mnt/c/Users/<name>/AppData.
|
|
137
|
+
const wsl = /^\/mnt\/[a-z]\/(.+)$/i.exec(real.split(path.sep).join("/"));
|
|
138
|
+
if (wsl) {
|
|
139
|
+
const names = wsl[1].split("/").map(lower);
|
|
140
|
+
if (WINDOWS_SYSTEM_FOLDERS.has(names[0]))
|
|
141
|
+
return `it is inside a Windows system folder (${wsl[1].split("/")[0]})`;
|
|
142
|
+
if (appDataOutsideTemp(names))
|
|
143
|
+
return "it is inside a Windows AppData folder";
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Why `dir` may not be a download destination, or null when it may. Refused:
|
|
150
|
+
* device paths, a drive root, the home folder itself, any path that goes
|
|
151
|
+
* through a hidden (dot) folder, ~/Library (except CloudStorage and Mobile
|
|
152
|
+
* Documents), system folders (/etc, /usr, /bin, /sbin, /System, /Library,
|
|
153
|
+
* /Applications, /boot, /dev, /proc, /sys, /lib*, /var apart from its temp
|
|
154
|
+
* folders, /root, and on Windows or under WSL Windows, Program Files,
|
|
155
|
+
* ProgramData and AppData). A destination inside a root listed in
|
|
156
|
+
* D2L_DOWNLOAD_ROOTS is allowed even where those rules would refuse it,
|
|
157
|
+
* but never a device path, a drive root or the home folder itself.
|
|
158
|
+
*/
|
|
159
|
+
export async function sensitiveDestination(dir, options = {}) {
|
|
160
|
+
if (DEVICE_PREFIX.test(dir))
|
|
161
|
+
return REFUSED_DEVICE;
|
|
162
|
+
const policy = { ...configured, ...options };
|
|
163
|
+
try {
|
|
164
|
+
const real = await resolveReal(dir);
|
|
165
|
+
if (DEVICE_PREFIX.test(real))
|
|
166
|
+
return REFUSED_DEVICE;
|
|
167
|
+
if (path.parse(real).root === real)
|
|
168
|
+
return REFUSED_ROOT;
|
|
169
|
+
const realHome = await resolveReal(policy.home ?? os.homedir());
|
|
170
|
+
if (fold(real) === fold(realHome))
|
|
171
|
+
return REFUSED_HOME;
|
|
172
|
+
const operatorRoots = [...(policy.allowRoots ?? []), ...(await downloadRootsFromEnv(undefined, policy.home))];
|
|
173
|
+
for (const root of operatorRoots) {
|
|
174
|
+
if (below(real, await resolveReal(root)))
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
return await refusedByRules(real, realHome);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (error instanceof LinkLoopError)
|
|
181
|
+
return "it involves a symbolic-link loop";
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const warnedRoots = new Set();
|
|
186
|
+
/** Let the next check warn again about an ignored entry. For tests. */
|
|
187
|
+
export function resetDownloadRootWarnings() {
|
|
188
|
+
warnedRoots.clear();
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The extra allowed roots named by D2L_DOWNLOAD_ROOTS, a path.delimiter
|
|
192
|
+
* separated list, each resolved to its real path. It is read from the
|
|
193
|
+
* server's own environment, which a tool argument cannot set. A listed root
|
|
194
|
+
* overrides every rule above, even inside the home folder (a hidden or
|
|
195
|
+
* ~/Library folder the user really wants), except that an entry which is a
|
|
196
|
+
* relative path, a device path, a drive root, the home folder itself or a
|
|
197
|
+
* folder that holds the home folder is ignored with a warning.
|
|
198
|
+
*/
|
|
199
|
+
export async function downloadRootsFromEnv(value = process.env.D2L_DOWNLOAD_ROOTS, home = os.homedir()) {
|
|
200
|
+
const roots = [];
|
|
201
|
+
const realHome = await resolveReal(home);
|
|
202
|
+
for (const entry of (value ?? "").split(path.delimiter).map((e) => e.trim()).filter(Boolean)) {
|
|
203
|
+
let reason = null;
|
|
204
|
+
let real = entry;
|
|
205
|
+
if (!path.isAbsolute(entry))
|
|
206
|
+
reason = "it is not an absolute path";
|
|
207
|
+
else if (DEVICE_PREFIX.test(entry))
|
|
208
|
+
reason = "it is a Windows device path";
|
|
209
|
+
else {
|
|
210
|
+
try {
|
|
211
|
+
real = await resolveReal(entry);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
reason = "it could not be resolved";
|
|
215
|
+
}
|
|
216
|
+
if (!reason && path.parse(real).root === real)
|
|
217
|
+
reason = "it is the root of a drive";
|
|
218
|
+
else if (!reason && fold(real) === fold(realHome))
|
|
219
|
+
reason = "it is your home folder itself";
|
|
220
|
+
else if (!reason && below(realHome, real))
|
|
221
|
+
reason = "it contains your home folder";
|
|
222
|
+
}
|
|
223
|
+
if (reason) {
|
|
224
|
+
if (!warnedRoots.has(entry)) {
|
|
225
|
+
warnedRoots.add(entry);
|
|
226
|
+
log("WARN", `[config] Ignoring D2L_DOWNLOAD_ROOTS entry ${JSON.stringify(entry)}: ${reason}.`);
|
|
227
|
+
}
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
roots.push(real);
|
|
231
|
+
}
|
|
232
|
+
return roots;
|
|
233
|
+
}
|
|
234
|
+
/** The refusal shown for a protected destination: the rule that refused it, and what to do. */
|
|
235
|
+
export function sensitiveDestinationMessage(dir, reason) {
|
|
236
|
+
return `Cannot save into ${dir}: ${reason}. Choose a folder such as ~/Documents/Brightspace, or have the person who runs the server add the folder to D2L_DOWNLOAD_ROOTS.`;
|
|
237
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.2.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",
|