brightspace-mcp-server 5.0.13 → 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.
@@ -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({
@@ -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
- return {
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.
@@ -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 ? undefined : await resolveStoredPassword(baseUrl, username, store);
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 ? (await getStoredTotpUri(baseUrl, username).catch(() => null)) ?? undefined : undefined;
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,
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { appendFileSync, mkdirSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { AUTH_PHASES } from "../auth/auth-phases.js";
6
+ import { ERROR_KINDS, errorKind, resultErrorKind } from "./error-kind.js";
6
7
  const calls = new AsyncLocalStorage();
7
8
  const runId = randomUUID();
8
9
  const MAX_BYTES = 5 * 1024 * 1024;
@@ -42,6 +43,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
42
43
  record.reason = fields.reason;
43
44
  if (AUTH_PHASES.includes(fields.phase ?? ""))
44
45
  record.phase = fields.phase;
46
+ if (ERROR_KINDS.includes(fields.errorKind ?? ""))
47
+ record.errorKind = fields.errorKind;
45
48
  try {
46
49
  mkdirSync(directory, { recursive: true, mode: 0o700 });
47
50
  const file = join(directory, `activity-${at.slice(0, 10)}-${process.pid}-${runId}.jsonl`);
@@ -77,16 +80,24 @@ export function trackToolActivity(server) {
77
80
  lastStart = started;
78
81
  return calls.run(context, async () => {
79
82
  let outcome = "error";
83
+ let kind;
80
84
  try {
81
85
  const result = await callback(...args);
82
- if (!result?.isError) {
86
+ if (result?.isError) {
87
+ kind = resultErrorKind(result);
88
+ }
89
+ else {
83
90
  outcome = "success";
84
91
  lastSuccess = Date.now();
85
92
  }
86
93
  return result;
87
94
  }
95
+ catch (error) {
96
+ kind = errorKind(error);
97
+ throw error;
98
+ }
88
99
  finally {
89
- write("tool_finished", { outcome, elapsedMs: Date.now() - started });
100
+ write("tool_finished", { outcome, errorKind: kind, elapsedMs: Date.now() - started });
90
101
  }
91
102
  });
92
103
  })));