brightspace-mcp-server 5.1.43 → 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.
@@ -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
- * Download a content file using topicId
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 downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename) {
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 = await apiClient.getRaw(apiPath);
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 toolResponse(unavailable);
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;
@@ -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";
@@ -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."),
@@ -114,7 +114,8 @@ const MFA_SAME_PROMPT = "Calling again doesn't send a new prompt, and neither do
114
114
  "keeps waiting, for up to 5 minutes, on the prompt already sent.";
115
115
  /** Entra number match only: purdue-sso.ts asks for another request when one expires or is denied. */
116
116
  const MFA_NEW_NUMBER = "If Microsoft's number expires or is denied, the sign-in asks Microsoft for a new one by itself " +
117
- "and the next answer shows it; always give the user the latest number.";
117
+ "and the next answer shows it, with its own 5 minutes to approve (at most twice, so a sign-in can run up " +
118
+ "to 15 minutes in all); always give the user the latest number.";
118
119
  /**
119
120
  * Why a missed prompt cannot be skipped. Microsoft keeps a prompt open for up
120
121
  * to 5 minutes and refuses another for the same account until then
@@ -239,6 +240,11 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
239
240
  "directory and was refused. Pass customFilename to choose one yourself.",
240
241
  tooLarge: "The file is larger than the download size limit. " +
241
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.",
242
248
  };
243
249
  /**
244
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
- const safeFilename = path.basename(validateDownloadPath(targetDir, filename));
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
- await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }));
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 finalPath = await publishExclusively(partialPath, targetDir, nameForType(safeFilename, type));
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.1.43",
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",