brightspace-mcp-server 3.10.9 → 3.10.10

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.
@@ -54,6 +54,43 @@ function isExpiredSessionRedirect(body, baseUrl) {
54
54
  * Braces are used because D2L paths never contain them, so a substitution can
55
55
  * never collide with a real path segment.
56
56
  */
57
+ /**
58
+ * An abort signal that fires after `ms` without progress. Each touch() restarts
59
+ * the countdown, so a slow transfer that keeps moving is never cut off while a
60
+ * stalled one still fails.
61
+ */
62
+ function idleTimeout(ms) {
63
+ const controller = new AbortController();
64
+ let timer;
65
+ const clear = () => clearTimeout(timer);
66
+ const touch = () => {
67
+ clear();
68
+ timer = setTimeout(() => controller.abort(new DOMException("The operation was aborted due to timeout", "TimeoutError")), ms);
69
+ };
70
+ touch();
71
+ return { signal: controller.signal, touch, clear };
72
+ }
73
+ /** Restart the idle timer on every chunk of the body; stop it at the end. */
74
+ function touchOnProgress(response, idle) {
75
+ if (!response.body) {
76
+ idle.clear();
77
+ return response;
78
+ }
79
+ const body = response.body.pipeThrough(new TransformStream({
80
+ transform(chunk, controller) {
81
+ idle.touch();
82
+ controller.enqueue(chunk);
83
+ },
84
+ flush() {
85
+ idle.clear();
86
+ },
87
+ }));
88
+ return new Response(body, {
89
+ status: response.status,
90
+ statusText: response.statusText,
91
+ headers: response.headers,
92
+ });
93
+ }
57
94
  const LP_VERSION = "{lp}";
58
95
  const LE_VERSION = "{le}";
59
96
  /**
@@ -429,12 +466,15 @@ export class D2LApiClient {
429
466
  async makeRawRequest(path, token) {
430
467
  const url = `${this.baseUrl}${path}`;
431
468
  const headers = this.buildAuthHeaders(token);
469
+ // A file can take far longer than timeoutMs to arrive. Time out on a
470
+ // stall rather than on total duration, for the headers and the body alike.
471
+ const idle = idleTimeout(this.timeoutMs);
432
472
  try {
433
473
  log("DEBUG", `Requesting GET ${path} (raw)`);
434
474
  const response = await fetch(url, {
435
475
  method: "GET",
436
476
  headers,
437
- signal: AbortSignal.timeout(this.timeoutMs),
477
+ signal: idle.signal,
438
478
  });
439
479
  this.recordStatus(response.status);
440
480
  // Preserve cookie material for the shared HTTP refresh path.
@@ -473,6 +513,7 @@ export class D2LApiClient {
473
513
  }
474
514
  // A legitimate HTML page: hand back an equivalent response with the
475
515
  // body we already consumed.
516
+ idle.clear();
476
517
  return new Response(body, {
477
518
  status: response.status,
478
519
  statusText: response.statusText,
@@ -480,9 +521,10 @@ export class D2LApiClient {
480
521
  });
481
522
  }
482
523
  // Return raw response for caller to process
483
- return response;
524
+ return touchOnProgress(response, idle);
484
525
  }
485
526
  catch (error) {
527
+ idle.clear();
486
528
  // Re-throw our own errors
487
529
  if (error instanceof ApiError ||
488
530
  error instanceof RateLimitError ||
@@ -8,16 +8,35 @@ import { DownloadFileSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError, errorResponse, withUpdateNotice } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
10
  import { checkTopicAvailability } from "./topic-availability.js";
11
- // Path containment checks belong to secureDownload, which disk-mode downloads
11
+ // Path containment checks belong to secureStreamDownload, which disk-mode downloads
12
12
  // go through; importing it here only made it look as though this file
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, MAX_FILE_SIZE } from "../utils/file-validator.js";
16
- import { secureDownload } from "../utils/download-helpers.js";
16
+ import { secureStreamDownload } from "../utils/download-helpers.js";
17
+ import { DownloadError } from "../utils/download-errors.js";
17
18
  import { extractPdfText } from "../utils/pdf-extractor.js";
18
19
  import { officeDocumentText } from "../utils/zip-extract.js";
19
20
  import fs from "node:fs/promises";
20
21
  import path from "node:path";
22
+ /**
23
+ * Maximum bytes of a file disk mode will save. Disk downloads stream straight
24
+ * to the file, so memory no longer bounds them the way MAX_FILE_SIZE (50 MB)
25
+ * bounds inline mode; this only stops a runaway body filling the disk.
26
+ * Lecture decks and recordings routinely pass 50 MB.
27
+ */
28
+ const DISK_MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB
29
+ /** The size cap for a download: inline mode buffers the file, disk mode streams it. */
30
+ function maxFileSize(downloadPath) {
31
+ return downloadPath === undefined ? MAX_FILE_SIZE : DISK_MAX_FILE_SIZE;
32
+ }
33
+ /** The refusal for a file over its mode's cap, pointing inline callers at disk mode. */
34
+ function tooLargeResponse(bytes, downloadPath) {
35
+ const hint = downloadPath === undefined
36
+ ? " Provide an absolute downloadPath to save it to disk instead."
37
+ : "";
38
+ return errorResponse(`File too large (${Math.round(bytes / 1024 / 1024)}MB). Maximum allowed: ${maxFileSize(downloadPath) / 1024 / 1024}MB.${hint}`);
39
+ }
21
40
  /**
22
41
  * Maximum bytes of a file we'll embed inline in a tool response. Base64
23
42
  * encoding (for images) adds ~33% overhead, so this stays well under typical
@@ -94,8 +113,8 @@ async function respondInline(buffer, originalFilename, customFilename) {
94
113
  if (buffer.length > INLINE_MAX_SIZE) {
95
114
  return errorResponse(`File too large for inline delivery (${Math.round(buffer.length / 1024 / 1024)}MB, inline max ${INLINE_MAX_SIZE / 1024 / 1024}MB). Provide an absolute downloadPath to save it to disk instead.`);
96
115
  }
97
- // Enforces the same magic-byte allowlist as disk mode (secureDownload calls
98
- // validateFileType internally); inline mode never writes to disk, so it has
116
+ // Enforces the same magic-byte allowlist as disk mode (secureStreamDownload calls
117
+ // validateFileTypeOfFile internally); inline mode never writes to disk, so it has
99
118
  // to call this itself. Throws DownloadError on an unsupported or
100
119
  // undetectable type, handled by the caller's sanitizeError.
101
120
  const { mime } = await validateFileType(buffer, undefined, effectiveFilename);
@@ -179,20 +198,35 @@ async function respondInline(buffer, originalFilename, customFilename) {
179
198
  return withUpdateNotice(content);
180
199
  }
181
200
  /**
182
- * Finish a download once the bytes are in hand: write to disk when
201
+ * Finish a download once the response is in hand: stream it to disk when
183
202
  * `downloadPath` is given (unchanged response shape, plus a new `mode: "disk"`
184
- * field), otherwise return the file inline in the tool response.
203
+ * field), otherwise buffer it and return the file inline in the tool response.
185
204
  */
186
- async function finishDownload(buffer, originalFilename, downloadPath, customFilename, sourceLabel) {
205
+ async function finishDownload(response, originalFilename, downloadPath, customFilename, sourceLabel) {
206
+ // Check Content-Length BEFORE reading the body (prevent memory exhaustion)
207
+ const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
208
+ if (contentLength > maxFileSize(downloadPath)) {
209
+ return tooLargeResponse(contentLength, downloadPath);
210
+ }
187
211
  if (downloadPath === undefined) {
212
+ const buffer = Buffer.from(await response.arrayBuffer());
213
+ // Double-check actual size
214
+ if (buffer.length > MAX_FILE_SIZE) {
215
+ return tooLargeResponse(buffer.length, downloadPath);
216
+ }
188
217
  return respondInline(buffer, originalFilename, customFilename);
189
218
  }
190
219
  const effectiveFilename = customFilename || originalFilename;
191
- // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
192
- const result = await secureDownload({
220
+ if (!response.body) {
221
+ throw new DownloadError("undetectableType", "File is empty (0 bytes)");
222
+ }
223
+ // Streams to disk with path traversal prevention, file type validation,
224
+ // conflict resolution, and the disk size cap
225
+ const result = await secureStreamDownload({
193
226
  targetDir: downloadPath,
194
227
  filename: effectiveFilename,
195
- data: buffer,
228
+ body: response.body,
229
+ maxBytes: DISK_MAX_FILE_SIZE,
196
230
  });
197
231
  log("INFO", `${sourceLabel} downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
198
232
  return toolResponse({
@@ -314,23 +348,12 @@ async function downloadContentFile(apiClient, courseId, topicId, downloadPath, c
314
348
  }
315
349
  throw error;
316
350
  }
317
- // Check Content-Length BEFORE downloading body (prevent memory exhaustion)
318
- const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
319
- if (contentLength > MAX_FILE_SIZE) {
320
- return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
321
- }
322
351
  // Get filename from Content-Disposition header
323
352
  const disposition = response.headers.get("Content-Disposition") ?? "";
324
353
  const filename = parseContentDispositionFilename(disposition) ?? "download";
325
354
  log("DEBUG", `Content-Disposition filename: ${filename}`);
326
- // Download body as buffer
327
- const buffer = Buffer.from(await response.arrayBuffer());
328
- // Double-check actual size
329
- if (buffer.length > MAX_FILE_SIZE) {
330
- return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
331
- }
332
355
  const originalFilename = filename;
333
- return finishDownload(buffer, originalFilename, downloadPath, customFilename, "Content file");
356
+ return finishDownload(response, originalFilename, downloadPath, customFilename, "Content file");
334
357
  }
335
358
  /**
336
359
  * Download a submission/feedback file using folderId + fileId
@@ -368,22 +391,16 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
368
391
  return errorResponse(`File ID ${fileId} not found in submission. Available files: ${available.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
369
392
  }
370
393
  // Check file size before downloading
371
- if (file.Size > MAX_FILE_SIZE) {
372
- return errorResponse(`File too large (${Math.round(file.Size / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
394
+ if (file.Size > maxFileSize(downloadPath)) {
395
+ return tooLargeResponse(file.Size, downloadPath);
373
396
  }
374
397
  // D2L file download URL pattern for submission files
375
398
  // GET /d2l/api/le/(version)/(orgUnitId)/dropbox/folders/(folderId)/submissions/(submissionId)/files/(fileId)/download
376
399
  const downloadApiPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/${submission.Id}/files/${fileId}/download`);
377
400
  // Fetch file
378
401
  const response = await apiClient.getRaw(downloadApiPath);
379
- // Download body as buffer
380
- const buffer = Buffer.from(await response.arrayBuffer());
381
- // Double-check actual size
382
- if (buffer.length > MAX_FILE_SIZE) {
383
- return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
384
- }
385
402
  const originalFilename = file.FileName;
386
- return finishDownload(buffer, originalFilename, downloadPath, customFilename, "Submission file");
403
+ return finishDownload(response, originalFilename, downloadPath, customFilename, "Submission file");
387
404
  }
388
405
  /**
389
406
  * Download an announcement attachment using newsId + fileId
@@ -396,24 +413,13 @@ async function downloadNewsAttachment(apiClient, courseId, newsId, fileId, downl
396
413
  if (!file) {
397
414
  return errorResponse(`File ID ${fileId} not found on this announcement. Available files: ${attachments.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
398
415
  }
399
- if (file.Size > MAX_FILE_SIZE) {
400
- return errorResponse(`File too large (${Math.round(file.Size / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
416
+ if (file.Size > maxFileSize(downloadPath)) {
417
+ return tooLargeResponse(file.Size, downloadPath);
401
418
  }
402
419
  // GET /d2l/api/le/(version)/(orgUnitId)/news/(newsItemId)/attachments/(fileId)
403
420
  const response = await apiClient.getRaw(apiClient.le(courseId, `/news/${newsId}/attachments/${fileId}`));
404
- // Check Content-Length BEFORE downloading body (prevent memory exhaustion)
405
- const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
406
- if (contentLength > MAX_FILE_SIZE) {
407
- return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
408
- }
409
421
  const disposition = response.headers.get("Content-Disposition") ?? "";
410
422
  const filename = parseContentDispositionFilename(disposition) ?? file.FileName;
411
- // Download body as buffer
412
- const buffer = Buffer.from(await response.arrayBuffer());
413
- // Double-check actual size
414
- if (buffer.length > MAX_FILE_SIZE) {
415
- return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
416
- }
417
423
  const originalFilename = filename;
418
- return finishDownload(buffer, originalFilename, downloadPath, customFilename, "Announcement attachment");
424
+ return finishDownload(response, originalFilename, downloadPath, customFilename, "Announcement attachment");
419
425
  }
@@ -133,6 +133,8 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
133
133
  "Pass customFilename to choose one yourself.",
134
134
  pathTraversal: "The name Brightspace gave this file pointed outside the download " +
135
135
  "directory and was refused. Pass customFilename to choose one yourself.",
136
+ tooLarge: "The file is larger than the download size limit. " +
137
+ "Open it from Brightspace in a browser instead.",
136
138
  };
137
139
  /**
138
140
  * True when an error means the session itself is unusable, not that one
@@ -4,8 +4,13 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import fs from "node:fs/promises";
7
+ import { createWriteStream } from "node:fs";
7
8
  import path from "node:path";
8
- import { validateDownloadPath, validateFileType, MAX_FILE_SIZE } from "./file-validator.js";
9
+ import { randomUUID } from "node:crypto";
10
+ import { Readable, Transform } from "node:stream";
11
+ import { pipeline } from "node:stream/promises";
12
+ import { validateDownloadPath, validateFileType, validateFileTypeOfFile, MAX_FILE_SIZE, } from "./file-validator.js";
13
+ import { DownloadError } from "./download-errors.js";
9
14
  import { log } from "./logger.js";
10
15
  /**
11
16
  * Resolve filename conflicts by appending (1), (2), etc.
@@ -97,3 +102,42 @@ export async function secureDownload(options) {
97
102
  mime,
98
103
  };
99
104
  }
105
+ /**
106
+ * secureDownload for a response body that is streamed to disk rather than
107
+ * held in memory, so a file's size is bounded by `maxBytes` instead of by
108
+ * MAX_FILE_SIZE. The body is written to a hidden temporary file in targetDir,
109
+ * type-checked there, and only then renamed to its final name; a refused or
110
+ * failed download leaves nothing behind.
111
+ *
112
+ * @throws DownloadError("tooLarge") once the body passes maxBytes
113
+ */
114
+ export async function secureStreamDownload(options) {
115
+ const { targetDir, filename, body, maxBytes } = options;
116
+ const safeFilename = path.basename(validateDownloadPath(targetDir, filename));
117
+ const partialPath = path.join(targetDir, `.download-${randomUUID()}.part`);
118
+ let size = 0;
119
+ const limit = new Transform({
120
+ transform(chunk, _encoding, callback) {
121
+ size += chunk.length;
122
+ if (size > maxBytes) {
123
+ callback(new DownloadError("tooLarge", `File exceeds ${maxBytes} bytes`));
124
+ }
125
+ else {
126
+ callback(null, chunk);
127
+ }
128
+ },
129
+ });
130
+ try {
131
+ await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }));
132
+ const { mime } = await validateFileTypeOfFile(partialPath, undefined, safeFilename);
133
+ const resolvedFilename = await resolveFilenameConflict(targetDir, safeFilename);
134
+ const finalPath = path.join(targetDir, resolvedFilename);
135
+ await fs.rename(partialPath, finalPath);
136
+ log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
137
+ return { path: finalPath, size, mime };
138
+ }
139
+ catch (error) {
140
+ await fs.rm(partialPath, { force: true });
141
+ throw error;
142
+ }
143
+ }
@@ -3,6 +3,7 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
+ import fs from "node:fs/promises";
6
7
  import path from "node:path";
7
8
  import sanitizeFilename from "sanitize-filename";
8
9
  import { DownloadError } from "./download-errors.js";
@@ -21,12 +22,12 @@ import { DownloadError } from "./download-errors.js";
21
22
  */
22
23
  // file-type v21 is ESM-only and costs ~50ms; defer the import to first use
23
24
  // since validateFileType is already async.
24
- let fileTypeFromBufferPromise;
25
- function getFileTypeFromBuffer() {
26
- if (!fileTypeFromBufferPromise) {
27
- fileTypeFromBufferPromise = import("file-type").then((m) => m.fileTypeFromBuffer);
25
+ let fileTypePromise;
26
+ function getFileType() {
27
+ if (!fileTypePromise) {
28
+ fileTypePromise = import("file-type");
28
29
  }
29
- return fileTypeFromBufferPromise;
30
+ return fileTypePromise;
30
31
  }
31
32
  const CFB_MIME = "application/x-cfb";
32
33
  const CFB_EXTENSION_MIMES = {
@@ -147,33 +148,61 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
147
148
  throw new DownloadError("undetectableType", "File is empty (0 bytes)");
148
149
  }
149
150
  // Try magic byte detection first
150
- const fileTypeFromBuffer = await getFileTypeFromBuffer();
151
+ const { fileTypeFromBuffer } = await getFileType();
151
152
  const detected = await fileTypeFromBuffer(buffer);
152
- if (detected) {
153
- if (detected.mime === XML_MIME) {
154
- const ext = filename ? path.extname(filename).toLowerCase() : "";
155
- const resolved = XML_EXTENSION_MIMES[ext];
156
- if (resolved && allowedTypes.includes(resolved)) {
157
- return { mime: resolved, ext: ext.slice(1) };
158
- }
159
- // Anything else falls through to the allowlist check, which refuses
160
- // application/xml the way it always has.
161
- }
162
- if (detected.mime === CFB_MIME) {
163
- const ext = filename ? path.extname(filename).toLowerCase() : "";
164
- const resolved = CFB_EXTENSION_MIMES[ext];
165
- if (resolved && allowedTypes.includes(resolved)) {
166
- return { mime: resolved, ext: ext.slice(1) };
167
- }
168
- throw new DownloadError("unsupportedType", `Compound File Binary with extension '${ext || "none"}' is not an allowed Office format`, CFB_MIME);
153
+ if (detected)
154
+ return allowDetectedType(detected, allowedTypes, filename);
155
+ return allowTextType(buffer, allowedTypes);
156
+ }
157
+ /**
158
+ * validateFileType for a file already on disk, so a download streamed there
159
+ * is never read back into memory whole. Magic bytes are read from the file
160
+ * itself; only a file with none, the text fallback's case, is read in, and
161
+ * only when it is within MAX_FILE_SIZE.
162
+ */
163
+ export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MIME_TYPES, filename) {
164
+ const { size } = await fs.stat(filePath);
165
+ if (size === 0) {
166
+ throw new DownloadError("undetectableType", "File is empty (0 bytes)");
167
+ }
168
+ const { fileTypeFromFile } = await getFileType();
169
+ const detected = await fileTypeFromFile(filePath);
170
+ if (detected)
171
+ return allowDetectedType(detected, allowedTypes, filename);
172
+ if (size > MAX_FILE_SIZE) {
173
+ throw new DownloadError("undetectableType", "Could not determine file type or type not allowed");
174
+ }
175
+ return allowTextType(await fs.readFile(filePath), allowedTypes);
176
+ }
177
+ /** The allowlist decision for a type file-type recognised from magic bytes. */
178
+ function allowDetectedType(detected, allowedTypes, filename) {
179
+ if (detected.mime === XML_MIME) {
180
+ const ext = filename ? path.extname(filename).toLowerCase() : "";
181
+ const resolved = XML_EXTENSION_MIMES[ext];
182
+ if (resolved && allowedTypes.includes(resolved)) {
183
+ return { mime: resolved, ext: ext.slice(1) };
169
184
  }
170
- if (!allowedTypes.includes(detected.mime)) {
171
- throw new DownloadError("unsupportedType", `File type '${detected.mime}' not allowed`, detected.mime);
185
+ // Anything else falls through to the allowlist check, which refuses
186
+ // application/xml the way it always has.
187
+ }
188
+ if (detected.mime === CFB_MIME) {
189
+ const ext = filename ? path.extname(filename).toLowerCase() : "";
190
+ const resolved = CFB_EXTENSION_MIMES[ext];
191
+ if (resolved && allowedTypes.includes(resolved)) {
192
+ return { mime: resolved, ext: ext.slice(1) };
172
193
  }
173
- return { mime: detected.mime, ext: detected.ext };
194
+ throw new DownloadError("unsupportedType", `Compound File Binary with extension '${ext || "none"}' is not an allowed Office format`, CFB_MIME);
195
+ }
196
+ if (!allowedTypes.includes(detected.mime)) {
197
+ throw new DownloadError("unsupportedType", `File type '${detected.mime}' not allowed`, detected.mime);
174
198
  }
175
- // Fallback for text-based files with no magic-byte signature: accept if the
176
- // buffer decodes as UTF-8 (rejecting binaries and NUL bytes), then sniff HTML.
199
+ return { mime: detected.mime, ext: detected.ext };
200
+ }
201
+ /**
202
+ * Fallback for text-based files with no magic-byte signature: accept if the
203
+ * buffer decodes as UTF-8 (rejecting binaries and NUL bytes), then sniff HTML.
204
+ */
205
+ function allowTextType(buffer, allowedTypes) {
177
206
  if (!buffer.includes(0)) {
178
207
  let decoded = null;
179
208
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.10.9",
3
+ "version": "3.10.10",
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",