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.
- package/build/api/client.js +23 -0
- package/build/tools/attachment-reader.js +23 -1
- package/build/tools/download-dropbox-submission-file.js +23 -19
- package/build/tools/download-file.js +39 -17
- package/build/tools/get-assignment-files.js +5 -1
- package/build/tools/get-syllabus.js +72 -21
- package/build/tools/get-video-transcript.js +21 -13
- package/build/tools/schemas.js +4 -2
- package/build/utils/download-helpers.js +65 -7
- package/build/utils/file-validator.js +73 -25
- package/build/utils/html-converter.js +3 -28
- package/build/utils/session-params.js +34 -0
- package/build/utils/transcript/lti.js +47 -3
- package/package.json +1 -1
package/build/api/client.js
CHANGED
|
@@ -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
|
-
|
|
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,
|
|
11
|
-
import {
|
|
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 >
|
|
78
|
-
return
|
|
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
|
|
100
|
+
// Check Content-Length before reading the body.
|
|
96
101
|
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
97
|
-
if (contentLength >
|
|
98
|
-
return
|
|
99
|
-
`Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
|
|
102
|
+
if (contentLength > DISK_MAX_FILE_SIZE) {
|
|
103
|
+
return tooLargeResponse(contentLength);
|
|
100
104
|
}
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
213
|
-
//
|
|
214
|
-
|
|
215
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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 ${
|
|
51
|
-
|
|
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 [
|
|
56
|
-
|
|
57
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
106
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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 {
|
|
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
|
-
|
|
138
|
-
|
|
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:
|
|
150
|
+
videoUrl: publicUrl,
|
|
144
151
|
platform: "unknown",
|
|
145
152
|
hasTranscript: false,
|
|
146
|
-
message: `This is a Brightspace LTI link (${
|
|
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:
|
|
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:
|
|
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 ${
|
|
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:
|
|
193
|
+
videoUrl: publicUrl,
|
|
186
194
|
platform,
|
|
187
195
|
hasTranscript: true,
|
|
188
196
|
title: result.title,
|
package/build/tools/schemas.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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;
|
|
161
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
89
|
+
const src = stripD2lSessionParams(el.getAttribute("src") ?? "");
|
|
89
90
|
const title = el.getAttribute("title");
|
|
90
91
|
const titlePart = title ? ` "${title}"` : "";
|
|
91
92
|
return src ? `` : "";
|
|
@@ -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
|
-
|
|
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
|
|
85
|
-
|
|
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
|
+
"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",
|