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.
@@ -0,0 +1,649 @@
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
+ * Whole-course download: every File topic in a course's table of contents,
7
+ * saved into folders that mirror its modules. Shared by the
8
+ * `download_course_files` tool and the `sync` command.
9
+ */
10
+ import fs from "node:fs/promises";
11
+ import path from "node:path";
12
+ import { ApiError, NetworkError, RateLimitError } from "../api/errors.js";
13
+ import { openContentTopic, parseContentDispositionFilename } from "../tools/download-file.js";
14
+ import { isAuthUnavailable, sanitizeError } from "../tools/tool-helpers.js";
15
+ import { DownloadError } from "../utils/download-errors.js";
16
+ import { expectedLength, secureStreamDownload } from "../utils/download-helpers.js";
17
+ import { DISK_MAX_FILE_SIZE } from "../utils/file-validator.js";
18
+ import { sensitiveDestination, sensitiveDestinationMessage } from "../utils/sensitive-paths.js";
19
+ import { log } from "../utils/logger.js";
20
+ import { SyncInputError } from "./errors.js";
21
+ import { removeStalePartials } from "./cleanup.js";
22
+ import { acquireSyncLock } from "./lock.js";
23
+ import { SyncManifest, unchangedSinceSync } from "./manifest.js";
24
+ import { NameSet, fileNameFromTopic, sanitizeComponent, splitExtension } from "./names.js";
25
+ export { SyncInputError } from "./errors.js";
26
+ export const DEFAULT_MAX_FILES = 300;
27
+ export const DEFAULT_MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024;
28
+ /**
29
+ * Pauses before the second and third attempt at a file. The API client already
30
+ * retries 429, 5xx and network errors on the request itself; the sync retries
31
+ * only what it does not: a body that ends early or drops mid-stream, and 408/425.
32
+ */
33
+ const RETRY_DELAYS_MS = [1500, 4000];
34
+ const RETRYABLE_STATUSES = new Set([408, 425]);
35
+ const MAX_DEPTH = 32;
36
+ /** More modules and topics than any real course has: a table of contents past this is cut off. */
37
+ const MAX_TOC_NODES = 50_000;
38
+ /** Longest full path, in characters, kept on Windows (its classic limit is 260). */
39
+ const WINDOWS_PATH_BUDGET = 250;
40
+ const MIN_NAME_CHARS = 16;
41
+ /** The manifest is rewritten after this many files or this long, and always at the end. */
42
+ const MANIFEST_BATCH_FILES = 25;
43
+ const MANIFEST_BATCH_MS = 2000;
44
+ const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
45
+ function classify(topic) {
46
+ if (topic.IsBroken === true)
47
+ return "broken";
48
+ const type = typeof topic.TypeIdentifier === "string" ? topic.TypeIdentifier.toLowerCase() : "";
49
+ if (type === "contentservice")
50
+ return "media";
51
+ if (type === "link" || topic.TopicType === 2 || topic.TopicType === 3)
52
+ return "link";
53
+ if (type === "file" || topic.TopicType === 1) {
54
+ return typeof topic.Url === "string" && topic.Url.trim() !== "" ? "file" : "broken";
55
+ }
56
+ return "other";
57
+ }
58
+ const topicIdOf = (topic) => {
59
+ const id = Number(topic.TopicId ?? topic.Id);
60
+ return Number.isInteger(id) && id > 0 ? id : null;
61
+ };
62
+ /** The table of contents as folders and files, every name made safe and unique, none longer than `cap` characters. */
63
+ function planFiles(toc, moduleId, cap) {
64
+ const files = [];
65
+ const nonFiles = { link: 0, media: 0, broken: 0, other: 0, unavailable: 0 };
66
+ let nodes = 0;
67
+ let truncated = false;
68
+ const addTopics = (topics, segments, names) => {
69
+ if (!Array.isArray(topics))
70
+ return;
71
+ for (const raw of topics) {
72
+ if (++nodes > MAX_TOC_NODES) {
73
+ truncated = true;
74
+ return;
75
+ }
76
+ const topic = record(raw);
77
+ if (!topic)
78
+ continue;
79
+ const kind = classify(topic);
80
+ const topicId = topicIdOf(topic);
81
+ if (kind !== "file" || topicId === null) {
82
+ nonFiles[kind === "file" ? "broken" : kind]++;
83
+ continue;
84
+ }
85
+ const title = typeof topic.Title === "string" ? topic.Title : "";
86
+ const name = names.add(sanitizeComponent(fileNameFromTopic(topic.Url, title, `File ${topicId}`), `File ${topicId}`, cap));
87
+ files.push({ segments, name, topicId });
88
+ }
89
+ };
90
+ const walk = (container, segments, depth) => {
91
+ const names = new NameSet(cap);
92
+ const children = [];
93
+ if (Array.isArray(container.Modules)) {
94
+ // Only a level that really holds modules past the limit is cut off, not one that ends in an empty list.
95
+ if (depth >= MAX_DEPTH)
96
+ truncated ||= container.Modules.length > 0;
97
+ else {
98
+ for (const raw of container.Modules) {
99
+ if (++nodes > MAX_TOC_NODES) {
100
+ truncated = true;
101
+ break;
102
+ }
103
+ const module = record(raw);
104
+ if (!module)
105
+ continue;
106
+ const title = typeof module.Title === "string" ? module.Title : "";
107
+ const folder = names.add(sanitizeComponent(title, sanitizeComponent(`Module ${String(module.ModuleId ?? module.Id ?? "")}`.trim(), "Module", cap), cap), true);
108
+ children.push({ module, segments: [...segments, folder] });
109
+ }
110
+ }
111
+ }
112
+ // Folder names are taken before file names, so a file can never claim a module's name.
113
+ addTopics(container.Topics, segments, names);
114
+ for (const child of children)
115
+ walk(child.module, child.segments, depth + 1);
116
+ };
117
+ const root = record(toc);
118
+ if (root && moduleId === undefined) {
119
+ walk(root, [], 0);
120
+ }
121
+ else if (root) {
122
+ const found = findModule(root, moduleId, 0);
123
+ if (!found)
124
+ throw new SyncInputError(`Module ${moduleId} is not in this course's table of contents.`);
125
+ walk({ Modules: [found] }, [], 0);
126
+ }
127
+ return { files, nonFiles, truncated };
128
+ }
129
+ /** Plan, shortening names until the longest full path fits `budget` (when there is one). */
130
+ function planWithinBudget(toc, moduleId, rootLength, budget) {
131
+ let cap = 150;
132
+ let plan = planFiles(toc, moduleId, cap);
133
+ for (let round = 0; round < 6 && Number.isFinite(budget) && cap > MIN_NAME_CHARS; round++) {
134
+ const longest = Math.max(0, ...plan.files.map((f) => rootLength + 1 + [...f.segments, f.name].join("/").length));
135
+ if (longest <= budget)
136
+ break;
137
+ cap = Math.max(MIN_NAME_CHARS, Math.floor((cap * (budget - rootLength)) / (longest - rootLength)));
138
+ plan = planFiles(toc, moduleId, cap);
139
+ }
140
+ return plan;
141
+ }
142
+ function findModule(container, moduleId, depth) {
143
+ if (!Array.isArray(container.Modules) || depth > MAX_DEPTH)
144
+ return null;
145
+ for (const raw of container.Modules) {
146
+ const module = record(raw);
147
+ if (!module)
148
+ continue;
149
+ if (Number(module.ModuleId ?? module.Id) === moduleId)
150
+ return module;
151
+ const inner = findModule(module, moduleId, depth + 1);
152
+ if (inner)
153
+ return inner;
154
+ }
155
+ return null;
156
+ }
157
+ class RefusedFolder extends Error {
158
+ }
159
+ /**
160
+ * Walk `segments` below the destination one folder at a time. A folder that is
161
+ * a symbolic link is refused, not followed, so a link planted in the
162
+ * destination cannot send a download somewhere else. Returns null for a folder
163
+ * that does not exist yet, unless `create` is set.
164
+ */
165
+ export async function locateFolder(root, segments, create) {
166
+ let current = root;
167
+ for (const segment of segments) {
168
+ // One folder name at a time: a separator would make it several.
169
+ if (/[/\\\u0000]/.test(segment))
170
+ throw new RefusedFolder(`Folder "${segment}" would be outside the destination.`);
171
+ const next = path.join(current, segment);
172
+ // Containment by path.relative, so a destination that is a drive root or "/" works.
173
+ const rel = path.relative(root, next);
174
+ if (rel === "" || rel.split(path.sep)[0] === ".." || path.isAbsolute(rel)) {
175
+ throw new RefusedFolder(`Folder "${segment}" would be outside the destination.`);
176
+ }
177
+ let stat;
178
+ try {
179
+ stat = await fs.lstat(next);
180
+ }
181
+ catch (error) {
182
+ const statCode = error.code;
183
+ if (statCode === "ENAMETOOLONG")
184
+ throw new RefusedFolder("The path is too long for this filesystem.");
185
+ if (statCode !== "ENOENT")
186
+ throw error;
187
+ if (!create)
188
+ return null;
189
+ try {
190
+ await fs.mkdir(next);
191
+ }
192
+ catch (mkdirError) {
193
+ const code = mkdirError.code;
194
+ if (code === "ENAMETOOLONG" || code === "ENOENT")
195
+ throw new RefusedFolder("The path is too long for this filesystem.");
196
+ throw mkdirError;
197
+ }
198
+ current = next;
199
+ continue;
200
+ }
201
+ if (stat.isSymbolicLink())
202
+ throw new RefusedFolder(`Folder "${segment}" is a symbolic link; it is not followed.`);
203
+ if (!stat.isDirectory())
204
+ throw new RefusedFolder(`"${segment}" is a file, so a folder of that name cannot be created.`);
205
+ current = next;
206
+ }
207
+ return current;
208
+ }
209
+ const lstatOrNull = async (target) => {
210
+ try {
211
+ return await fs.lstat(target);
212
+ }
213
+ catch (error) {
214
+ if (error.code === "ENOENT")
215
+ return null;
216
+ throw error;
217
+ }
218
+ };
219
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
220
+ /** The first "name (n).ext" from `from` up that is not taken in `dir`. */
221
+ async function freeNumberedName(dir, name, from) {
222
+ const { stem, ext } = splitExtension(name);
223
+ for (let n = from;; n++) {
224
+ const candidate = `${stem} (${n})${ext}`;
225
+ if (!(await lstatOrNull(path.join(dir, candidate))))
226
+ return candidate;
227
+ }
228
+ }
229
+ /** Sizes of the file at `name` and of every "name (n).ext" beside it. */
230
+ async function copySizes(dir, name) {
231
+ const { stem, ext } = splitExtension(name);
232
+ const copy = new RegExp(`^${escapeRegExp(stem)} \\(\\d+\\)${escapeRegExp(ext)}$`, "i");
233
+ const sizes = [];
234
+ for (const entry of await fs.readdir(dir)) {
235
+ if (entry.toLowerCase() !== name.toLowerCase() && !copy.test(entry))
236
+ continue;
237
+ const stat = await lstatOrNull(path.join(dir, entry));
238
+ if (stat?.isFile())
239
+ sizes.push(stat.size);
240
+ }
241
+ return sizes;
242
+ }
243
+ /**
244
+ * Worth another attempt after a pause: what the API client does not retry
245
+ * itself. It already retries 429, 5xx and network errors on a request, so
246
+ * those are not repeated here (that would multiply the attempts).
247
+ */
248
+ function isTransient(error) {
249
+ if (isAuthUnavailable(error))
250
+ return false;
251
+ if (error instanceof DownloadError)
252
+ return error.kind === "incomplete";
253
+ if (error instanceof ApiError)
254
+ return RETRYABLE_STATUSES.has(error.status);
255
+ if (error instanceof NetworkError)
256
+ return false;
257
+ if (error instanceof Error) {
258
+ // What fetch throws when a body is cut or stalls mid-stream.
259
+ const code = String(error.cause?.code ?? "");
260
+ return error.message === "terminated" || error.name === "TimeoutError" || /^(ECONN|ETIMEDOUT|EPIPE|UND_ERR)/.test(code);
261
+ }
262
+ return false;
263
+ }
264
+ /** Run `attempt`, retrying a transient failure after each pause in `delays`. */
265
+ async function withBackoff(attempt, delays, sleep, signal) {
266
+ for (let tried = 0;; tried++) {
267
+ try {
268
+ return await attempt();
269
+ }
270
+ catch (error) {
271
+ if (signal.aborted || tried >= delays.length || !isTransient(error))
272
+ throw error;
273
+ // A cancel or deadline ends the wait at once instead of sitting out the pause.
274
+ await new Promise((resolve, reject) => {
275
+ const onAbort = () => reject(error);
276
+ signal.addEventListener("abort", onAbort, { once: true });
277
+ sleep(delays[tried]).then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
278
+ });
279
+ }
280
+ }
281
+ }
282
+ /** One short, fixed-vocabulary reason; nothing from a response body or header is echoed. */
283
+ function describeFailure(error) {
284
+ if (error instanceof RateLimitError)
285
+ return "Brightspace is rate limiting requests (429)";
286
+ if (error instanceof ApiError) {
287
+ const note = error.status === 403 ? " (access denied)" : error.status === 404 ? " (file not found)" : "";
288
+ return `Brightspace answered ${error.status}${note}`;
289
+ }
290
+ if (error instanceof NetworkError)
291
+ return "Could not connect to Brightspace";
292
+ if (error instanceof DownloadError) {
293
+ const first = sanitizeError(error).content[0];
294
+ return first.type === "text" ? first.text : "Could not save the file";
295
+ }
296
+ const code = error?.code;
297
+ if (code === "ENAMETOOLONG")
298
+ return "The path is too long for this filesystem";
299
+ if (code === "EILSEQ")
300
+ return "The file system does not accept this file or folder name";
301
+ if (isTransient(error))
302
+ return "The connection to Brightspace dropped";
303
+ return "Unexpected error";
304
+ }
305
+ const sleepFor = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
306
+ const textOf = (error) => {
307
+ const first = sanitizeError(error).content[0];
308
+ return first.type === "text" ? first.text : "Sign in to Brightspace and try again.";
309
+ };
310
+ /**
311
+ * Download every File topic of a course into `destination`, folders named
312
+ * after the modules. Files already on disk are left alone unless `existing`
313
+ * says otherwise, so a rerun is a sync. A manifest in the destination records
314
+ * what the sync wrote: only those files are ever replaced, and a rerun finds
315
+ * them again whatever they ended up named.
316
+ *
317
+ * @throws SyncInputError for a bad destination or cap, an unknown moduleId, or
318
+ * a sync already running into the same destination; the table of contents
319
+ * failing for any reason but a lost session is thrown too.
320
+ */
321
+ export async function syncCourse(apiClient, options) {
322
+ const existing = options.existing ?? "skip";
323
+ const dryRun = options.dryRun === true;
324
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
325
+ const maxTotalBytes = options.maxTotalBytes ?? DEFAULT_MAX_TOTAL_BYTES;
326
+ if (!Number.isInteger(maxFiles) || maxFiles < 1)
327
+ throw new SyncInputError("maxFiles must be a whole number of at least 1.");
328
+ if (!Number.isFinite(maxTotalBytes) || maxTotalBytes < 1)
329
+ throw new SyncInputError("maxTotalBytes must be a positive number.");
330
+ const destination = path.resolve(options.destination);
331
+ // Names inside come from the course, so never into the home folder or a credential or system folder.
332
+ const protectedReason = await sensitiveDestination(destination);
333
+ if (protectedReason)
334
+ throw new SyncInputError(sensitiveDestinationMessage(destination, protectedReason));
335
+ let root;
336
+ try {
337
+ if (!(await fs.stat(destination)).isDirectory())
338
+ throw new SyncInputError(`Destination is not a directory: ${destination}`);
339
+ root = await fs.realpath(destination);
340
+ }
341
+ catch (error) {
342
+ if (error.code === "ENOENT") {
343
+ // A dry run creates nothing, so it can preview a destination that is not there yet.
344
+ if (dryRun) {
345
+ root = destination;
346
+ }
347
+ else
348
+ throw new SyncInputError(`Destination directory does not exist: ${destination}. Create it, then retry, or choose an existing directory.`);
349
+ }
350
+ else
351
+ throw error;
352
+ }
353
+ const release = dryRun ? undefined : await acquireSyncLock(root);
354
+ try {
355
+ // What a killed earlier run (kill -9) left behind, while no other sync holds this folder.
356
+ if (!dryRun)
357
+ await removeStalePartials(root);
358
+ return await runSync(apiClient, options, { existing, dryRun, maxFiles, maxTotalBytes, destination, root });
359
+ }
360
+ finally {
361
+ await release?.();
362
+ }
363
+ }
364
+ async function runSync(apiClient, options, settings) {
365
+ const { courseId, moduleId, onProgress } = options;
366
+ const { existing, dryRun, maxFiles, maxTotalBytes, destination, root } = settings;
367
+ const delays = options.retryDelaysMs ?? RETRY_DELAYS_MS;
368
+ const sleep = options.sleep ?? sleepFor;
369
+ const budget = options.pathBudget ?? (process.platform === "win32" ? WINDOWS_PATH_BUDGET : Infinity);
370
+ // One signal for the caller's cancel and the deadline, told apart by the reason.
371
+ const stop = new AbortController();
372
+ const onCallerAbort = () => stop.abort("cancelled");
373
+ if (options.signal?.aborted)
374
+ onCallerAbort();
375
+ else
376
+ options.signal?.addEventListener("abort", onCallerAbort, { once: true });
377
+ const timer = options.deadlineMs === undefined ? undefined : setTimeout(() => stop.abort("deadline"), options.deadlineMs);
378
+ timer?.unref();
379
+ try {
380
+ const result = {
381
+ courseId,
382
+ destination,
383
+ existing,
384
+ dryRun,
385
+ downloaded: [],
386
+ ...(dryRun ? { planned: [] } : {}),
387
+ skippedExisting: 0,
388
+ emptyOnBrightspace: 0,
389
+ failed: [],
390
+ notAttempted: 0,
391
+ nonFiles: { link: 0, media: 0, broken: 0, other: 0, unavailable: 0 },
392
+ };
393
+ // Read once, and fresh: a cached copy would hide what the instructor added since the last run.
394
+ let toc;
395
+ try {
396
+ toc = await apiClient.get(apiClient.le(courseId, "/content/toc"), { signal: stop.signal });
397
+ }
398
+ catch (error) {
399
+ if (stop.signal.aborted) {
400
+ // Cancelled or out of time before the course was even read: nothing was attempted.
401
+ result.stopped = stop.signal.reason === "deadline" ? "deadline" : "cancelled";
402
+ return result;
403
+ }
404
+ if (!isAuthUnavailable(error))
405
+ throw error;
406
+ result.stopped = "auth";
407
+ result.authError = textOf(error);
408
+ return result;
409
+ }
410
+ const plan = planWithinBudget(toc, moduleId, root.length, budget);
411
+ result.nonFiles = plan.nonFiles;
412
+ if (plan.truncated)
413
+ result.tocTruncated = true;
414
+ const manifest = await SyncManifest.load(root);
415
+ const total = plan.files.length;
416
+ let done = 0;
417
+ const emit = (event) => onProgress?.({ ...event, done, total });
418
+ const fail = (rel, error) => {
419
+ result.failed.push({ path: rel, error });
420
+ emit({ type: "failed", path: rel, error });
421
+ };
422
+ const stopWith = (reason) => {
423
+ result.stopped ??= reason;
424
+ };
425
+ const abortedReason = () => (stop.signal.reason === "deadline" ? "deadline" : "cancelled");
426
+ let attempted = 0;
427
+ let totalBytes = 0;
428
+ let unsaved = 0;
429
+ let lastSaved = Date.now();
430
+ try {
431
+ for (const file of plan.files) {
432
+ done++;
433
+ if (!result.stopped && stop.signal.aborted)
434
+ stopWith(abortedReason());
435
+ if (result.stopped) {
436
+ result.notAttempted++;
437
+ continue;
438
+ }
439
+ let segments = file.segments;
440
+ let name = file.name;
441
+ let dir = null;
442
+ let present = false;
443
+ let owned = false;
444
+ // The file is still exactly what the sync wrote: only then may replace overwrite it.
445
+ let untouched = false;
446
+ let ownedEntry;
447
+ try {
448
+ // A file this sync wrote is found by its topic, wherever it ended up and whatever it was named.
449
+ const entry = manifest.get(courseId, file.topicId);
450
+ if (entry) {
451
+ const parts = entry.path.split("/");
452
+ const entryName = parts.pop();
453
+ const entryDir = await locateFolder(root, parts, false);
454
+ const onDisk = entryDir ? await lstatOrNull(path.join(entryDir, entryName)) : null;
455
+ if (entryDir && onDisk?.isFile()) {
456
+ segments = parts;
457
+ name = entryName;
458
+ dir = entryDir;
459
+ present = owned = true;
460
+ untouched = unchangedSinceSync(entry, onDisk);
461
+ ownedEntry = entry;
462
+ }
463
+ }
464
+ if (!owned) {
465
+ dir = await locateFolder(root, segments, false);
466
+ const found = dir === null ? null : await lstatOrNull(path.join(dir, name));
467
+ if (found?.isDirectory()) {
468
+ fail([...segments, name].join("/"), "A folder with this name is in the way");
469
+ continue;
470
+ }
471
+ if (found) {
472
+ const owner = manifest.ownerOf(courseId, [...segments, name].join("/"));
473
+ if (owner !== undefined && owner !== file.topicId) {
474
+ // The planned name is another topic's file (a topic added ahead of it shifted the numbering).
475
+ name = await freeNumberedName(dir, name, 2);
476
+ }
477
+ else {
478
+ present = true;
479
+ }
480
+ }
481
+ }
482
+ }
483
+ catch (error) {
484
+ fail([...segments, name].join("/"), error instanceof RefusedFolder ? error.message : "Could not open the destination folder");
485
+ continue;
486
+ }
487
+ const rel = [...segments, name].join("/");
488
+ if (Number.isFinite(budget) && root.length + 1 + rel.length > budget) {
489
+ fail(rel, `The path is too long (over ${budget} characters)`);
490
+ continue;
491
+ }
492
+ // Present and not (or no longer) exactly what this sync wrote: the user's own file, edited or not ours.
493
+ if (present && (existing === "skip" || (existing === "replace" && !untouched))) {
494
+ result.skippedExisting++;
495
+ emit({ type: "skipped", path: rel });
496
+ continue;
497
+ }
498
+ if (attempted >= maxFiles) {
499
+ result.notAttempted++;
500
+ continue;
501
+ }
502
+ // keep_both saves beside the existing file; replace overwrites only what this sync wrote.
503
+ const saveAs = present && existing === "keep_both" ? await freeNumberedName(dir, name, 1) : name;
504
+ attempted++;
505
+ if (dryRun) {
506
+ const plannedPath = [...segments, saveAs].join("/");
507
+ result.planned.push(plannedPath);
508
+ emit({ type: "planned", path: plannedPath });
509
+ continue;
510
+ }
511
+ try {
512
+ const outcome = await withBackoff(() => downloadOne(apiClient, courseId, file.topicId, {
513
+ root, dir, segments, name, saveAs,
514
+ replace: present && untouched && existing === "replace",
515
+ replaceEntry: ownedEntry,
516
+ compare: present && existing === "keep_both",
517
+ remainingBytes: maxTotalBytes - totalBytes,
518
+ signal: stop.signal,
519
+ }), delays, sleep, stop.signal);
520
+ if (outcome.kind === "saved") {
521
+ const saved = [...segments, outcome.savedName].join("/");
522
+ result.downloaded.push({ path: saved, bytes: outcome.bytes });
523
+ totalBytes += outcome.bytes;
524
+ // The file as left on disk, so a later edit by the user is noticed.
525
+ const written = await fs.stat(path.join(root, ...segments, outcome.savedName));
526
+ manifest.set(courseId, file.topicId, { path: saved, bytes: outcome.bytes, size: written.size, mtimeMs: written.mtimeMs });
527
+ // Written in batches (every 25 files or 2 s) and always when the run ends, however it ends.
528
+ unsaved++;
529
+ if (unsaved >= MANIFEST_BATCH_FILES || Date.now() - lastSaved >= MANIFEST_BATCH_MS) {
530
+ await manifest.save();
531
+ unsaved = 0;
532
+ lastSaved = Date.now();
533
+ }
534
+ emit({ type: "downloaded", path: saved, bytes: outcome.bytes });
535
+ if (totalBytes >= maxTotalBytes)
536
+ stopWith("max_total_bytes");
537
+ }
538
+ else if (outcome.kind === "duplicate") {
539
+ result.skippedExisting++;
540
+ emit({ type: "skipped", path: rel });
541
+ }
542
+ else if (outcome.kind === "empty") {
543
+ result.emptyOnBrightspace++;
544
+ emit({ type: "empty", path: rel });
545
+ }
546
+ else if (outcome.kind === "overBudget") {
547
+ stopWith("max_total_bytes");
548
+ result.notAttempted++;
549
+ }
550
+ else {
551
+ result.nonFiles.unavailable++;
552
+ emit({ type: "unavailable", path: rel });
553
+ }
554
+ }
555
+ catch (error) {
556
+ if (stop.signal.aborted) {
557
+ stopWith(abortedReason());
558
+ result.notAttempted++;
559
+ }
560
+ else if (isAuthUnavailable(error)) {
561
+ stopWith("auth");
562
+ result.authError = textOf(error);
563
+ result.notAttempted++;
564
+ }
565
+ else if (error instanceof DownloadError && error.kind === "empty") {
566
+ result.emptyOnBrightspace++;
567
+ emit({ type: "empty", path: rel });
568
+ }
569
+ else if (error instanceof RefusedFolder) {
570
+ fail(rel, error.message);
571
+ }
572
+ else {
573
+ log("WARN", `Course sync: ${rel} failed`, error);
574
+ fail(rel, describeFailure(error));
575
+ }
576
+ }
577
+ }
578
+ }
579
+ finally {
580
+ await manifest.save();
581
+ }
582
+ log("INFO", `Course sync ${courseId}: ${result.downloaded.length} downloaded, ${result.skippedExisting} already present, ${result.failed.length} failed, ${result.notAttempted} not attempted`);
583
+ return result;
584
+ }
585
+ finally {
586
+ clearTimeout(timer);
587
+ options.signal?.removeEventListener("abort", onCallerAbort);
588
+ }
589
+ }
590
+ /** One attempt at one file: the same topic download `download_file` makes, streamed into the folder. */
591
+ async function downloadOne(apiClient, courseId, topicId, where) {
592
+ const opened = await openContentTopic(apiClient, courseId, topicId, where.signal);
593
+ if ("unavailable" in opened)
594
+ return { kind: "unavailable" };
595
+ const { response } = opened;
596
+ const expected = expectedLength(response);
597
+ if (!response.body || expected === 0) {
598
+ await response.body?.cancel();
599
+ return { kind: "empty" };
600
+ }
601
+ if (expected !== undefined && expected > DISK_MAX_FILE_SIZE) {
602
+ await response.body.cancel();
603
+ throw new DownloadError("tooLarge", `File exceeds ${DISK_MAX_FILE_SIZE} bytes`);
604
+ }
605
+ // Counted before a byte is written when the server says how big the file is.
606
+ if (expected !== undefined && expected > where.remainingBytes) {
607
+ await response.body.cancel();
608
+ return { kind: "overBudget" };
609
+ }
610
+ // Under keep_both an existing copy of the same size is the same file.
611
+ const sameSize = where.compare && where.dir !== null ? await copySizes(where.dir, where.name) : [];
612
+ if (expected !== undefined && sameSize.includes(expected)) {
613
+ await response.body.cancel();
614
+ return { kind: "duplicate" };
615
+ }
616
+ const dir = await locateFolder(where.root, where.segments, true);
617
+ let result;
618
+ try {
619
+ result = await secureStreamDownload({
620
+ targetDir: dir,
621
+ filename: where.saveAs,
622
+ verbatimName: true,
623
+ originalFilename: parseContentDispositionFilename(response.headers.get("Content-Disposition") ?? "") ?? undefined,
624
+ body: response.body,
625
+ // The run's remaining byte budget caps the stream itself, so the limit holds when the server states no Content-Length.
626
+ maxBytes: Math.min(DISK_MAX_FILE_SIZE, where.remainingBytes),
627
+ expectedBytes: expected,
628
+ replaceExisting: where.replace,
629
+ // Minutes can pass between planning and the rename: an edit made meanwhile turns the replace into a copy beside the file.
630
+ replaceGuard: where.replace && where.replaceEntry
631
+ ? async (existingPath) => {
632
+ const now = await lstatOrNull(existingPath);
633
+ return now === null || (now.isFile() && unchangedSinceSync(where.replaceEntry, now));
634
+ }
635
+ : undefined,
636
+ signal: where.signal,
637
+ });
638
+ }
639
+ catch (error) {
640
+ if (error instanceof DownloadError && error.kind === "tooLarge" && where.remainingBytes < DISK_MAX_FILE_SIZE)
641
+ return { kind: "overBudget" };
642
+ throw error;
643
+ }
644
+ if (expected === undefined && sameSize.includes(result.size)) {
645
+ await fs.rm(result.path, { force: true });
646
+ return { kind: "duplicate" };
647
+ }
648
+ return { kind: "saved", savedName: path.basename(result.path), bytes: result.size };
649
+ }
@@ -0,0 +1,8 @@
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
+ /** A bad argument to the sync (unknown module, unusable destination, bad cap, a sync already running), as opposed to a failed request. */
7
+ export class SyncInputError extends Error {
8
+ }