brightspace-mcp-server 5.1.5 → 5.1.7

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.
@@ -10,7 +10,7 @@ import * as path from "node:path";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { AuthError } from "../utils/errors.js";
12
12
  import { AUTH_COMMAND } from "../utils/commands.js";
13
- import { devActivity } from "../utils/dev-activity.js";
13
+ import { devActivity, newRecoveryId, RECOVERY_ID_ENV } from "../utils/dev-activity.js";
14
14
  import { parsePhaseMarker } from "./auth-phases.js";
15
15
  import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
16
16
  import { inspectLockOwner } from "./auth-lock.js";
@@ -693,7 +693,8 @@ export class AuthRunner {
693
693
  */
694
694
  async spawnAuth() {
695
695
  const started = Date.now();
696
- devActivity("recovery_started");
696
+ const recoveryId = newRecoveryId();
697
+ devActivity("recovery_started", { recoveryId });
697
698
  log("INFO", "Auto-launching brightspace-auth...");
698
699
  return await new Promise((resolve, reject) => {
699
700
  const child = spawn(process.execPath, // use the same Node binary
@@ -702,7 +703,8 @@ export class AuthRunner {
702
703
  // Background recovery never opens a browser over the user's work
703
704
  // unless D2L_HEADLESS explicitly asks for one; the saved
704
705
  // visible-browser preference applies to manual sign-in only.
705
- env: { D2L_HEADLESS: "true", ...process.env },
706
+ // The recovery id lets the child's dev activity join this recovery.
707
+ env: { D2L_HEADLESS: "true", ...process.env, [RECOVERY_ID_ENV]: recoveryId },
706
708
  stdio: ["ignore", "pipe", "pipe"],
707
709
  detached: process.platform !== "win32",
708
710
  windowsHide: true,
@@ -831,7 +833,7 @@ export class AuthRunner {
831
833
  const firstChallenge = this.pendingChallenge === null || this.pendingChallenge.kind === "automaticPending";
832
834
  startWatching();
833
835
  if (firstChallenge)
834
- devActivity("mfa_observed", { elapsedMs: Date.now() - started });
836
+ devActivity("mfa_observed", { elapsedMs: Date.now() - started, recoveryId });
835
837
  if (firstChallenge || matched) {
836
838
  this.pendingChallenge = { kind: "mfaPending", numberMatch: matched ?? this.pendingChallenge?.numberMatch };
837
839
  }
@@ -872,7 +874,7 @@ export class AuthRunner {
872
874
  if (childFinished)
873
875
  return;
874
876
  childFinished = true;
875
- devActivity("recovery_finished", { outcome: error ? "error" : "success", reason: error?.kind, elapsedMs: Date.now() - started });
877
+ devActivity("recovery_finished", { outcome: error ? "error" : "success", reason: error?.kind, elapsedMs: Date.now() - started, recoveryId });
876
878
  clearTimeout(timer);
877
879
  if (killTimer)
878
880
  clearTimeout(killTimer);
@@ -914,7 +916,7 @@ export class AuthRunner {
914
916
  const numberMarker = MFA_NUMBER_MARKER.exec(line);
915
917
  const phaseMarker = parsePhaseMarker(line);
916
918
  if (phaseMarker) {
917
- devActivity("auth_phase", phaseMarker);
919
+ devActivity("auth_phase", { ...phaseMarker, recoveryId });
918
920
  }
919
921
  else if (numberMarker) {
920
922
  numberMatch = numberMarker[1];
package/build/auth-cli.js CHANGED
@@ -59,7 +59,7 @@ async function main() {
59
59
  const config = await loadConfig();
60
60
  // The sign-in itself runs here, not in the server, so the dev activity log
61
61
  // only sees its MFA events if this process is configured for them too.
62
- configureDevActivity(config.sessionDir);
62
+ configureDevActivity(config.sessionDir, { version: pkg.version, role: "auth" });
63
63
  console.error(`\n=== Brightspace Authentication v${pkg.version} ===\n`);
64
64
  console.error(config.headless
65
65
  ? "Authentication runs headlessly. MFA numbers and code prompts appear here."
package/build/index.js CHANGED
@@ -96,7 +96,7 @@ else {
96
96
  const { setActiveShortcuts } = await import("./utils/two-factor-shortcuts.js");
97
97
  const { startUpdateChecks } = await import("./utils/update-checker.js");
98
98
  const { createRuntime } = await import("./server.js");
99
- configureDevActivity(config.sessionDir);
99
+ configureDevActivity(config.sessionDir, { version: PKG_VERSION, role: "server" });
100
100
  // Lets the MFA approval message mention the setup options only to users
101
101
  // who have neither turned on. Booleans only; the key is never passed.
102
102
  setActiveShortcuts({ rememberMfa: config.rememberMfa === true, keySaved: Boolean(config.totpUri) });
@@ -23,7 +23,8 @@ function tooLargeResponse(bytes) {
23
23
  export function registerDownloadDropboxSubmissionFile(server, apiClient) {
24
24
  server.registerTool("download_dropbox_submission_file", {
25
25
  title: "Download Dropbox Submission File",
26
- description: "Download a specific file from a student's (or group's) dropbox submission to a local directory, " +
26
+ description: "Download a specific file from a student's (or group's) dropbox submission to an existing local directory " +
27
+ "(it is not created for you), " +
27
28
  "for an instructor or TA. Use get_dropbox_submissions or get_dropbox_user_submissions first to " +
28
29
  "find submissionId and fileId. Requires instructor or TA access to the course. " +
29
30
  "IMPORTANT: Ask the user where they want to save the file before calling this tool. " +
@@ -48,7 +49,7 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
48
49
  }
49
50
  catch (error) {
50
51
  if (error?.code === "ENOENT") {
51
- return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
52
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
52
53
  }
53
54
  throw error;
54
55
  }
@@ -267,7 +267,7 @@ function extensionNote(requestedFilename, saved) {
267
267
  export function registerDownloadFile(server, apiClient) {
268
268
  server.registerTool("download_file", {
269
269
  title: "Download File",
270
- description: "Download a file from course content, files linked by path inside a module description (coursePath, from get_course_content's quickLink URLs with type=coursefile), 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).",
270
+ description: "Download a file from course content, files linked by path inside a module description (coursePath, from get_course_content's quickLink URLs with type=coursefile), 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. The directory must already exist; it is not created for you. 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).",
271
271
  inputSchema: DownloadFileSchema,
272
272
  }, async (args) => {
273
273
  try {
@@ -299,7 +299,7 @@ export function registerDownloadFile(server, apiClient) {
299
299
  }
300
300
  catch (error) {
301
301
  if (error?.code === "ENOENT") {
302
- return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
302
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
303
303
  }
304
304
  throw error;
305
305
  }
@@ -110,7 +110,7 @@ async function discoverExternalSyllabusSources(apiClient, courseId) {
110
110
  export function registerGetSyllabus(server, apiClient) {
111
111
  server.registerTool("get_syllabus", {
112
112
  title: "Get Course Syllabus",
113
- 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.",
113
+ 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); the directory must already exist, since it is not created for you. IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
114
114
  inputSchema: GetSyllabusSchema,
115
115
  }, async (args) => {
116
116
  try {
@@ -129,7 +129,7 @@ export function registerGetSyllabus(server, apiClient) {
129
129
  }
130
130
  catch (error) {
131
131
  if (error?.code === "ENOENT") {
132
- return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
132
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
133
133
  }
134
134
  throw error;
135
135
  }
@@ -78,7 +78,7 @@ export const DownloadFileSchema = z.object({
78
78
  newsId: z.coerce.number().int().positive().optional()
79
79
  .describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
80
80
  downloadPath: z.string().min(1).optional()
81
- .describe("Absolute path to the directory where the file should be saved on disk. Omit this to receive the file inline in the tool response instead — the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see files an MCP server writes to its own host filesystem. Inline mode returns extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, and a short description (pointing back to disk mode) for anything else, capped at 10MB and 400,000 characters of extracted text."),
81
+ .describe("Absolute path to an existing directory where the file should be saved on disk. The directory must already exist; it is not created for you, so create it first if needed. Omit this to receive the file inline in the tool response instead — the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see files an MCP server writes to its own host filesystem. Inline mode returns extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, and a short description (pointing back to disk mode) for anything else, capped at 10MB and 400,000 characters of extracted text."),
82
82
  customFilename: z.string().max(255).optional()
83
83
  .describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
84
84
  });
@@ -86,7 +86,7 @@ export const GetSyllabusSchema = z.object({
86
86
  courseId: z.coerce.number().int().positive()
87
87
  .describe("Course ID to get syllabus for."),
88
88
  downloadPath: z.string().min(1).optional()
89
- .describe("Absolute path to the directory where the attachment should be saved."),
89
+ .describe("Absolute path to an existing directory where the attachment should be saved. The directory must already exist; it is not created for you, so create it first if needed."),
90
90
  });
91
91
  export const GetDiscussionsSchema = z.object({
92
92
  courseId: z.coerce.number().int().positive()
@@ -219,7 +219,7 @@ export const DownloadDropboxSubmissionFileSchema = z.object({
219
219
  fileId: z.coerce.number().int().positive()
220
220
  .describe("File ID within that submission."),
221
221
  downloadPath: z.string().min(1)
222
- .describe("Absolute path to the local directory where the file should be saved."),
222
+ .describe("Absolute path to an existing local directory where the file should be saved. The directory must already exist; it is not created for you, so create it first if needed."),
223
223
  customFilename: z.string().max(255).optional()
224
224
  .describe("Custom filename for the downloaded file (include extension). If omitted, uses the original submission filename."),
225
225
  });
@@ -44,27 +44,40 @@ export function httpOperation(path) {
44
44
  const bare = path.split(/[?#]/, 1)[0];
45
45
  return OPERATION_PATTERNS.find(([, pattern]) => pattern.test(bare))?.[0] ?? "other";
46
46
  }
47
+ /** Names one background recovery; the parent passes it to its auth child so their events join. */
48
+ export const RECOVERY_ID_ENV = "D2L_RECOVERY_ID";
49
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
47
50
  const calls = new AsyncLocalStorage();
48
51
  const runId = randomUUID();
49
52
  const MAX_BYTES = 5 * 1024 * 1024;
50
53
  let directory;
51
54
  let warned = false;
55
+ /** Set in an auth child: the parent recovery every event of this process belongs to. */
56
+ let processRecoveryId;
52
57
  let lastStart;
53
58
  let lastSuccess;
54
59
  /** Explicit opt-in; separate from ordinary stderr debugging. */
55
- export function configureDevActivity(sessionDir) {
60
+ export function configureDevActivity(sessionDir, run) {
56
61
  directory = process.env.D2L_DEV_MODE === "true" ? join(sessionDir, "dev-activity") : undefined;
57
62
  lastStart = lastSuccess = undefined;
58
63
  warned = false;
64
+ const inherited = process.env[RECOVERY_ID_ENV];
65
+ processRecoveryId = inherited && ID.test(inherited) ? inherited : undefined;
66
+ if (run)
67
+ write("run_started", {}, undefined, undefined, undefined, run);
68
+ }
69
+ /** A fresh, non-secret id for one recovery; see RECOVERY_ID_ENV. */
70
+ export function newRecoveryId() {
71
+ return randomUUID();
59
72
  }
60
73
  /** No arbitrary text, errors, URLs, request arguments or response content. */
61
74
  export function devActivity(event, fields = {}) {
62
75
  write(event, fields);
63
76
  }
64
- function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs) {
77
+ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs, run) {
65
78
  if (!directory)
66
79
  return;
67
- if (!["tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
80
+ if (!["run_started", "tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
68
81
  "token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished", "auth_phase",
69
82
  "mfa_method_selected", "mfa_code_submitted"].includes(event))
70
83
  return;
@@ -72,6 +85,17 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
72
85
  const record = { at, runId, pid: process.pid, event };
73
86
  if (context)
74
87
  Object.assign(record, context);
88
+ // A child's inherited id wins: it is the one its parent recorded.
89
+ const recoveryId = processRecoveryId ?? fields.recoveryId;
90
+ if (recoveryId && ID.test(recoveryId))
91
+ record.recoveryId = recoveryId;
92
+ if (run) {
93
+ if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,40})?$/.test(run.version))
94
+ record.version = run.version;
95
+ record.node = process.versions.node;
96
+ if (run.role === "server" || run.role === "auth")
97
+ record.role = run.role;
98
+ }
75
99
  for (const [key, value] of Object.entries({ elapsedMs: fields.elapsedMs, status: fields.status, idleMs, sinceSuccessMs })) {
76
100
  if (typeof value === "number" && Number.isFinite(value) && value >= 0)
77
101
  record[key] = value;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.5",
3
+ "version": "5.1.7",
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",