brightspace-mcp-server 3.11.3 → 3.11.5

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.
@@ -211,6 +211,10 @@ export class D2LApiClient {
211
211
  async initialize() {
212
212
  await this.ensureVersions();
213
213
  }
214
+ /** The configured Brightspace origin (scheme, host and port), e.g. `https://purdue.brightspace.com`. */
215
+ get origin() {
216
+ return new URL(this.baseUrl).origin;
217
+ }
214
218
  /**
215
219
  * Get discovered API versions.
216
220
  * @throws Error if no request has discovered them yet
@@ -308,6 +312,13 @@ export class D2LApiClient {
308
312
  * read by a read-only tool is not worth an MFA prompt.
309
313
  */
310
314
  async getPage(path) {
315
+ // The cookie belongs to this Brightspace alone. A path that would make
316
+ // `${baseUrl}${path}` name another host (`@evil.example/`, `.evil.example/`,
317
+ // `//evil.example/`) is refused before the cookie is ever read.
318
+ if (!this.isSameOriginPath(path)) {
319
+ log("WARN", "getPage refused a path outside the Brightspace origin");
320
+ return null;
321
+ }
311
322
  const token = await this.tokenManager.getToken();
312
323
  if (!token?.cookieHeader)
313
324
  return null;
@@ -322,6 +333,18 @@ export class D2LApiClient {
322
333
  throw error;
323
334
  }
324
335
  }
336
+ /** True when `path` is a root-relative path that keeps a request on the configured origin. */
337
+ isSameOriginPath(path) {
338
+ if (!path.startsWith("/") || path.startsWith("//") || path.includes("\\"))
339
+ return false;
340
+ try {
341
+ const target = new URL(`${this.baseUrl}${path}`);
342
+ return target.origin === this.origin && !target.username && !target.password;
343
+ }
344
+ catch {
345
+ return false;
346
+ }
347
+ }
325
348
  /** One HTTP refresh and at most one browser login per caller. */
326
349
  async withAuthentication(path, request) {
327
350
  let token = await this.tokenManager.getToken();
@@ -5,6 +5,9 @@
5
5
  */
6
6
  import { extractPdfText } from "../utils/pdf-extractor.js";
7
7
  import { officeDocumentText } from "../utils/zip-extract.js";
8
+ import { readBodyCapped } from "../utils/download-helpers.js";
9
+ import { DownloadError } from "../utils/download-errors.js";
10
+ import { MAX_FILE_SIZE } from "../utils/file-validator.js";
8
11
  const KIND_BY_EXTENSION = {
9
12
  pdf: "pdf",
10
13
  docx: "docx",
@@ -44,8 +47,27 @@ export async function readAttachment(apiClient, sourcePath, attachment, extract,
44
47
  const base = describeAttachment(attachment);
45
48
  if (!extract)
46
49
  return { ...base, text: null, note: "Text extraction was not requested." };
50
+ // Extraction holds the whole file in memory, so it is capped: refused from
51
+ // the listed size when that is already over, and cut off mid-read when the
52
+ // listed size (or Content-Length) understated it.
53
+ const tooLarge = {
54
+ ...base,
55
+ text: null,
56
+ truncated: false,
57
+ note: `This file is over the ${MAX_FILE_SIZE / 1024 / 1024}MB text extraction limit. Use download_file with an absolute downloadPath to save it to disk instead.`,
58
+ };
59
+ if (attachment.Size > MAX_FILE_SIZE)
60
+ return tooLarge;
47
61
  const response = await apiClient.getRaw(sourcePath);
48
- const buffer = Buffer.from(await response.arrayBuffer());
62
+ let buffer;
63
+ try {
64
+ buffer = await readBodyCapped(response, MAX_FILE_SIZE);
65
+ }
66
+ catch (error) {
67
+ if (error instanceof DownloadError && error.kind === "tooLarge")
68
+ return tooLarge;
69
+ throw error;
70
+ }
49
71
  let text = null;
50
72
  let note;
51
73
  switch (base.kind) {
@@ -7,10 +7,16 @@ import { ApiError } from "../api/index.js";
7
7
  import { DownloadDropboxSubmissionFileSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
- import { validateContentId, MAX_FILE_SIZE } from "../utils/file-validator.js";
11
- import { secureDownload } from "../utils/download-helpers.js";
10
+ import { validateContentId, DISK_MAX_FILE_SIZE } from "../utils/file-validator.js";
11
+ import { secureStreamDownload } from "../utils/download-helpers.js";
12
+ import { DownloadError } from "../utils/download-errors.js";
12
13
  import path from "node:path";
13
14
  import fs from "node:fs/promises";
15
+ /** The refusal for a file over the disk download cap. */
16
+ function tooLargeResponse(bytes) {
17
+ return errorResponse(`File too large (${Math.round(bytes / 1024 / 1024)}MB). ` +
18
+ `Maximum allowed: ${DISK_MAX_FILE_SIZE / 1024 / 1024}MB`);
19
+ }
14
20
  /**
15
21
  * Register download_dropbox_submission_file tool.
16
22
  */
@@ -74,9 +80,8 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
74
80
  throw error;
75
81
  }
76
82
  // Check the size Brightspace reported before downloading anything.
77
- if (targetFile.Size > MAX_FILE_SIZE) {
78
- return errorResponse(`File too large (${Math.round(targetFile.Size / 1024 / 1024)}MB). ` +
79
- `Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
83
+ if (targetFile.Size > DISK_MAX_FILE_SIZE) {
84
+ return tooLargeResponse(targetFile.Size);
80
85
  }
81
86
  const downloadApiPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/${submissionId}/files/${fileId}/download`);
82
87
  let response;
@@ -92,27 +97,26 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
92
97
  }
93
98
  throw error;
94
99
  }
95
- // Check Content-Length before downloading the body (prevent memory exhaustion).
100
+ // Check Content-Length before reading the body.
96
101
  const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
97
- if (contentLength > MAX_FILE_SIZE) {
98
- return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). ` +
99
- `Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
102
+ if (contentLength > DISK_MAX_FILE_SIZE) {
103
+ return tooLargeResponse(contentLength);
100
104
  }
101
- const buffer = Buffer.from(await response.arrayBuffer());
102
- // Double-check the actual size once downloaded.
103
- if (buffer.length > MAX_FILE_SIZE) {
104
- return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). ` +
105
- `Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
105
+ if (!response.body) {
106
+ throw new DownloadError("undetectableType", "File is empty (0 bytes)");
106
107
  }
107
108
  const originalFilename = targetFile.FileName;
108
109
  const effectiveFilename = customFilename || originalFilename;
109
- // secureDownload applies this project's path-containment and
110
- // magic-byte file-type checks and resolves filename conflicts,
111
- // rather than writing the fetched buffer straight to disk.
112
- const result = await secureDownload({
110
+ // secureStreamDownload streams the body to a temporary file in the
111
+ // target directory, applies this project's path-containment and
112
+ // magic-byte file-type checks there, enforces the disk size cap on the
113
+ // bytes actually received, and resolves filename conflicts; a refused
114
+ // or failed download leaves nothing behind.
115
+ const result = await secureStreamDownload({
113
116
  targetDir: downloadPath,
114
117
  filename: effectiveFilename,
115
- data: buffer,
118
+ body: response.body,
119
+ maxBytes: DISK_MAX_FILE_SIZE,
116
120
  });
117
121
  log("INFO", `download_dropbox_submission_file: saved ${result.path} (${result.size} bytes, ${result.mime})`);
118
122
  return toolResponse({
@@ -12,20 +12,13 @@ import { checkTopicAvailability } from "./topic-availability.js";
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
- import { validateContentId, validateFileType, MAX_FILE_SIZE } from "../utils/file-validator.js";
16
- import { secureStreamDownload } from "../utils/download-helpers.js";
15
+ import { validateContentId, validateFileType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
16
+ import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
17
17
  import { DownloadError } from "../utils/download-errors.js";
18
18
  import { extractPdfText } from "../utils/pdf-extractor.js";
19
19
  import { officeDocumentText } from "../utils/zip-extract.js";
20
20
  import fs from "node:fs/promises";
21
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
22
  /** The size cap for a download: inline mode buffers the file, disk mode streams it. */
30
23
  function maxFileSize(downloadPath) {
31
24
  return downloadPath === undefined ? MAX_FILE_SIZE : DISK_MAX_FILE_SIZE;
@@ -35,7 +28,9 @@ function tooLargeResponse(bytes, downloadPath) {
35
28
  const hint = downloadPath === undefined
36
29
  ? " Provide an absolute downloadPath to save it to disk instead."
37
30
  : "";
38
- return errorResponse(`File too large (${Math.round(bytes / 1024 / 1024)}MB). Maximum allowed: ${maxFileSize(downloadPath) / 1024 / 1024}MB.${hint}`);
31
+ // null: the body was cut off at the cap, so its full size is unknown.
32
+ const size = bytes === null ? "over the limit" : `${Math.round(bytes / 1024 / 1024)}MB`;
33
+ return errorResponse(`File too large (${size}). Maximum allowed: ${maxFileSize(downloadPath) / 1024 / 1024}MB.${hint}`);
39
34
  }
40
35
  /**
41
36
  * Maximum bytes of a file we'll embed inline in a tool response. Base64
@@ -209,10 +204,17 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
209
204
  return tooLargeResponse(contentLength, downloadPath);
210
205
  }
211
206
  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);
207
+ // Content-Length can be missing or understated, so the read itself is
208
+ // capped and stops at the limit rather than buffering the whole body.
209
+ let buffer;
210
+ try {
211
+ buffer = await readBodyCapped(response, MAX_FILE_SIZE);
212
+ }
213
+ catch (error) {
214
+ if (error instanceof DownloadError && error.kind === "tooLarge") {
215
+ return tooLargeResponse(null, downloadPath);
216
+ }
217
+ throw error;
216
218
  }
217
219
  return respondInline(buffer, originalFilename, customFilename);
218
220
  }
@@ -245,13 +247,16 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
245
247
  export function registerDownloadFile(server, apiClient) {
246
248
  server.registerTool("download_file", {
247
249
  title: "Download File",
248
- description: "Download a file from course content, assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
250
+ description: "Download a file from course content, instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
249
251
  inputSchema: DownloadFileSchema,
250
252
  }, async (args) => {
251
253
  try {
252
254
  log("DEBUG", "download_file tool called", { args });
253
255
  // Parse and validate input
254
- const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
256
+ const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
257
+ if (source !== undefined && (topicId !== undefined || newsId !== undefined || folderId === undefined || fileId === undefined)) {
258
+ return errorResponse("source requires folderId and fileId and cannot be combined with topicId or newsId.");
259
+ }
255
260
  // Validate courseId
256
261
  validateContentId(courseId);
257
262
  // downloadPath is optional: omitting it means "return the file
@@ -286,7 +291,7 @@ export function registerDownloadFile(server, apiClient) {
286
291
  // Submission file download
287
292
  validateContentId(folderId);
288
293
  validateContentId(fileId);
289
- return await downloadSubmissionFile(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
294
+ return await (source === "assignmentAttachment" ? downloadAssignmentAttachment : downloadSubmissionFile)(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
290
295
  }
291
296
  else if (newsId !== undefined && fileId !== undefined) {
292
297
  // Announcement attachment download
@@ -303,6 +308,23 @@ export function registerDownloadFile(server, apiClient) {
303
308
  }
304
309
  });
305
310
  }
311
+ /** Download an instructor-provided handout, independently of student submissions. */
312
+ async function downloadAssignmentAttachment(apiClient, courseId, folderId, fileId, downloadPath, customFilename) {
313
+ const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"));
314
+ const folders = Array.isArray(raw) ? raw : raw?.Objects ?? [];
315
+ const folder = folders.find((candidate) => candidate.Id === folderId && candidate.IsHidden !== true);
316
+ if (!folder)
317
+ return errorResponse(`No visible assignment with id ${folderId} in course ${courseId}.`);
318
+ const attachments = folder.Attachments ?? [];
319
+ const file = attachments.find((candidate) => candidate.FileId === fileId);
320
+ if (!file)
321
+ return errorResponse(`File ID ${fileId} not found on this assignment. Available files: ${attachments.map((candidate) => `${candidate.FileName} (ID: ${candidate.FileId})`).join(", ")}`);
322
+ if (file.Size > maxFileSize(downloadPath))
323
+ return tooLargeResponse(file.Size, downloadPath);
324
+ const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`));
325
+ const filename = parseContentDispositionFilename(response.headers.get("Content-Disposition") ?? "") ?? file.FileName;
326
+ return finishDownload(response, filename, downloadPath, customFilename, "Assignment attachment");
327
+ }
306
328
  /**
307
329
  * Extract a filename from a Content-Disposition header.
308
330
  */
@@ -60,6 +60,7 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
60
60
  });
61
61
  }
62
62
  const file = await readAttachment(apiClient, apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`), attachment, extractText, maxChars);
63
+ file.downloadArgs = { courseId, folderId, fileId, source: "assignmentAttachment" };
63
64
  return toolResponse({
64
65
  courseId,
65
66
  folderId,
@@ -76,7 +77,10 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
76
77
  folderName: folder.Name,
77
78
  dueDate: folder.DueDate,
78
79
  url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
79
- attachments: (folder.Attachments ?? []).map(describeAttachment),
80
+ attachments: (folder.Attachments ?? []).map((attachment) => ({
81
+ ...describeAttachment(attachment),
82
+ downloadArgs: { courseId, folderId: folder.Id, fileId: attachment.FileId, source: "assignmentAttachment" },
83
+ })),
80
84
  }));
81
85
  log("INFO", `get_assignment_files: ${withFiles.length} assignments with attachments in course ${courseId}`);
82
86
  return toolResponse({
@@ -7,7 +7,9 @@ import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetSyllabusSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
9
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
- import { secureDownload } from "../utils/download-helpers.js";
10
+ import { stripD2lSessionParams } from "../utils/session-params.js";
11
+ import { secureDownload, readBodyCapped } from "../utils/download-helpers.js";
12
+ import { DownloadError } from "../utils/download-errors.js";
11
13
  import { MAX_FILE_SIZE } from "../utils/file-validator.js";
12
14
  import { extractPdfText } from "../utils/pdf-extractor.js";
13
15
  import { log } from "../utils/logger.js";
@@ -32,7 +34,9 @@ function findExternalSyllabusSources(ltiLinks, toc) {
32
34
  for (const m of modules) {
33
35
  for (const t of m.Topics ?? []) {
34
36
  if (t.Url && LTI_LAUNCH_PATTERN.test(t.Url) && SYLLABUS_PATTERN.test(t.Title)) {
35
- sources.push({ title: t.Title, location: "content", url: t.Url });
37
+ // Keep routing params (ou, type, rcode) so the link still opens the
38
+ // right tool, but never echo D2L session tokens.
39
+ sources.push({ title: t.Title, location: "content", url: stripD2lSessionParams(t.Url) });
36
40
  }
37
41
  }
38
42
  walk(m.Modules ?? []);
@@ -41,25 +45,63 @@ function findExternalSyllabusSources(ltiLinks, toc) {
41
45
  walk(toc);
42
46
  return sources;
43
47
  }
44
- /** Fetch a lookup that students may not be permitted to make; failure means "none". */
45
- async function fetchOrEmpty(fetch, fallback, what) {
48
+ const LOOKUP_LABELS = {
49
+ "lti-links": "course LTI links",
50
+ "content-toc": "course content table of contents",
51
+ };
52
+ /**
53
+ * Run a lookup that students may not be permitted to make. A failure is
54
+ * reported as unavailable rather than folded into "nothing found", so callers
55
+ * can tell an empty search from one that could not be completed. Only the
56
+ * HTTP status is kept; raw error bodies never reach the tool output.
57
+ */
58
+ async function tryLookup(lookup, fetch) {
46
59
  try {
47
- return await fetch();
60
+ return { ok: true, value: await fetch() };
48
61
  }
49
62
  catch (error) {
50
- log("DEBUG", `get_syllabus: could not read ${what}`, error);
51
- return fallback;
63
+ log("DEBUG", `get_syllabus: could not read ${LOOKUP_LABELS[lookup]}`, error);
64
+ if (error instanceof ApiError) {
65
+ const forbidden = error.status === 401 || error.status === 403;
66
+ return {
67
+ ok: false,
68
+ unavailable: { lookup, reason: forbidden ? "permission-denied" : "request-failed", status: error.status },
69
+ };
70
+ }
71
+ return { ok: false, unavailable: { lookup, reason: "request-failed" } };
52
72
  }
53
73
  }
74
+ function describeUnavailable(u) {
75
+ const why = u.reason === "permission-denied"
76
+ ? "permission denied"
77
+ : u.status !== undefined
78
+ ? `request failed (HTTP ${u.status})`
79
+ : "request failed (network error)";
80
+ return `${LOOKUP_LABELS[u.lookup]}: ${why}`;
81
+ }
54
82
  async function discoverExternalSyllabusSources(apiClient, courseId) {
55
- const [ltiLinks, toc] = await Promise.all([
56
- fetchOrEmpty(() => apiClient.get(apiClient.leGlobal(`/lti/link/${courseId}/`), { ttl: DEFAULT_CACHE_TTLS.courseContent }), [], "LTI links"),
57
- fetchOrEmpty(() => apiClient.get(apiClient.le(courseId, "/content/toc"), { ttl: DEFAULT_CACHE_TTLS.courseContent }), {}, "content table of contents"),
83
+ const [lti, toc] = await Promise.all([
84
+ tryLookup("lti-links", () => apiClient.get(apiClient.leGlobal(`/lti/link/${courseId}/`), { ttl: DEFAULT_CACHE_TTLS.courseContent })),
85
+ tryLookup("content-toc", () => apiClient.get(apiClient.le(courseId, "/content/toc"), { ttl: DEFAULT_CACHE_TTLS.courseContent })),
58
86
  ]);
59
- const sources = findExternalSyllabusSources(ltiLinks ?? [], toc?.Modules ?? []);
60
- return sources.length > 0
61
- ? { externalSyllabusSources: sources, externalSyllabusNote: EXTERNAL_SYLLABUS_NOTE }
62
- : {};
87
+ const ltiLinks = lti.ok ? (Array.isArray(lti.value) ? lti.value : []) : [];
88
+ const modules = toc.ok ? (toc.value?.Modules ?? []) : [];
89
+ const sources = findExternalSyllabusSources(ltiLinks, modules);
90
+ const unavailable = [lti, toc].flatMap((o) => (o.ok ? [] : [o.unavailable]));
91
+ const fields = {};
92
+ if (sources.length > 0) {
93
+ fields.externalSyllabusSources = sources;
94
+ fields.externalSyllabusNote = EXTERNAL_SYLLABUS_NOTE;
95
+ }
96
+ if (unavailable.length > 0) {
97
+ fields.externalSyllabusDiscovery = {
98
+ complete: false,
99
+ unavailable,
100
+ note: `Could not check every place an external syllabus may be linked (${unavailable.map(describeUnavailable).join("; ")}). ` +
101
+ "Missing external sources here does not mean the course has no syllabus.",
102
+ };
103
+ }
104
+ return { fields, complete: unavailable.length === 0 };
63
105
  }
64
106
  /**
65
107
  * Register get_syllabus tool
@@ -67,7 +109,7 @@ async function discoverExternalSyllabusSources(apiClient, courseId) {
67
109
  export function registerGetSyllabus(server, apiClient) {
68
110
  server.registerTool("get_syllabus", {
69
111
  title: "Get Course Syllabus",
70
- description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. When the syllabus lives in an external LTI tool (e.g. Simple Syllabus), lists those sources in externalSyllabusSources; their contents cannot be read. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
112
+ description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. When the syllabus lives in an external LTI tool (e.g. Simple Syllabus), lists those sources in externalSyllabusSources; their contents cannot be read. If some of those lookups could not be completed (e.g. permission denied), externalSyllabusDiscovery says which, and an empty result is then not proof the course has no syllabus. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
71
113
  inputSchema: GetSyllabusSchema,
72
114
  }, async (args) => {
73
115
  try {
@@ -98,12 +140,15 @@ export function registerGetSyllabus(server, apiClient) {
98
140
  }
99
141
  catch (error) {
100
142
  if (error instanceof ApiError && error.status === 404) {
143
+ const discovery = await discoverExternalSyllabusSources(apiClient, courseId);
101
144
  return toolResponse({
102
145
  courseId,
103
146
  description: null,
104
147
  hasAttachment: false,
105
- message: "No syllabus/overview found for this course.",
106
- ...(await discoverExternalSyllabusSources(apiClient, courseId)),
148
+ message: discovery.complete
149
+ ? "No syllabus/overview found for this course."
150
+ : "No course overview found, but external syllabus discovery was incomplete, so the course may still have a syllabus elsewhere.",
151
+ ...discovery.fields,
107
152
  });
108
153
  }
109
154
  throw error;
@@ -132,9 +177,15 @@ export function registerGetSyllabus(server, apiClient) {
132
177
  attachmentFilename = match[1].replace(/['"]/g, "");
133
178
  }
134
179
  // Download body as buffer
135
- attachmentBuffer = Buffer.from(await response.arrayBuffer());
136
- if (attachmentBuffer.length > MAX_FILE_SIZE) {
137
- return errorResponse(`Attachment too large (${Math.round(attachmentBuffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
180
+ // Capped read: Content-Length can be missing or understated.
181
+ try {
182
+ attachmentBuffer = await readBodyCapped(response, MAX_FILE_SIZE);
183
+ }
184
+ catch (error) {
185
+ if (error instanceof DownloadError && error.kind === "tooLarge") {
186
+ return errorResponse(`Attachment too large (over ${MAX_FILE_SIZE / 1024 / 1024}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
187
+ }
188
+ throw error;
138
189
  }
139
190
  }
140
191
  }
@@ -201,7 +252,7 @@ export function registerGetSyllabus(server, apiClient) {
201
252
  if (download) {
202
253
  result.download = download;
203
254
  }
204
- Object.assign(result, await discoverExternalSyllabusSources(apiClient, courseId));
255
+ Object.assign(result, (await discoverExternalSyllabusSources(apiClient, courseId)).fields);
205
256
  return toolResponse(result);
206
257
  }
207
258
  catch (error) {
@@ -8,11 +8,12 @@ import { GetVideoTranscriptSchema } from "./schemas.js";
8
8
  import { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
9
9
  import { cuesToText, paginateText } from "../utils/transcript/captions.js";
10
10
  import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId, } from "../utils/transcript/platform.js";
11
- import { isBrightspaceRelativeLink, readLtiLaunchPage } from "../utils/transcript/lti.js";
11
+ import { readLtiLaunchPage, toBrightspacePath } from "../utils/transcript/lti.js";
12
12
  import { getKalturaTranscript } from "../utils/transcript/kaltura.js";
13
13
  import { getYouTubeTranscript } from "../utils/transcript/youtube.js";
14
14
  import { NoTranscriptError, TranscriptFetchError } from "../utils/transcript/errors.js";
15
15
  import { log } from "../utils/logger.js";
16
+ import { stripD2lSessionParams } from "../utils/session-params.js";
16
17
  const UNSUPPORTED_PLATFORM_LABEL = {
17
18
  panopto: "Panopto",
18
19
  yuja: "YuJa",
@@ -38,7 +39,7 @@ async function followLtiLaunch(apiClient, path) {
38
39
  const html = await apiClient.getPage(next);
39
40
  if (html === null)
40
41
  return null;
41
- const finding = readLtiLaunchPage(html);
42
+ const finding = readLtiLaunchPage(html, apiClient.origin);
42
43
  if (!finding)
43
44
  return null;
44
45
  if ("videoUrl" in finding)
@@ -53,7 +54,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
53
54
  const ids = extractKalturaIds(videoUrl);
54
55
  if (!ids) {
55
56
  return {
56
- failed: `Could not find a Kaltura entry ID and partner ID in this URL: ${videoUrl}`,
57
+ failed: `Could not find a Kaltura entry ID and partner ID in this URL: ${stripD2lSessionParams(videoUrl)}`,
57
58
  };
58
59
  }
59
60
  try {
@@ -74,7 +75,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
74
75
  case "youtube": {
75
76
  const videoId = extractYouTubeVideoId(videoUrl);
76
77
  if (!videoId) {
77
- return { failed: `Could not find a YouTube video ID in this URL: ${videoUrl}` };
78
+ return { failed: `Could not find a YouTube video ID in this URL: ${stripD2lSessionParams(videoUrl)}` };
78
79
  }
79
80
  try {
80
81
  return { result: await getYouTubeTranscript(videoId, fetchImpl) };
@@ -97,7 +98,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
97
98
  };
98
99
  default:
99
100
  return {
100
- unsupported: `Could not identify a supported video platform for this URL: ${videoUrl}. ` +
101
+ unsupported: `Could not identify a supported video platform for this URL: ${stripD2lSessionParams(videoUrl)}. ` +
101
102
  "Open the video in Brightspace directly.",
102
103
  };
103
104
  }
@@ -134,16 +135,22 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
134
135
  else {
135
136
  return errorResponse("Provide either videoUrl, or both courseId and topicId to look up the video from course content.");
136
137
  }
137
- if (isBrightspaceRelativeLink(resolvedUrl)) {
138
- const launchedUrl = await followLtiLaunch(apiClient, resolvedUrl);
138
+ // A relative /d2l/ link, or an absolute one on this Brightspace's own
139
+ // origin, is requested as the user; any other origin never sees the session.
140
+ const brightspacePath = toBrightspacePath(resolvedUrl, apiClient.origin);
141
+ if (brightspacePath !== null) {
142
+ const launchedUrl = await followLtiLaunch(apiClient, brightspacePath);
139
143
  if (!launchedUrl) {
144
+ // resolvedUrl (with any session params) was only for the
145
+ // authenticated getPage above; public output gets the clean form.
146
+ const publicUrl = stripD2lSessionParams(resolvedUrl);
140
147
  return toolResponse({
141
148
  courseId,
142
149
  topicId,
143
- videoUrl: resolvedUrl,
150
+ videoUrl: publicUrl,
144
151
  platform: "unknown",
145
152
  hasTranscript: false,
146
- message: `This is a Brightspace LTI link (${resolvedUrl}), and its launch did not reveal which video it opens. ` +
153
+ message: `This is a Brightspace LTI link (${publicUrl}), and its launch did not reveal which video it opens. ` +
147
154
  "Some tools only hand over the video after a browser sign-in to the tool itself, which this server " +
148
155
  "can't do. The video platform itself may still be supported. Open it in Brightspace directly.",
149
156
  });
@@ -152,11 +159,12 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
152
159
  }
153
160
  const platform = detectVideoPlatform(resolvedUrl);
154
161
  const outcome = await fetchTranscript(platform, resolvedUrl, fetchImpl);
162
+ const publicUrl = stripD2lSessionParams(resolvedUrl);
155
163
  if ("unsupported" in outcome) {
156
164
  return toolResponse({
157
165
  courseId,
158
166
  topicId,
159
- videoUrl: resolvedUrl,
167
+ videoUrl: publicUrl,
160
168
  platform,
161
169
  hasTranscript: false,
162
170
  message: outcome.unsupported,
@@ -166,7 +174,7 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
166
174
  return toolResponse({
167
175
  courseId,
168
176
  topicId,
169
- videoUrl: resolvedUrl,
177
+ videoUrl: publicUrl,
170
178
  platform,
171
179
  hasTranscript: false,
172
180
  message: outcome.noTranscript,
@@ -178,11 +186,11 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
178
186
  const { result } = outcome;
179
187
  const fullText = cuesToText(result.cues);
180
188
  const { window, truncated, nextOffset, totalChars } = paginateText(fullText, offset, maxChars);
181
- log("INFO", `get_video_transcript: ${platform} transcript for ${resolvedUrl} (${result.cues.length} cues, ${totalChars} chars)`);
189
+ log("INFO", `get_video_transcript: ${platform} transcript for ${publicUrl} (${result.cues.length} cues, ${totalChars} chars)`);
182
190
  return toolResponse({
183
191
  courseId,
184
192
  topicId,
185
- videoUrl: resolvedUrl,
193
+ videoUrl: publicUrl,
186
194
  platform,
187
195
  hasTranscript: true,
188
196
  title: result.title,
@@ -62,14 +62,16 @@ export const GetClasslistEmailsSchema = z.object({
62
62
  .describe("Course ID to get emails for."),
63
63
  });
64
64
  export const DownloadFileSchema = z.object({
65
+ source: z.enum(["submission", "assignmentAttachment"]).optional()
66
+ .describe("For folderId + fileId: assignmentAttachment downloads an instructor-provided handout from get_assignment_files; submission (default) downloads your own submitted file. Do not combine source with topicId or newsId."),
65
67
  courseId: z.coerce.number().int().positive()
66
68
  .describe("Course ID the file belongs to."),
67
69
  topicId: z.coerce.number().int().positive().optional()
68
70
  .describe("Content topic ID to download (for course content files)."),
69
71
  folderId: z.coerce.number().int().positive().optional()
70
- .describe("Dropbox folder ID (for submission/feedback file downloads)."),
72
+ .describe("Dropbox folder ID. Downloads your submission by default; set source to assignmentAttachment for an instructor-provided handout."),
71
73
  fileId: z.coerce.number().int().positive().optional()
72
- .describe("Specific file ID within a dropbox submission, or an announcement attachment's file ID (with newsId)."),
74
+ .describe("Specific file ID within a dropbox submission, instructor assignment attachment (with source: assignmentAttachment), or announcement attachment (with newsId)."),
73
75
  newsId: z.coerce.number().int().positive().optional()
74
76
  .describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
75
77
  downloadPath: z.string().min(1).optional()
@@ -4,7 +4,7 @@
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
+ import { createWriteStream, constants as fsConstants } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { Readable, Transform } from "node:stream";
@@ -106,7 +106,7 @@ export async function secureDownload(options) {
106
106
  * secureDownload for a response body that is streamed to disk rather than
107
107
  * held in memory, so a file's size is bounded by `maxBytes` instead of by
108
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
109
+ * type-checked there, and only then published under its final name; a refused or
110
110
  * failed download leaves nothing behind.
111
111
  *
112
112
  * @throws DownloadError("tooLarge") once the body passes maxBytes
@@ -130,14 +130,72 @@ export async function secureStreamDownload(options) {
130
130
  try {
131
131
  await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }));
132
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);
133
+ const finalPath = await publishExclusively(partialPath, targetDir, safeFilename);
136
134
  log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
137
135
  return { path: finalPath, size, mime };
138
136
  }
139
- catch (error) {
137
+ finally {
140
138
  await fs.rm(partialPath, { force: true });
141
- throw error;
142
139
  }
143
140
  }
141
+ /**
142
+ * Give a finished temporary file its final name without ever replacing an
143
+ * existing file. resolveFilenameConflict only reports a name that was free a
144
+ * moment ago, and rename() overwrites, so two concurrent downloads of the same
145
+ * name could both pick it and the later rename silently replaced the earlier
146
+ * file. link() and COPYFILE_EXCL fail with EEXIST instead, and we try the next
147
+ * free name. The caller removes the temporary file.
148
+ */
149
+ async function publishExclusively(partialPath, targetDir, filename) {
150
+ for (let attempt = 0; attempt <= 100; attempt++) {
151
+ const finalPath = path.join(targetDir, await resolveFilenameConflict(targetDir, filename));
152
+ try {
153
+ try {
154
+ await fs.link(partialPath, finalPath);
155
+ }
156
+ catch (error) {
157
+ // Some filesystems (FAT/exFAT drives, certain network shares) have no
158
+ // hard links; copy instead, still refusing to overwrite.
159
+ const code = error.code;
160
+ if (code !== "EPERM" && code !== "ENOTSUP" && code !== "ENOSYS")
161
+ throw error;
162
+ await fs.copyFile(partialPath, finalPath, fsConstants.COPYFILE_EXCL);
163
+ }
164
+ return finalPath;
165
+ }
166
+ catch (error) {
167
+ if (error.code !== "EEXIST")
168
+ throw error;
169
+ }
170
+ }
171
+ throw new Error("Could not resolve filename conflict after 100 attempts");
172
+ }
173
+ /**
174
+ * Read a response body into memory, refusing to hold more than `maxBytes`.
175
+ *
176
+ * response.arrayBuffer() buffers whatever the server sends, so a missing or
177
+ * understated Content-Length let an arbitrarily large body be allocated before
178
+ * any size check ran. This counts the bytes actually received and cancels the
179
+ * stream as soon as they pass the cap.
180
+ *
181
+ * @throws DownloadError("tooLarge") once the body passes maxBytes
182
+ */
183
+ export async function readBodyCapped(response, maxBytes) {
184
+ if (!response.body)
185
+ return Buffer.alloc(0);
186
+ const reader = response.body.getReader();
187
+ const chunks = [];
188
+ let size = 0;
189
+ for (;;) {
190
+ const { done, value } = await reader.read();
191
+ if (done)
192
+ break;
193
+ size += value.byteLength;
194
+ if (size > maxBytes) {
195
+ await reader.cancel().catch(() => { });
196
+ throw new DownloadError("tooLarge", `File exceeds ${maxBytes} bytes`);
197
+ }
198
+ chunks.push(value);
199
+ }
200
+ return Buffer.concat(chunks, size);
201
+ }
@@ -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 { createReadStream } from "node:fs";
6
7
  import fs from "node:fs/promises";
7
8
  import path from "node:path";
8
9
  import sanitizeFilename from "sanitize-filename";
@@ -58,6 +59,14 @@ const XML_EXTENSION_MIMES = {
58
59
  * Prevents memory exhaustion from malicious large file requests.
59
60
  */
60
61
  export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
62
+ /**
63
+ * Maximum bytes of a file a disk-mode download will save. Disk downloads
64
+ * stream straight to the file, so memory no longer bounds them the way
65
+ * MAX_FILE_SIZE bounds buffered downloads; this only stops a runaway body
66
+ * filling the disk. Lecture decks, recordings and submitted decks routinely
67
+ * pass 50 MB.
68
+ */
69
+ export const DISK_MAX_FILE_SIZE = 2 * 1024 * 1024 * 1024; // 2 GB
61
70
  /**
62
71
  * Allowlist of MIME types safe for download.
63
72
  * Prevents execution of potentially malicious file types (executables, scripts).
@@ -157,8 +166,9 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
157
166
  /**
158
167
  * validateFileType for a file already on disk, so a download streamed there
159
168
  * 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.
169
+ * itself; a file with none, the text fallback's case, is streamed through the
170
+ * same UTF-8 and NUL checks a chunk at a time, so plain text, CSV and JSON
171
+ * are held to the disk download limit rather than to MAX_FILE_SIZE.
162
172
  */
163
173
  export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MIME_TYPES, filename) {
164
174
  const { size } = await fs.stat(filePath);
@@ -169,10 +179,7 @@ export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MI
169
179
  const detected = await fileTypeFromFile(filePath);
170
180
  if (detected)
171
181
  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);
182
+ return allowTextTypeOfFile(filePath, allowedTypes);
176
183
  }
177
184
  /** The allowlist decision for a type file-type recognised from magic bytes. */
178
185
  function allowDetectedType(detected, allowedTypes, filename) {
@@ -211,28 +218,69 @@ function allowTextType(buffer, allowedTypes) {
211
218
  catch {
212
219
  // Invalid UTF-8 — treat as binary.
213
220
  }
214
- if (decoded !== null) {
215
- const noBom = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded;
216
- const head = noBom.trimStart().toLowerCase();
217
- let mime = "text/plain";
218
- let ext = "txt";
219
- if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
220
- mime = "text/html";
221
- ext = "html";
222
- }
223
- else if (head.startsWith("<svg") || head.startsWith("<!doctype svg")) {
224
- // An SVG without the XML prolog reaches the fallback instead of being
225
- // detected. Naming it text/plain told the caller the wrong type for a
226
- // file the allowlist has an entry for.
227
- mime = "image/svg+xml";
228
- ext = "svg";
229
- }
230
- if (allowedTypes.includes(mime)) {
231
- return { mime, ext };
221
+ if (decoded !== null)
222
+ return allowTextHead(decoded, allowedTypes);
223
+ }
224
+ throw undetectableText();
225
+ }
226
+ /**
227
+ * allowTextType for a file on disk, read a chunk at a time so memory stays
228
+ * bounded however large the file is. The decoder runs in streaming mode, so a
229
+ * multi-byte character split across chunks is still decoded, and only enough
230
+ * of the leading text to sniff HTML and SVG is kept.
231
+ */
232
+ async function allowTextTypeOfFile(filePath, allowedTypes) {
233
+ const decoder = new TextDecoder("utf-8", { fatal: true });
234
+ let head = "";
235
+ try {
236
+ for await (const chunk of createReadStream(filePath)) {
237
+ const bytes = chunk;
238
+ if (bytes.includes(0))
239
+ throw undetectableText();
240
+ const text = decoder.decode(bytes, { stream: true });
241
+ if (head.length < TEXT_HEAD_CHARS) {
242
+ // Leading whitespace is dropped by the sniff anyway; trimming it here
243
+ // keeps a file of nothing but whitespace from growing `head` unbounded.
244
+ head = (head + text.slice(0, TEXT_HEAD_CHARS)).trimStart();
232
245
  }
233
246
  }
247
+ head += decoder.decode();
248
+ }
249
+ catch (error) {
250
+ if (error instanceof DownloadError)
251
+ throw error;
252
+ if (error instanceof TypeError)
253
+ throw undetectableText(); // Invalid UTF-8.
254
+ throw error;
255
+ }
256
+ return allowTextHead(head, allowedTypes);
257
+ }
258
+ /** Enough leading characters to recognise every prefix allowTextHead tests. */
259
+ const TEXT_HEAD_CHARS = 64;
260
+ /** Name decoded text by its leading characters and apply the allowlist. */
261
+ function allowTextHead(decoded, allowedTypes) {
262
+ const noBom = decoded.charCodeAt(0) === 0xfeff ? decoded.slice(1) : decoded;
263
+ const head = noBom.trimStart().toLowerCase();
264
+ let mime = "text/plain";
265
+ let ext = "txt";
266
+ if (head.startsWith("<!doctype html") || head.startsWith("<html")) {
267
+ mime = "text/html";
268
+ ext = "html";
269
+ }
270
+ else if (head.startsWith("<svg") || head.startsWith("<!doctype svg")) {
271
+ // An SVG without the XML prolog reaches the fallback instead of being
272
+ // detected. Naming it text/plain told the caller the wrong type for a
273
+ // file the allowlist has an entry for.
274
+ mime = "image/svg+xml";
275
+ ext = "svg";
234
276
  }
235
- throw new DownloadError("undetectableType", "Could not determine file type or type not allowed");
277
+ if (allowedTypes.includes(mime)) {
278
+ return { mime, ext };
279
+ }
280
+ throw undetectableText();
281
+ }
282
+ function undetectableText() {
283
+ return new DownloadError("undetectableType", "Could not determine file type or type not allowed");
236
284
  }
237
285
  /**
238
286
  * Validate content ID is a positive integer.
@@ -4,6 +4,7 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { createRequire } from "node:module";
7
+ import { stripD2lSessionParams } from "./session-params.js";
7
8
  // turndown is CJS and costs ~50ms to import; defer it to first use via
8
9
  // createRequire so callers keep a synchronous API instead of turning async.
9
10
  const require = createRequire(import.meta.url);
@@ -74,7 +75,7 @@ function getTurndownService() {
74
75
  // keep whatever text was inside it.
75
76
  if (isUnsafeHref(href))
76
77
  return content;
77
- const cleaned = stripSessionParams(href).replace(/([()])/g, "\\$1");
78
+ const cleaned = stripD2lSessionParams(href).replace(/([()])/g, "\\$1");
78
79
  const title = node.getAttribute("title");
79
80
  const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
80
81
  return `[${content}](${cleaned}${titlePart})`;
@@ -85,7 +86,7 @@ function getTurndownService() {
85
86
  replacement: (_content, node) => {
86
87
  const el = node;
87
88
  const alt = el.getAttribute("alt") ?? "";
88
- const src = stripSessionParams(el.getAttribute("src") ?? "");
89
+ const src = stripD2lSessionParams(el.getAttribute("src") ?? "");
89
90
  const title = el.getAttribute("title");
90
91
  const titlePart = title ? ` "${title}"` : "";
91
92
  return src ? `![${alt}](${src}${titlePart})` : "";
@@ -94,32 +95,6 @@ function getTurndownService() {
94
95
  }
95
96
  return turndownService;
96
97
  }
97
- const SESSION_QUERY_PARAMS = new Set(["d2lsessionval", "d2lsecuresessionval", "_"]);
98
- /**
99
- * Strip D2L's per-session query params (`d2lSessionVal`, `d2lSecureSessionVal`,
100
- * and the `_` cache-buster) from a URL. Everything else about the URL --
101
- * scheme, path, other params -- is left untouched, including a relative href,
102
- * which is returned as-is apart from this filtering.
103
- */
104
- function stripSessionParams(url) {
105
- const queryStart = url.indexOf("?");
106
- if (queryStart < 0)
107
- return url;
108
- const earlyHash = url.indexOf("#");
109
- if (earlyHash >= 0 && earlyHash < queryStart) {
110
- // The "?" falls inside the fragment (e.g. "#frag?x=1"), not a real query
111
- // string -- nothing to strip, and treating it as one would mangle the
112
- // fragment.
113
- return url;
114
- }
115
- const hashStart = url.indexOf("#", queryStart);
116
- const query = url.slice(queryStart + 1, hashStart < 0 ? undefined : hashStart);
117
- const hash = hashStart < 0 ? "" : url.slice(hashStart);
118
- const kept = query
119
- .split("&")
120
- .filter((param) => param !== "" && !SESSION_QUERY_PARAMS.has(param.split("=")[0].toLowerCase()));
121
- return url.slice(0, queryStart) + (kept.length > 0 ? `?${kept.join("&")}` : "") + hash;
122
- }
123
98
  /** True for `javascript:`/`data:` hrefs -- never a legitimate content link. */
124
99
  function isUnsafeHref(href) {
125
100
  return /^\s*(javascript|data):/i.test(href);
@@ -0,0 +1,34 @@
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
+ /** D2L's per-session query params, matched case-insensitively by name. */
7
+ const SESSION_QUERY_PARAMS = new Set(["d2lsessionval", "d2lsecuresessionval", "_"]);
8
+ /**
9
+ * Strip D2L's per-session query params (`d2lSessionVal`, `d2lSecureSessionVal`,
10
+ * and the `_` cache-buster) from a URL before it is shown to the model or user.
11
+ * Everything else about the URL -- scheme, path, routing params such as
12
+ * `ou`/`type`/`rcode`, the fragment -- is left untouched, and a relative URL
13
+ * stays relative. Use the original URL for any authenticated request; this is
14
+ * only for public output.
15
+ */
16
+ export function stripD2lSessionParams(url) {
17
+ const queryStart = url.indexOf("?");
18
+ if (queryStart < 0)
19
+ return url;
20
+ const earlyHash = url.indexOf("#");
21
+ if (earlyHash >= 0 && earlyHash < queryStart) {
22
+ // The "?" falls inside the fragment (e.g. "#frag?x=1"), not a real query
23
+ // string -- nothing to strip, and treating it as one would mangle the
24
+ // fragment.
25
+ return url;
26
+ }
27
+ const hashStart = url.indexOf("#", queryStart);
28
+ const query = url.slice(queryStart + 1, hashStart < 0 ? undefined : hashStart);
29
+ const hash = hashStart < 0 ? "" : url.slice(hashStart);
30
+ const kept = query
31
+ .split("&")
32
+ .filter((param) => param !== "" && !SESSION_QUERY_PARAMS.has(param.split("=")[0].toLowerCase()));
33
+ return url.slice(0, queryStart) + (kept.length > 0 ? `?${kept.join("&")}` : "") + hash;
34
+ }
@@ -16,6 +16,40 @@ import { detectVideoPlatform, extractKalturaIds, extractYouTubeVideoId } from ".
16
16
  export function isBrightspaceRelativeLink(url) {
17
17
  return url.startsWith("/d2l/");
18
18
  }
19
+ /**
20
+ * The path and query of a link into the user's own Brightspace, or null for
21
+ * anything else. A relative `/d2l/` link qualifies, and so does an absolute
22
+ * URL whose origin is exactly the configured Brightspace origin and whose
23
+ * path starts with `/d2l/`, so both take the same authenticated route. A URL
24
+ * on any other origin (a lookalike host, an http: downgrade, a different
25
+ * port, a protocol-relative `//host/...`, or one carrying userinfo) is never
26
+ * a Brightspace link: the session must not travel there.
27
+ */
28
+ export function toBrightspacePath(url, origin) {
29
+ const trimmed = url.trim();
30
+ let expectedOrigin;
31
+ try {
32
+ expectedOrigin = new URL(origin ?? "https://brightspace.invalid").origin;
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ const relative = isBrightspaceRelativeLink(trimmed);
38
+ if (!relative && (!origin || !/^https?:\/\//i.test(trimmed)))
39
+ return null;
40
+ let parsed;
41
+ try {
42
+ parsed = new URL(trimmed, expectedOrigin);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (parsed.origin !== expectedOrigin || parsed.username || parsed.password)
48
+ return null;
49
+ if (!parsed.pathname.startsWith("/d2l/"))
50
+ return null;
51
+ return `${parsed.pathname}${parsed.search}`;
52
+ }
19
53
  function decodeEntities(value) {
20
54
  return value
21
55
  .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
@@ -68,7 +102,13 @@ function asVideoUrl(candidate, consumerKey) {
68
102
  withPartner.searchParams.set("wid", `_${consumerKey}`);
69
103
  return namesVideo(withPartner.href) ? withPartner.href : null;
70
104
  }
71
- export function readLtiLaunchPage(html) {
105
+ /**
106
+ * Read an LTI launch page for the video it opens, or for the one Brightspace
107
+ * page it frames. Pass the Brightspace `origin` so an absolute link back into
108
+ * the same Brightspace counts as a page to follow; without it only relative
109
+ * `/d2l/` links do.
110
+ */
111
+ export function readLtiLaunchPage(html, origin) {
72
112
  const inputs = tagAttributes(html, "input");
73
113
  const consumerKey = inputs.find(input => input.get("name") === "oauth_consumer_key")?.get("value");
74
114
  const candidates = [
@@ -81,6 +121,10 @@ export function readLtiLaunchPage(html) {
81
121
  if (videoUrl)
82
122
  return { videoUrl };
83
123
  }
84
- const nextPath = candidates.find(isBrightspaceRelativeLink);
85
- return nextPath ? { nextPath } : null;
124
+ for (const candidate of candidates) {
125
+ const nextPath = toBrightspacePath(candidate, origin);
126
+ if (nextPath)
127
+ return { nextPath };
128
+ }
129
+ return null;
86
130
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.11.3",
3
+ "version": "3.11.5",
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",