brightspace-mcp-server 5.0.12 → 5.1.4
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/README.md +11 -3
- package/build/http-server.js +3 -1
- package/build/index.js +68 -44
- package/build/runtime-relay.js +290 -0
- package/build/server.js +5 -6
- package/build/shared-runtime.js +126 -0
- package/build/tools/download-dropbox-submission-file.js +10 -10
- package/build/tools/download-file.js +15 -15
- package/build/tools/get-calendar-events.js +1 -1
- package/build/tools/get-discussions.js +1 -1
- package/build/tools/get-dropbox-feedback.js +2 -2
- package/build/tools/get-dropbox-folders.js +1 -1
- package/build/tools/get-dropbox-submissions.js +1 -1
- package/build/tools/get-dropbox-user-submissions.js +1 -1
- package/build/tools/get-my-grades.js +1 -1
- package/build/tools/get-rubrics-for-object.js +1 -1
- package/build/tools/get-syllabus.js +5 -5
- package/build/tools/get-video-transcript.js +14 -2
- package/build/tools/schemas.js +1 -1
- package/build/tools/tool-helpers.js +8 -2
- package/build/utils/config.js +8 -3
- package/build/utils/course-files.js +68 -12
- package/build/utils/dev-activity.js +13 -2
- package/build/utils/error-kind.js +47 -0
- package/build/utils/startup-phase.js +37 -0
- package/package.json +1 -1
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import * as fs from "node:fs/promises";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
10
|
+
import { acquireProcessLock, AuthenticationInProgressError } from "./auth/auth-lock.js";
|
|
11
|
+
import { log } from "./utils/logger.js";
|
|
12
|
+
/** How long a shared runtime waits with no request in progress before it stops. */
|
|
13
|
+
export const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
14
|
+
/** How long a starting runtime waits for a runtime that is starting or stopping to settle. */
|
|
15
|
+
const CLAIM_WAIT_MS = 10_000;
|
|
16
|
+
const STATE_FILE = "state.json";
|
|
17
|
+
const OWNER_LOCK = "owner.lock";
|
|
18
|
+
/**
|
|
19
|
+
* The directory one shared runtime lives in: one per account (inside its
|
|
20
|
+
* session directory), settings, and package version. Two chats share a
|
|
21
|
+
* runtime only when they would have built the same one themselves, and an
|
|
22
|
+
* upgraded package starts its own instead of talking to old code.
|
|
23
|
+
*
|
|
24
|
+
* The saved password and authenticator key play no part: the relay that picks
|
|
25
|
+
* the runtime loads its settings without reading the credential store (see
|
|
26
|
+
* loadConfig), and every sign-in reads them itself. A pasted access token or
|
|
27
|
+
* cookie is a high-entropy secret and is hashed like any other setting.
|
|
28
|
+
*/
|
|
29
|
+
export function runtimeDirectory(config, version) {
|
|
30
|
+
const { password: _password, totpUri: _totpUri, legacyBrowserStateMigrated: _perRun, ...settings } = config;
|
|
31
|
+
const identity = JSON.stringify({ version, settings });
|
|
32
|
+
const key = createHash("sha256").update(identity).digest("hex").slice(0, 32);
|
|
33
|
+
return path.join(config.sessionDir, "shared-runtime", key);
|
|
34
|
+
}
|
|
35
|
+
/** The published address in `dir`, or undefined when no runtime has published one. */
|
|
36
|
+
export async function readRuntimeAddress(dir) {
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(await fs.readFile(path.join(dir, STATE_FILE), "utf-8"));
|
|
39
|
+
if (typeof parsed.pid === "number" && typeof parsed.url === "string" && typeof parsed.token === "string") {
|
|
40
|
+
return { pid: parsed.pid, url: parsed.url, token: parsed.token };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
// Missing, or caught mid-write by a reader; either way, nothing to use.
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Serve `runtime` as the one shared runtime for `dir`, or resolve undefined
|
|
50
|
+
* when another runtime already serves it. Concurrent starts converge: the
|
|
51
|
+
* owner lock admits one, and the rest return once its address is published.
|
|
52
|
+
*
|
|
53
|
+
* The winner listens on a free 127.0.0.1 port behind a fresh random bearer
|
|
54
|
+
* token, publishes both in a 0600 state file, and stops after `idleMs` with
|
|
55
|
+
* no request in progress. A call waiting on MFA approval is a request in
|
|
56
|
+
* progress, so idleness never cuts one off. It unpublishes its address before
|
|
57
|
+
* it stops listening, so a chat arriving during shutdown starts a successor
|
|
58
|
+
* rather than finding a dead address.
|
|
59
|
+
*/
|
|
60
|
+
export async function serveSharedRuntime(runtime, { dir, idleMs }) {
|
|
61
|
+
const releaseLock = await claimOwnership(dir);
|
|
62
|
+
if (!releaseLock)
|
|
63
|
+
return undefined;
|
|
64
|
+
const stateFile = path.join(dir, STATE_FILE);
|
|
65
|
+
const token = randomBytes(32).toString("hex");
|
|
66
|
+
let idleTimer;
|
|
67
|
+
let stopping;
|
|
68
|
+
let server;
|
|
69
|
+
let markStopped;
|
|
70
|
+
const stopped = new Promise((done) => { markStopped = done; });
|
|
71
|
+
const stop = () => {
|
|
72
|
+
stopping ??= (async () => {
|
|
73
|
+
clearTimeout(idleTimer);
|
|
74
|
+
await fs.unlink(stateFile).catch(() => { });
|
|
75
|
+
await server?.close();
|
|
76
|
+
await releaseLock();
|
|
77
|
+
markStopped();
|
|
78
|
+
})();
|
|
79
|
+
return stopping;
|
|
80
|
+
};
|
|
81
|
+
const onActiveChange = (active) => {
|
|
82
|
+
clearTimeout(idleTimer);
|
|
83
|
+
if (active > 0 || stopping)
|
|
84
|
+
return;
|
|
85
|
+
idleTimer = setTimeout(() => {
|
|
86
|
+
log("INFO", `Shared runtime idle for ${Math.round(idleMs / 1000)}s; stopping`);
|
|
87
|
+
void stop();
|
|
88
|
+
}, idleMs);
|
|
89
|
+
};
|
|
90
|
+
try {
|
|
91
|
+
// Loaded here, not at the top: the relay in every chat imports this
|
|
92
|
+
// module for runtimeDirectory and must not pull in the server with it.
|
|
93
|
+
const { startHttpServer } = await import("./http-server.js");
|
|
94
|
+
server = await startHttpServer({ runtime, token, port: 0, onActiveChange });
|
|
95
|
+
const address = { pid: process.pid, url: server.url, token };
|
|
96
|
+
// Write then rename, so a reader never sees half a file.
|
|
97
|
+
const partial = `${stateFile}.${process.pid}.tmp`;
|
|
98
|
+
await fs.writeFile(partial, JSON.stringify(address), { mode: 0o600 });
|
|
99
|
+
await fs.rename(partial, stateFile);
|
|
100
|
+
onActiveChange(0);
|
|
101
|
+
return { address, stopped, stop };
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
await stop();
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Take the owner lock for `dir`, waiting out a runtime that is starting or
|
|
110
|
+
* stopping. undefined when another live runtime has published its address.
|
|
111
|
+
*/
|
|
112
|
+
async function claimOwnership(dir) {
|
|
113
|
+
const deadline = Date.now() + CLAIM_WAIT_MS;
|
|
114
|
+
for (;;) {
|
|
115
|
+
try {
|
|
116
|
+
return await acquireProcessLock(path.join(dir, OWNER_LOCK), { mode: "exclusive" });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
if (!(error instanceof AuthenticationInProgressError))
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
if (await readRuntimeAddress(dir) || Date.now() > deadline)
|
|
123
|
+
return undefined;
|
|
124
|
+
await delay(50);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
@@ -15,7 +15,7 @@ import fs from "node:fs/promises";
|
|
|
15
15
|
/** The refusal for a file over the disk download cap. */
|
|
16
16
|
function tooLargeResponse(bytes) {
|
|
17
17
|
return errorResponse(`File too large (${Math.round(bytes / 1024 / 1024)}MB). ` +
|
|
18
|
-
`Maximum allowed: ${DISK_MAX_FILE_SIZE / 1024 / 1024}MB
|
|
18
|
+
`Maximum allowed: ${DISK_MAX_FILE_SIZE / 1024 / 1024}MB`, "rejectedFile");
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Register download_dropbox_submission_file tool.
|
|
@@ -38,17 +38,17 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
38
38
|
validateContentId(submissionId);
|
|
39
39
|
validateContentId(fileId);
|
|
40
40
|
if (!path.isAbsolute(downloadPath)) {
|
|
41
|
-
return errorResponse("downloadPath must be an absolute path (e.g., /Users/username/Downloads or C:\\Users\\username\\Downloads)");
|
|
41
|
+
return errorResponse("downloadPath must be an absolute path (e.g., /Users/username/Downloads or C:\\Users\\username\\Downloads)", "invalidInput");
|
|
42
42
|
}
|
|
43
43
|
try {
|
|
44
44
|
const stats = await fs.stat(downloadPath);
|
|
45
45
|
if (!stats.isDirectory()) {
|
|
46
|
-
return errorResponse(`downloadPath is not a directory: ${downloadPath}
|
|
46
|
+
return errorResponse(`downloadPath is not a directory: ${downloadPath}`, "destination");
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
catch (error) {
|
|
50
50
|
if (error?.code === "ENOENT") {
|
|
51
|
-
return errorResponse(`Download directory does not exist: ${downloadPath}
|
|
51
|
+
return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
|
|
52
52
|
}
|
|
53
53
|
throw error;
|
|
54
54
|
}
|
|
@@ -61,21 +61,21 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
61
61
|
const submission = allSubmissions.find((s) => s.Id === submissionId);
|
|
62
62
|
if (!submission) {
|
|
63
63
|
return errorResponse(`Submission ${submissionId} not found in folder ${folderId}. ` +
|
|
64
|
-
"Use get_dropbox_submissions to list available submissions.");
|
|
64
|
+
"Use get_dropbox_submissions to list available submissions.", "notFound");
|
|
65
65
|
}
|
|
66
66
|
targetFile = submission.Files.find((f) => f.FileId === fileId) ?? null;
|
|
67
67
|
if (!targetFile) {
|
|
68
68
|
const available = submission.Files.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ");
|
|
69
|
-
return errorResponse(`File ID ${fileId} not found in submission ${submissionId}. Available files: ${available}
|
|
69
|
+
return errorResponse(`File ID ${fileId} not found in submission ${submissionId}. Available files: ${available}`, "notFound");
|
|
70
70
|
}
|
|
71
71
|
}
|
|
72
72
|
catch (error) {
|
|
73
73
|
if (error instanceof ApiError && error.status === 403) {
|
|
74
74
|
return errorResponse("Instructor or TA access required for this course. This tool downloads another user's " +
|
|
75
|
-
"dropbox submission file, which Brightspace only grants to course staff.");
|
|
75
|
+
"dropbox submission file, which Brightspace only grants to course staff.", "permission");
|
|
76
76
|
}
|
|
77
77
|
if (error instanceof ApiError && error.status === 404) {
|
|
78
|
-
return errorResponse(`Folder ${folderId} not found in course ${courseId}
|
|
78
|
+
return errorResponse(`Folder ${folderId} not found in course ${courseId}.`, "notFound");
|
|
79
79
|
}
|
|
80
80
|
throw error;
|
|
81
81
|
}
|
|
@@ -90,10 +90,10 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
|
|
|
90
90
|
}
|
|
91
91
|
catch (error) {
|
|
92
92
|
if (error instanceof ApiError && error.status === 403) {
|
|
93
|
-
return errorResponse("Instructor or TA access required for this course. Brightspace refused this download.");
|
|
93
|
+
return errorResponse("Instructor or TA access required for this course. Brightspace refused this download.", "permission");
|
|
94
94
|
}
|
|
95
95
|
if (error instanceof ApiError && error.status === 404) {
|
|
96
|
-
return errorResponse("File not found on the server. It may have been deleted.");
|
|
96
|
+
return errorResponse("File not found on the server. It may have been deleted.", "notFound");
|
|
97
97
|
}
|
|
98
98
|
throw error;
|
|
99
99
|
}
|
|
@@ -32,7 +32,7 @@ function tooLargeResponse(bytes, downloadPath) {
|
|
|
32
32
|
: "";
|
|
33
33
|
// null: the body was cut off at the cap, so its full size is unknown.
|
|
34
34
|
const size = bytes === null ? "over the limit" : `${Math.round(bytes / 1024 / 1024)}MB`;
|
|
35
|
-
return errorResponse(`File too large (${size}). Maximum allowed: ${maxFileSize(downloadPath) / 1024 / 1024}MB.${hint}
|
|
35
|
+
return errorResponse(`File too large (${size}). Maximum allowed: ${maxFileSize(downloadPath) / 1024 / 1024}MB.${hint}`, "rejectedFile");
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* Maximum bytes of a file we'll embed inline in a tool response. Base64
|
|
@@ -108,7 +108,7 @@ function truncateForInline(text, max) {
|
|
|
108
108
|
async function respondInline(buffer, originalFilename, customFilename) {
|
|
109
109
|
const effectiveFilename = customFilename || originalFilename;
|
|
110
110
|
if (buffer.length > INLINE_MAX_SIZE) {
|
|
111
|
-
return errorResponse(`File too large for inline delivery (${Math.round(buffer.length / 1024 / 1024)}MB, inline max ${INLINE_MAX_SIZE / 1024 / 1024}MB). Provide an absolute downloadPath to save it to disk instead
|
|
111
|
+
return errorResponse(`File too large for inline delivery (${Math.round(buffer.length / 1024 / 1024)}MB, inline max ${INLINE_MAX_SIZE / 1024 / 1024}MB). Provide an absolute downloadPath to save it to disk instead.`, "rejectedFile");
|
|
112
112
|
}
|
|
113
113
|
// Enforces the same magic-byte allowlist as disk mode (secureStreamDownload calls
|
|
114
114
|
// validateFileTypeOfFile internally); inline mode never writes to disk, so it has
|
|
@@ -275,10 +275,10 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
275
275
|
// Parse and validate input
|
|
276
276
|
const { courseId, topicId, coursePath, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
|
|
277
277
|
if (coursePath !== undefined && (topicId !== undefined || folderId !== undefined || newsId !== undefined || fileId !== undefined || source !== undefined)) {
|
|
278
|
-
return errorResponse("coursePath cannot be combined with topicId, folderId, newsId, fileId, or source.");
|
|
278
|
+
return errorResponse("coursePath cannot be combined with topicId, folderId, newsId, fileId, or source.", "invalidInput");
|
|
279
279
|
}
|
|
280
280
|
if (source !== undefined && (topicId !== undefined || newsId !== undefined || folderId === undefined || fileId === undefined)) {
|
|
281
|
-
return errorResponse("source requires folderId and fileId and cannot be combined with topicId or newsId.");
|
|
281
|
+
return errorResponse("source requires folderId and fileId and cannot be combined with topicId or newsId.", "invalidInput");
|
|
282
282
|
}
|
|
283
283
|
// Validate courseId
|
|
284
284
|
validateContentId(courseId);
|
|
@@ -288,18 +288,18 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
288
288
|
if (downloadPath !== undefined) {
|
|
289
289
|
// Validate download path is absolute
|
|
290
290
|
if (!path.isAbsolute(downloadPath)) {
|
|
291
|
-
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)");
|
|
291
|
+
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)", "invalidInput");
|
|
292
292
|
}
|
|
293
293
|
// Validate download directory exists and is a directory
|
|
294
294
|
try {
|
|
295
295
|
const stats = await fs.stat(downloadPath);
|
|
296
296
|
if (!stats.isDirectory()) {
|
|
297
|
-
return errorResponse(`Download path is not a directory: ${downloadPath}
|
|
297
|
+
return errorResponse(`Download path is not a directory: ${downloadPath}`, "destination");
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
300
|
catch (error) {
|
|
301
301
|
if (error?.code === "ENOENT") {
|
|
302
|
-
return errorResponse(`Download directory does not exist: ${downloadPath}
|
|
302
|
+
return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
|
|
303
303
|
}
|
|
304
304
|
throw error;
|
|
305
305
|
}
|
|
@@ -308,7 +308,7 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
308
308
|
if (coursePath !== undefined) {
|
|
309
309
|
const filePath = parseCoursePath(coursePath, courseId, apiClient.origin);
|
|
310
310
|
if (typeof filePath !== "string")
|
|
311
|
-
return errorResponse(filePath.error);
|
|
311
|
+
return errorResponse(filePath.error, "invalidInput");
|
|
312
312
|
return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename);
|
|
313
313
|
}
|
|
314
314
|
else if (topicId !== undefined) {
|
|
@@ -329,7 +329,7 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
329
329
|
return await downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename);
|
|
330
330
|
}
|
|
331
331
|
else {
|
|
332
|
-
return errorResponse("Either topicId (for content files), coursePath (for course files linked from a module description), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
|
|
332
|
+
return errorResponse("Either topicId (for content files), coursePath (for course files linked from a module description), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided", "invalidInput");
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
catch (error) {
|
|
@@ -347,7 +347,7 @@ async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, c
|
|
|
347
347
|
log("INFO", `Downloading course file: courseId=${courseId}, path=${filePath}`);
|
|
348
348
|
const response = await apiClient.getPageRaw(`${QUICKLINK_PATH}?ou=${courseId}&type=coursefile&fileId=${encodeURIComponent(filePath)}`);
|
|
349
349
|
if (!response) {
|
|
350
|
-
return errorResponse(`Course files are served only to a signed-in Brightspace web session, and there is no valid one: the session cookie is missing or has expired. Sign in again with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser
|
|
350
|
+
return errorResponse(`Course files are served only to a signed-in Brightspace web session, and there is no valid one: the session cookie is missing or has expired. Sign in again with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser.`, "auth");
|
|
351
351
|
}
|
|
352
352
|
// A missing file or a refused one can come back as an ordinary Brightspace
|
|
353
353
|
// web page with HTTP 200. That page is not the file, so it is never saved
|
|
@@ -367,11 +367,11 @@ async function downloadAssignmentAttachment(apiClient, courseId, folderId, fileI
|
|
|
367
367
|
const folders = Array.isArray(raw) ? raw : raw?.Objects ?? [];
|
|
368
368
|
const folder = folders.find((candidate) => candidate.Id === folderId && candidate.IsHidden !== true);
|
|
369
369
|
if (!folder)
|
|
370
|
-
return errorResponse(`No visible assignment with id ${folderId} in course ${courseId}
|
|
370
|
+
return errorResponse(`No visible assignment with id ${folderId} in course ${courseId}.`, "notFound");
|
|
371
371
|
const attachments = folder.Attachments ?? [];
|
|
372
372
|
const file = attachments.find((candidate) => candidate.FileId === fileId);
|
|
373
373
|
if (!file)
|
|
374
|
-
return errorResponse(`File ID ${fileId} not found on this assignment. Available files: ${attachments.map((candidate) => `${candidate.FileName} (ID: ${candidate.FileId})`).join(", ")}
|
|
374
|
+
return errorResponse(`File ID ${fileId} not found on this assignment. Available files: ${attachments.map((candidate) => `${candidate.FileName} (ID: ${candidate.FileId})`).join(", ")}`, "notFound");
|
|
375
375
|
if (file.Size > maxFileSize(downloadPath))
|
|
376
376
|
return tooLargeResponse(file.Size, downloadPath);
|
|
377
377
|
const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`));
|
|
@@ -442,7 +442,7 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
|
|
|
442
442
|
const submissionsPath = apiClient.le(courseId, `/dropbox/folders/${folderId}/submissions/mysubmissions/`);
|
|
443
443
|
const submissions = await apiClient.get(submissionsPath);
|
|
444
444
|
if (!submissions || submissions.length === 0) {
|
|
445
|
-
return errorResponse("No submissions found for this assignment. Upload a submission first.");
|
|
445
|
+
return errorResponse("No submissions found for this assignment. Upload a submission first.", "notFound");
|
|
446
446
|
}
|
|
447
447
|
// Find the file across every submission.
|
|
448
448
|
//
|
|
@@ -463,7 +463,7 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
|
|
|
463
463
|
}
|
|
464
464
|
if (!submission || !file) {
|
|
465
465
|
const available = submissions.flatMap((s) => s.Files ?? []);
|
|
466
|
-
return errorResponse(`File ID ${fileId} not found in submission. Available files: ${available.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}
|
|
466
|
+
return errorResponse(`File ID ${fileId} not found in submission. Available files: ${available.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`, "notFound");
|
|
467
467
|
}
|
|
468
468
|
// Check file size before downloading
|
|
469
469
|
if (file.Size > maxFileSize(downloadPath)) {
|
|
@@ -486,7 +486,7 @@ async function downloadNewsAttachment(apiClient, courseId, newsId, fileId, downl
|
|
|
486
486
|
const attachments = newsItem?.Attachments ?? [];
|
|
487
487
|
const file = attachments.find((f) => f.FileId === fileId);
|
|
488
488
|
if (!file) {
|
|
489
|
-
return errorResponse(`File ID ${fileId} not found on this announcement. Available files: ${attachments.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}
|
|
489
|
+
return errorResponse(`File ID ${fileId} not found on this announcement. Available files: ${attachments.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`, "notFound");
|
|
490
490
|
}
|
|
491
491
|
if (file.Size > maxFileSize(downloadPath)) {
|
|
492
492
|
return tooLargeResponse(file.Size, downloadPath);
|
|
@@ -28,7 +28,7 @@ export function registerGetCalendarEvents(server, apiClient, config) {
|
|
|
28
28
|
if (windowEnd < windowStart) {
|
|
29
29
|
return errorResponse(from
|
|
30
30
|
? `to (${to}) is before from (${from}). Pass a to that is on or after from.`
|
|
31
|
-
: `to (${to}) is in the past, and from defaults to now. Pass from as well to look at past events
|
|
31
|
+
: `to (${to}) is in the past, and from defaults to now. Pass from as well to look at past events.`, "invalidInput");
|
|
32
32
|
}
|
|
33
33
|
const courses = await resolveCourses(apiClient, config, courseId);
|
|
34
34
|
const results = await Promise.allSettled(courses.map((course) => fetchCourseCalendarEvents(apiClient, config.baseUrl, course, windowStart, windowEnd)));
|
|
@@ -43,7 +43,7 @@ export function registerGetDiscussions(server, apiClient) {
|
|
|
43
43
|
const { courseId, forumId, topicId } = GetDiscussionsSchema.parse(args);
|
|
44
44
|
// topicId requires forumId
|
|
45
45
|
if (topicId !== undefined && forumId === undefined) {
|
|
46
|
-
return errorResponse("topicId requires forumId. Provide both forumId and topicId to get posts for a specific topic.");
|
|
46
|
+
return errorResponse("topicId requires forumId. Provide both forumId and topicId to get posts for a specific topic.", "invalidInput");
|
|
47
47
|
}
|
|
48
48
|
// Specific topic — get posts
|
|
49
49
|
if (forumId !== undefined && topicId !== undefined) {
|
|
@@ -32,7 +32,7 @@ export function registerGetDropboxFeedback(server, apiClient) {
|
|
|
32
32
|
// lets a student read once it is released.
|
|
33
33
|
const own = parsed.entityId === undefined;
|
|
34
34
|
if (own && parsed.entityType === "group") {
|
|
35
|
-
return errorResponse("entityId is required when entityType is group.");
|
|
35
|
+
return errorResponse("entityId is required when entityType is group.", "invalidInput");
|
|
36
36
|
}
|
|
37
37
|
const entityType = parsed.entityType ?? "user";
|
|
38
38
|
const entityId = parsed.entityId ?? await fetchCurrentUserId(apiClient);
|
|
@@ -47,7 +47,7 @@ export function registerGetDropboxFeedback(server, apiClient) {
|
|
|
47
47
|
? "Brightspace refused this read of your own feedback; it has not been released yet, or " +
|
|
48
48
|
"this course does not expose it through the API. Check the assignment in Brightspace."
|
|
49
49
|
: "Instructor or TA access required for this course. Reading another user's grading " +
|
|
50
|
-
"feedback is only granted to course staff; to read your own, omit entityType and entityId.");
|
|
50
|
+
"feedback is only granted to course staff; to read your own, omit entityType and entityId.", "permission");
|
|
51
51
|
}
|
|
52
52
|
if (error instanceof ApiError && error.status === 404) {
|
|
53
53
|
return toolResponse({
|
|
@@ -45,7 +45,7 @@ export function registerGetDropboxFolders(server, apiClient) {
|
|
|
45
45
|
catch (error) {
|
|
46
46
|
if (error instanceof ApiError && error.status === 403) {
|
|
47
47
|
return errorResponse("Instructor or TA access required for this course. This tool reads every student's " +
|
|
48
|
-
"dropbox folder metadata, which Brightspace only grants to course staff.");
|
|
48
|
+
"dropbox folder metadata, which Brightspace only grants to course staff.", "permission");
|
|
49
49
|
}
|
|
50
50
|
if (error instanceof ApiError && error.status === 404) {
|
|
51
51
|
return toolResponse({
|
|
@@ -55,7 +55,7 @@ export function registerGetDropboxSubmissions(server, apiClient) {
|
|
|
55
55
|
catch (error) {
|
|
56
56
|
if (error instanceof ApiError && error.status === 403) {
|
|
57
57
|
return errorResponse("Instructor or TA access required for this course. This tool reads every student's " +
|
|
58
|
-
"dropbox submissions, which Brightspace only grants to course staff.");
|
|
58
|
+
"dropbox submissions, which Brightspace only grants to course staff.", "permission");
|
|
59
59
|
}
|
|
60
60
|
if (error instanceof ApiError && error.status === 404) {
|
|
61
61
|
return toolResponse({
|
|
@@ -32,7 +32,7 @@ export function registerGetDropboxUserSubmissions(server, apiClient) {
|
|
|
32
32
|
catch (error) {
|
|
33
33
|
if (error instanceof ApiError && error.status === 403) {
|
|
34
34
|
return errorResponse("Instructor or TA access required for this course. This tool reads another user's " +
|
|
35
|
-
"dropbox submissions, which Brightspace only grants to course staff.");
|
|
35
|
+
"dropbox submissions, which Brightspace only grants to course staff.", "permission");
|
|
36
36
|
}
|
|
37
37
|
if (error instanceof ApiError && error.status === 404) {
|
|
38
38
|
return toolResponse({
|
|
@@ -106,7 +106,7 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
106
106
|
const url = gradebookUrl(config.baseUrl, courseId);
|
|
107
107
|
log("INFO", `get_my_grades: 403 for course ${courseId} - grade API access restricted`);
|
|
108
108
|
return errorResponse("Your institution restricts grade API access for this course. " +
|
|
109
|
-
`View your grades directly in Brightspace: ${url}
|
|
109
|
+
`View your grades directly in Brightspace: ${url}`, "permission");
|
|
110
110
|
}
|
|
111
111
|
// A pending sign-in is not an empty gradebook: the route never
|
|
112
112
|
// answered, so the result says so instead of reporting zero grades
|
|
@@ -32,7 +32,7 @@ export function registerGetRubricsForObject(server, apiClient) {
|
|
|
32
32
|
catch (error) {
|
|
33
33
|
if (error instanceof ApiError && error.status === 403) {
|
|
34
34
|
return errorResponse("Instructor or TA access required for this course. This tool reads dropbox rubric " +
|
|
35
|
-
"metadata, which Brightspace only grants to course staff.");
|
|
35
|
+
"metadata, which Brightspace only grants to course staff.", "permission");
|
|
36
36
|
}
|
|
37
37
|
if (error instanceof ApiError && error.status === 404) {
|
|
38
38
|
return toolResponse({
|
|
@@ -119,17 +119,17 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
119
119
|
// Validate downloadPath if provided
|
|
120
120
|
if (downloadPath !== undefined) {
|
|
121
121
|
if (!path.isAbsolute(downloadPath)) {
|
|
122
|
-
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)");
|
|
122
|
+
return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)", "invalidInput");
|
|
123
123
|
}
|
|
124
124
|
try {
|
|
125
125
|
const stats = await fs.stat(downloadPath);
|
|
126
126
|
if (!stats.isDirectory()) {
|
|
127
|
-
return errorResponse(`Download path is not a directory: ${downloadPath}
|
|
127
|
+
return errorResponse(`Download path is not a directory: ${downloadPath}`, "destination");
|
|
128
128
|
}
|
|
129
129
|
}
|
|
130
130
|
catch (error) {
|
|
131
131
|
if (error?.code === "ENOENT") {
|
|
132
|
-
return errorResponse(`Download directory does not exist: ${downloadPath}
|
|
132
|
+
return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
|
|
133
133
|
}
|
|
134
134
|
throw error;
|
|
135
135
|
}
|
|
@@ -169,7 +169,7 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
169
169
|
// Check Content-Length before downloading body
|
|
170
170
|
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
171
171
|
if (contentLength > MAX_FILE_SIZE) {
|
|
172
|
-
return errorResponse(`Attachment too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB
|
|
172
|
+
return errorResponse(`Attachment too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`, "rejectedFile");
|
|
173
173
|
}
|
|
174
174
|
// Get filename from Content-Disposition header
|
|
175
175
|
const disposition = response.headers.get("Content-Disposition") ?? "";
|
|
@@ -182,7 +182,7 @@ export function registerGetSyllabus(server, apiClient) {
|
|
|
182
182
|
}
|
|
183
183
|
catch (error) {
|
|
184
184
|
if (error instanceof DownloadError && error.kind === "tooLarge") {
|
|
185
|
-
return errorResponse(`Attachment too large (over ${MAX_FILE_SIZE / 1024 / 1024}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB
|
|
185
|
+
return errorResponse(`Attachment too large (over ${MAX_FILE_SIZE / 1024 / 1024}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`, "rejectedFile");
|
|
186
186
|
}
|
|
187
187
|
throw error;
|
|
188
188
|
}
|
|
@@ -53,6 +53,18 @@ async function followLtiLaunch(apiClient, path) {
|
|
|
53
53
|
* worked around here: the way past it is the user's own browser, and the
|
|
54
54
|
* result says plainly that nothing was read.
|
|
55
55
|
*/
|
|
56
|
+
/** The dev activity log's category for a transcript the platform would not serve. */
|
|
57
|
+
function transcriptErrorKind(reason) {
|
|
58
|
+
switch (reason) {
|
|
59
|
+
case "sign_in_required":
|
|
60
|
+
case "access_blocked":
|
|
61
|
+
return "providerBlocked";
|
|
62
|
+
case "unavailable":
|
|
63
|
+
return "notFound";
|
|
64
|
+
case "fetch_failed":
|
|
65
|
+
return "transport";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
56
68
|
function transcriptRecovery(reason, videoUrl) {
|
|
57
69
|
switch (reason) {
|
|
58
70
|
case "sign_in_required":
|
|
@@ -158,7 +170,7 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
|
|
|
158
170
|
resolvedUrl = resolved.url;
|
|
159
171
|
}
|
|
160
172
|
else {
|
|
161
|
-
return errorResponse("Provide either videoUrl, or both courseId and topicId to look up the video from course content.");
|
|
173
|
+
return errorResponse("Provide either videoUrl, or both courseId and topicId to look up the video from course content.", "invalidInput");
|
|
162
174
|
}
|
|
163
175
|
// A relative /d2l/ link, or an absolute one on this Brightspace's own
|
|
164
176
|
// origin, is requested as the user; any other origin never sees the session.
|
|
@@ -209,7 +221,7 @@ export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch)
|
|
|
209
221
|
if ("failed" in outcome) {
|
|
210
222
|
// The message stays the first block, as it always was; the second
|
|
211
223
|
// carries the same failure in a shape a caller can branch on.
|
|
212
|
-
const failure = errorResponse(outcome.failed);
|
|
224
|
+
const failure = errorResponse(outcome.failed, transcriptErrorKind(outcome.reason));
|
|
213
225
|
failure.content.push({
|
|
214
226
|
type: "text",
|
|
215
227
|
text: JSON.stringify({
|
package/build/tools/schemas.js
CHANGED
|
@@ -70,7 +70,7 @@ export const DownloadFileSchema = z.object({
|
|
|
70
70
|
topicId: z.coerce.number().int().positive().optional()
|
|
71
71
|
.describe("Content topic ID to download (for course content files)."),
|
|
72
72
|
coursePath: z.string().min(1).max(1024).optional()
|
|
73
|
-
.describe("A file in the course's file store, for files an instructor linked inside a module or page description instead of adding as a content topic (get_course_content lists them as courseFiles on the module). Pass one of those paths (e.g. 'Notes/Lecture 1.pdf')
|
|
73
|
+
.describe("A file in the course's file store, for files an instructor linked inside a module or page description instead of adding as a content topic (get_course_content lists them as courseFiles on the module). Pass one of those paths (e.g. 'Notes/Lecture 1.pdf'), the whole quickLink URL, or a '/content/enforced/<course folder>/...' link exactly as another tool (e.g. get_assignments) returned it. Not combined with topicId, folderId, or newsId."),
|
|
74
74
|
folderId: z.coerce.number().int().positive().optional()
|
|
75
75
|
.describe("Dropbox folder ID. Downloads your submission by default; set source to assignmentAttachment for an instructor-provided handout."),
|
|
76
76
|
fileId: z.coerce.number().int().positive().optional()
|
|
@@ -12,6 +12,7 @@ import { AUTH_COMMAND } from "../utils/commands.js";
|
|
|
12
12
|
import { getUpdateNotice } from "../utils/update-checker.js";
|
|
13
13
|
import { mfaShortcutTip } from "../utils/two-factor-shortcuts.js";
|
|
14
14
|
import { DownloadError, isSafeDetail, } from "../utils/download-errors.js";
|
|
15
|
+
import { tagErrorKind, withErrorKind } from "../utils/error-kind.js";
|
|
15
16
|
/**
|
|
16
17
|
* Append an available-update notice to an already-built content array and
|
|
17
18
|
* wrap it as a CallToolResult. This is the one place an update notice can
|
|
@@ -62,8 +63,8 @@ export function toolResponseWithNotice(data, notice) {
|
|
|
62
63
|
/**
|
|
63
64
|
* Wrap error message as MCP-compatible tool result
|
|
64
65
|
*/
|
|
65
|
-
export function errorResponse(message) {
|
|
66
|
-
|
|
66
|
+
export function errorResponse(message, kind) {
|
|
67
|
+
const result = {
|
|
67
68
|
content: [
|
|
68
69
|
{
|
|
69
70
|
type: "text",
|
|
@@ -72,6 +73,8 @@ export function errorResponse(message) {
|
|
|
72
73
|
],
|
|
73
74
|
isError: true,
|
|
74
75
|
};
|
|
76
|
+
// The dev activity log records the category, never the message.
|
|
77
|
+
return kind ? tagErrorKind(result, kind) : result;
|
|
75
78
|
}
|
|
76
79
|
/**
|
|
77
80
|
* What to tell the user for each way an automatic sign-in can fail.
|
|
@@ -228,6 +231,9 @@ export function authPendingNotice(error) {
|
|
|
228
231
|
export function sanitizeError(error) {
|
|
229
232
|
// Log full error to stderr for debugging (token redaction handled by logger)
|
|
230
233
|
log("ERROR", "Tool error", error);
|
|
234
|
+
return withErrorKind(describeError(error), error);
|
|
235
|
+
}
|
|
236
|
+
function describeError(error) {
|
|
231
237
|
// Authentication runs inside the tool call now, so its failures surface
|
|
232
238
|
// here rather than from a dedicated auth tool. Checked first: the kind
|
|
233
239
|
// carries guidance that the generic branches below would throw away.
|
package/build/utils/config.js
CHANGED
|
@@ -14,6 +14,7 @@ import { getStoredTotpUri } from "../auth/credential-store.js";
|
|
|
14
14
|
import { canonicalUsername } from "../auth/username.js";
|
|
15
15
|
import { normalizeTotpEnrollment } from "../auth/totp.js";
|
|
16
16
|
import { migrateLegacyState } from "../auth/legacy-state.js";
|
|
17
|
+
import { startupPhase } from "./startup-phase.js";
|
|
17
18
|
/**
|
|
18
19
|
* The saved settings, or null when there are none to use. A corrupt or
|
|
19
20
|
* permission-denied config.json must not take the whole server down at
|
|
@@ -77,7 +78,7 @@ export function resolveSessionLocation() {
|
|
|
77
78
|
const { sessionDir, sessionRoot } = resolveAccountLocation(readConfigStore());
|
|
78
79
|
return { sessionDir, sessionRoot };
|
|
79
80
|
}
|
|
80
|
-
export async function loadConfig() {
|
|
81
|
+
export async function loadConfig({ credentials = true } = {}) {
|
|
81
82
|
dotenv.config({ quiet: true });
|
|
82
83
|
const store = readConfigStore();
|
|
83
84
|
// Code-entry and other interactive MFA methods need a visible browser.
|
|
@@ -125,7 +126,9 @@ export async function loadConfig() {
|
|
|
125
126
|
const rawSessionCookie = readEnvSecret(process.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
|
|
126
127
|
const envSessionCookie = rawSessionCookie ? parseSessionCookieEnv(rawSessionCookie) : undefined;
|
|
127
128
|
const { baseUrl, username, sessionRoot, sessionDir } = resolveAccountLocation(store);
|
|
128
|
-
const password = passwordless
|
|
129
|
+
const password = passwordless || !credentials
|
|
130
|
+
? undefined
|
|
131
|
+
: await startupPhase("reading the saved password from the OS credential store", () => resolveStoredPassword(baseUrl, username, store));
|
|
129
132
|
// Opt-in: only an enrollment the user deliberately saved makes the Entra
|
|
130
133
|
// sign-in answer a verification-code challenge itself. With none, every MFA
|
|
131
134
|
// challenge is handled exactly as before. The keyring entry is per account;
|
|
@@ -139,7 +142,9 @@ export async function loadConfig() {
|
|
|
139
142
|
// an optional extra, and resolveStoredPassword above is already the place
|
|
140
143
|
// that reports a locked or missing store, loudly, for the credential the
|
|
141
144
|
// server genuinely cannot work without.
|
|
142
|
-
: username
|
|
145
|
+
: username && credentials
|
|
146
|
+
? (await startupPhase("reading the saved authenticator key from the OS credential store", () => getStoredTotpUri(baseUrl, username).catch(() => null))) ?? undefined
|
|
147
|
+
: undefined;
|
|
143
148
|
const legacyMigration = sessionDir !== sessionRoot ? await migrateLegacyState(sessionRoot) : undefined;
|
|
144
149
|
return {
|
|
145
150
|
baseUrl,
|