brightspace-mcp-server 3.4.1 → 3.6.0

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 CHANGED
@@ -101,7 +101,9 @@ If you miss an MFA request, automatic browser authentication pauses for five min
101
101
  npx -y brightspace-mcp-server@latest auth
102
102
  ```
103
103
 
104
- **MFA at Purdue** commonly uses Microsoft Authenticator number matching: enter the terminal-displayed number on your phone. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them. Some desktop clients hide server logs, so use the terminal command above for interactive MFA.
104
+ Run it from your home folder. On macOS, a terminal that lacks Files and Folders permission (the terminal panel inside Claude Desktop, or a fresh editor terminal) cannot start `npx` from inside Documents, Desktop, or Downloads — it fails with `EPERM: process.cwd failed … uv_cwd` before the server runs. The same applies if your AI client launches the server with one of those folders as its working directory; grant the app access under System Settings → Privacy & Security → Files and Folders, or start the server elsewhere.
105
+
106
+ **MFA at Purdue** commonly uses Microsoft Authenticator number matching (some schools use Duo instead). When a sign-in needs it, the tool call itself returns quickly with the number to enter, rather than sitting silent for up to five minutes — approve it on your phone, then call the tool again; sign-in finishes in the background in the meantime. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them, useful if you don't see the number in the tool response for some reason.
105
107
 
106
108
  ## What You Can Ask About
107
109
 
@@ -112,7 +114,7 @@ npx -y brightspace-mcp-server@latest auth
112
114
  | Quizzes | "Which quizzes close this week?" · "Is Quiz 3 timed, and does it have a grace period?" |
113
115
  | Assignment files | "What does the lab 4 spec actually ask for?" · "Summarize the rubric attached to the project" |
114
116
  | Exams | "Is there a midterm in the gradebook that isn't on my assignments list?" |
115
- | Announcements | "Did any professor post something important today?" · "What did my CS prof announce this week?" · "Any announcements since last Monday?" |
117
+ | Announcements | "Did any professor post something important today?" · "What did my CS prof announce this week?" · "Any announcements since last Monday?" · "Read the file attached to today's announcement" · "Save the rubric my prof attached to that announcement" |
116
118
  | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "What's new in this course since I last checked?" |
117
119
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
118
120
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
@@ -103,9 +103,12 @@ export class D2LApiClient {
103
103
  this.cacheTTLs = { ...DEFAULT_CACHE_TTLS, ...options.cacheTTLs };
104
104
  // Initialize cache and rate limiter
105
105
  this.cache = new TTLCache();
106
+ // Sized for the per-course fan-out the tools do (a dozen courses is ~60
107
+ // requests); at 3/s that took ~17 s once the limiter actually reserved
108
+ // tokens. Brightspace 429s are still retried with Retry-After.
106
109
  const rateLimitConfig = options.rateLimitConfig ?? {
107
- capacity: 10,
108
- refillRate: 3,
110
+ capacity: 20,
111
+ refillRate: 8,
109
112
  };
110
113
  this.rateLimiter = new TokenBucket(rateLimitConfig.capacity, rateLimitConfig.refillRate);
111
114
  log("DEBUG", `D2LApiClient initialized for ${this.baseUrl}`);
@@ -3,8 +3,8 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
- // Token bucket rate limiter - allows bursts up to capacity
7
- // Conservative defaults: capacity 10, refill 3/sec
6
+ // Token bucket rate limiter - allows bursts up to capacity.
7
+ // Defaults live in D2LApiClient (client.ts).
8
8
  export class TokenBucket {
9
9
  tokens;
10
10
  lastRefill;
@@ -19,7 +19,7 @@ export class AuthenticationCooldownError extends Error {
19
19
  // stalled chat window nothing they can act on.
20
20
  const minutes = Math.max(1, Math.ceil((retryAt - Date.now()) / 60000));
21
21
  super(`The last sign-in prompt was not approved, so automatic sign-in is paused for ${minutes} more minute${minutes === 1 ? "" : "s"}. ` +
22
- `To sign in right now, run this in a terminal: ${AUTH_COMMAND}`);
22
+ `To sign in right now, run this in a terminal from your home folder: ${AUTH_COMMAND}`);
23
23
  this.retryAt = retryAt;
24
24
  this.name = "AuthenticationCooldownError";
25
25
  }
@@ -13,17 +13,36 @@ import { AUTH_COMMAND } from "../utils/commands.js";
13
13
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
14
14
  * which is five minutes: a person has to find their phone, unlock it, and read
15
15
  * a number off the screen. A shorter parent budget would kill the child in the
16
- * middle of a sign-in the user was still completing.
16
+ * middle of a sign-in the user was still completing. run() itself usually
17
+ * returns long before this fires — the moment the child reports an MFA
18
+ * challenge — but this timer keeps bounding the background child regardless.
17
19
  */
18
20
  const AUTH_TIMEOUT_MS = 8 * 60 * 1000;
19
21
  const KILL_GRACE_MS = 5000;
20
22
  /**
21
- * The only line auth-cli.ts is allowed to hand back as structured data.
22
- * Deliberately strict (1-3 digits, whole line) so this can never become a
23
- * channel for arbitrary child-process text to reach a tool response —
24
- * anything that doesn't match this exactly is just another log line.
23
+ * How long a caller that joins a background child already reported as
24
+ * mfaPending waits for it before re-answering with the same challenge.
25
+ * Long enough for an already-approved login to finish its next ~2s poll and
26
+ * mint a token; short enough that a not-yet-approved one still answers
27
+ * within this tool call instead of blocking for the rest of the 5-minute
28
+ * approval window.
29
+ */
30
+ const JOIN_GRACE_MS = 5000;
31
+ /**
32
+ * The only two lines auth-cli.ts is allowed to hand back as structured data.
33
+ * Deliberately strict (whole line, 1-3 digits or the literal word) so this
34
+ * can never become a channel for arbitrary child-process text to reach a
35
+ * tool response — anything that doesn't match exactly is just another log
36
+ * line.
25
37
  */
26
38
  const MFA_NUMBER_MARKER = /^MFA_NUMBER:(\d{1,3})$/;
39
+ const MFA_PENDING_MARKER = /^MFA_PENDING$/;
40
+ /** The mfaPending kind and message, with or without number-match digits. */
41
+ function mfaPendingFailure(numberMatch) {
42
+ return numberMatch
43
+ ? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
44
+ : ["mfaPending", "An MFA approval was not completed in time. Try again."];
45
+ }
27
46
  export class AuthProcessError extends AuthError {
28
47
  kind;
29
48
  numberMatch;
@@ -93,6 +112,12 @@ function forwardLines(stream, emit) {
93
112
  *
94
113
  * The child inherits the parent's resolved environment and working directory,
95
114
  * so both processes read the same account configuration and .env file.
115
+ *
116
+ * run() settles as soon as the child reports an MFA challenge (with or
117
+ * without a number to display) rather than waiting for the child to exit —
118
+ * a tool call must not block for the whole approval window. The child keeps
119
+ * running in the background; a later run() call joins that background child
120
+ * instead of spawning a second one.
96
121
  */
97
122
  export class AuthRunner {
98
123
  /**
@@ -106,6 +131,22 @@ export class AuthRunner {
106
131
  * caller await the same login and read the same outcome.
107
132
  */
108
133
  inFlight = null;
134
+ /**
135
+ * The child from a login that answered its caller early (an MFA challenge
136
+ * was reported) and is still running in the background. Set for the
137
+ * duration of every spawned child, not just the early-answer case, so a
138
+ * caller who joins after the answer already exists resolves immediately.
139
+ */
140
+ childDone = null;
141
+ /**
142
+ * The last MFA challenge the current background child reported, if any.
143
+ * Set the moment a marker settles a caller early, cleared alongside
144
+ * childDone. Lets a later joiner re-answer immediately instead of
145
+ * discovering the challenge is stale only after blocking on childDone.
146
+ */
147
+ pendingChallenge = null;
148
+ /** Resolves on the next marker from the current child; null between children. */
149
+ challengeSignal = null;
109
150
  scriptPath;
110
151
  timeoutMs;
111
152
  onProgress;
@@ -125,6 +166,14 @@ export class AuthRunner {
125
166
  log("DEBUG", "Joining the authentication already in flight");
126
167
  return this.inFlight;
127
168
  }
169
+ // A caller that answered early is gone, but its child can still be
170
+ // running in the background (waiting on the user's phone). Join it
171
+ // instead of spawning a second child, which would only hit the
172
+ // cross-process lock and return "busy".
173
+ if (this.childDone) {
174
+ log("DEBUG", "Joining the background sign-in still running from an earlier call");
175
+ return this.joinBackgroundChild(this.childDone);
176
+ }
128
177
  // The latch is released by the flow that owns it, as it settles, so a
129
178
  // failed login is never replayed: the tool call after a declined MFA
130
179
  // prompt starts a fresh attempt rather than inheriting the stale
@@ -137,7 +186,55 @@ export class AuthRunner {
137
186
  this.inFlight = flow;
138
187
  return flow;
139
188
  }
140
- /** One child process, start to finish. */
189
+ /**
190
+ * Join a background child from an earlier early-answered call instead of
191
+ * spawning a new one. A joiner must not simply await childDone: that
192
+ * blocks for whatever is left of the 5-minute approval window, exactly the
193
+ * problem run() otherwise fixes, since the caller usually retries right
194
+ * after reading "call this tool again" and well before actually approving.
195
+ *
196
+ * If no challenge has been reported yet (the child hasn't reached MFA),
197
+ * wait for one — or for the child to finish on its own. Once a challenge
198
+ * is known, race the child against a short grace window: fast enough for
199
+ * an already-approved login to land, short enough to re-answer with the
200
+ * same challenge rather than block.
201
+ */
202
+ async joinBackgroundChild(childDone) {
203
+ if (!this.pendingChallenge && this.challengeSignal) {
204
+ await Promise.race([childDone.catch(() => { }), this.challengeSignal]);
205
+ }
206
+ const challenge = this.pendingChallenge;
207
+ if (!challenge)
208
+ return childDone;
209
+ return new Promise((resolve, reject) => {
210
+ let settled = false;
211
+ const graceTimer = setTimeout(() => {
212
+ if (settled)
213
+ return;
214
+ settled = true;
215
+ reject(new AuthProcessError(...mfaPendingFailure(challenge.numberMatch), challenge.numberMatch));
216
+ }, JOIN_GRACE_MS);
217
+ graceTimer.unref?.();
218
+ childDone.then((value) => {
219
+ if (settled)
220
+ return;
221
+ settled = true;
222
+ clearTimeout(graceTimer);
223
+ resolve(value);
224
+ }, (error) => {
225
+ if (settled)
226
+ return;
227
+ settled = true;
228
+ clearTimeout(graceTimer);
229
+ reject(error);
230
+ });
231
+ });
232
+ }
233
+ /**
234
+ * One child process, start to finish. The returned promise can settle
235
+ * before the child actually exits (see the stdout handler below); the
236
+ * child's real completion is tracked separately on this.childDone.
237
+ */
141
238
  async spawnAuth() {
142
239
  log("INFO", "Auto-launching brightspace-auth...");
143
240
  return await new Promise((resolve, reject) => {
@@ -149,7 +246,14 @@ export class AuthRunner {
149
246
  detached: process.platform !== "win32",
150
247
  });
151
248
  let timedOut = false;
152
- let settled = false;
249
+ // Whether the caller-facing promise above (resolve/reject) has
250
+ // settled. Distinct from childFinished: an early MFA answer settles
251
+ // this while the child keeps running.
252
+ let callerSettled = false;
253
+ // Whether the child has actually finished (exited, timed out, or
254
+ // failed to start) and teardown has run. Guards every handler below
255
+ // against double cleanup.
256
+ let childFinished = false;
153
257
  let numberMatch;
154
258
  let killTimer;
155
259
  const kill = (signal) => {
@@ -182,25 +286,83 @@ export class AuthRunner {
182
286
  };
183
287
  const onExit = () => kill("SIGTERM");
184
288
  process.once("exit", onExit);
185
- const finish = (error) => {
186
- if (settled)
289
+ const settleCaller = (error) => {
290
+ if (callerSettled)
187
291
  return;
188
- settled = true;
292
+ callerSettled = true;
293
+ if (error)
294
+ reject(error);
295
+ else
296
+ resolve(true);
297
+ };
298
+ // Tracks the child to its real end, independent of an early caller
299
+ // answer. Exposed as this.childDone so the next run() call can join
300
+ // it. A dummy catch keeps an unapproved background failure from
301
+ // becoming an unhandled rejection when nobody ever joins it; it does
302
+ // not stop a later `await this.childDone` from seeing the rejection.
303
+ let resolveChildDone;
304
+ let rejectChildDone;
305
+ const completion = new Promise((res, rej) => {
306
+ resolveChildDone = res;
307
+ rejectChildDone = rej;
308
+ });
309
+ const trackedCompletion = completion.finally(() => {
310
+ if (this.childDone === trackedCompletion) {
311
+ this.childDone = null;
312
+ this.pendingChallenge = null;
313
+ this.challengeSignal = null;
314
+ }
315
+ });
316
+ trackedCompletion.catch(() => { });
317
+ this.childDone = trackedCompletion;
318
+ this.pendingChallenge = null;
319
+ let resolveChallengeSignal;
320
+ this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
321
+ // Records the challenge (number or not) and wakes a joiner waiting in
322
+ // joinBackgroundChild. Idempotent on the "already known" question, but
323
+ // a later number still overwrites a numberless pendingChallenge so a
324
+ // fresh joiner sees it — see MFA_PENDING_MARKER's own comment.
325
+ const publishChallenge = (matched) => {
326
+ const firstChallenge = this.pendingChallenge === null;
327
+ if (firstChallenge || matched) {
328
+ this.pendingChallenge = { numberMatch: matched ?? this.pendingChallenge?.numberMatch };
329
+ }
330
+ if (firstChallenge)
331
+ resolveChallengeSignal();
332
+ if (!callerSettled) {
333
+ settleCaller(new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch));
334
+ }
335
+ };
336
+ // Runs once the child is actually done. Settles the caller too, if an
337
+ // early answer had not already done so; otherwise this is just the
338
+ // background sign-in finishing, which only childDone's joiner sees.
339
+ const finishChild = (error) => {
340
+ if (childFinished)
341
+ return;
342
+ childFinished = true;
189
343
  clearTimeout(timer);
190
344
  if (killTimer)
191
345
  clearTimeout(killTimer);
192
346
  process.off("exit", onExit);
347
+ const answeredEarly = callerSettled;
348
+ settleCaller(error);
349
+ if (answeredEarly) {
350
+ if (error)
351
+ log("WARN", `Background sign-in finished with ${error.kind}: ${error.message}`);
352
+ else
353
+ log("INFO", "Background sign-in completed successfully after an early MFA response");
354
+ }
193
355
  if (error)
194
- reject(error);
356
+ rejectChildDone(error);
195
357
  else
196
- resolve(true);
358
+ resolveChildDone(true);
197
359
  };
198
360
  const timer = setTimeout(() => {
199
361
  timedOut = true;
200
362
  kill("SIGTERM");
201
363
  killTimer = setTimeout(() => {
202
364
  kill("SIGKILL");
203
- finish(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
365
+ finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
204
366
  }, KILL_GRACE_MS);
205
367
  }, this.timeoutMs);
206
368
  forwardLines(child.stderr, (line) => {
@@ -211,31 +373,38 @@ export class AuthRunner {
211
373
  catch { /* Logging must not interrupt authentication. */ }
212
374
  });
213
375
  // Piped and drained rather than ignored: a full stdout pipe would
214
- // block the child mid-login.
376
+ // block the child mid-login. The first marker settles the caller
377
+ // immediately, without killing the child — see the class doc comment.
215
378
  forwardLines(child.stdout, (line) => {
216
- const match = MFA_NUMBER_MARKER.exec(line);
217
- if (match)
218
- numberMatch = match[1];
219
- else
379
+ const numberMarker = MFA_NUMBER_MARKER.exec(line);
380
+ if (numberMarker) {
381
+ numberMatch = numberMarker[1];
382
+ publishChallenge(numberMatch);
383
+ }
384
+ else if (MFA_PENDING_MARKER.test(line)) {
385
+ publishChallenge(undefined);
386
+ }
387
+ else {
220
388
  log("DEBUG", line);
389
+ }
221
390
  });
222
391
  child.on("error", (error) => {
223
- if (settled)
392
+ if (childFinished)
224
393
  return;
225
394
  log("ERROR", "Auto-auth process failed", error.message);
226
395
  kill("SIGKILL");
227
- finish(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
396
+ finishChild(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
228
397
  });
229
398
  child.on("close", (code) => {
230
- if (settled)
399
+ if (childFinished)
231
400
  return;
232
401
  if (timedOut) {
233
402
  kill("SIGKILL");
234
- finish(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
403
+ finishChild(new AuthProcessError("timeout", `Authentication timed out. Run ${AUTH_COMMAND} to try again.`));
235
404
  }
236
405
  else if (code === 0) {
237
406
  log("INFO", "Auto-auth completed successfully");
238
- finish();
407
+ finishChild();
239
408
  }
240
409
  else {
241
410
  const failures = {
@@ -244,13 +413,11 @@ export class AuthRunner {
244
413
  4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
245
414
  5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
246
415
  6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
247
- 7: numberMatch
248
- ? ["mfaPending", `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes, then try again.`]
249
- : ["mfaPending", "A Microsoft Authenticator approval was not completed in time. Try again."],
416
+ 7: mfaPendingFailure(numberMatch),
250
417
  };
251
418
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
252
419
  kill("SIGKILL");
253
- finish(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
420
+ finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
254
421
  }
255
422
  });
256
423
  });
@@ -39,9 +39,9 @@ export class BrowserAuth {
39
39
  ssoFlow;
40
40
  stateStore;
41
41
  cooldown;
42
- constructor(config, requestMfaCode) {
42
+ constructor(config, options = {}) {
43
43
  this.config = config;
44
- this.ssoFlow = createSSOFlow(config, requestMfaCode);
44
+ this.ssoFlow = createSSOFlow(config, options.requestMfaCode, options.onMfaChallenge);
45
45
  this.stateStore = new BrowserStateStore(config.sessionDir);
46
46
  this.cooldown = new AuthCooldown(config.sessionDir);
47
47
  }
@@ -53,6 +53,8 @@ export class DuoMfaHandler {
53
53
  approvalAnnounced = false;
54
54
  verificationCodeAnnounced = null;
55
55
  passcodeSubmitted = false;
56
+ /** True once onMfaChallenge has been told about this login, code or not. */
57
+ announcedToCaller = false;
56
58
  constructor(options) {
57
59
  this.options = options;
58
60
  }
@@ -63,14 +65,21 @@ export class DuoMfaHandler {
63
65
  async handle(page) {
64
66
  if (!this.isChallenge(page))
65
67
  return false;
68
+ const verificationCode = await this.readVerificationCode(page);
66
69
  if (!this.approvalAnnounced) {
67
70
  this.approvalAnnounced = true;
68
71
  log("WARN", "Waiting up to 5 minutes for Duo MFA approval on your device.");
72
+ this.options.onMfaChallenge?.(verificationCode);
73
+ if (verificationCode)
74
+ this.announcedToCaller = true;
69
75
  }
70
- const verificationCode = await this.readVerificationCode(page);
71
76
  if (verificationCode && verificationCode !== this.verificationCodeAnnounced) {
72
77
  this.verificationCodeAnnounced = verificationCode;
73
78
  log("WARN", `Duo verification code: ${verificationCode}. Enter it in Duo Mobile.`);
79
+ if (!this.announcedToCaller) {
80
+ this.announcedToCaller = true;
81
+ this.options.onMfaChallenge?.(verificationCode);
82
+ }
74
83
  }
75
84
  await this.submitPasscode(page);
76
85
  return true;
@@ -184,6 +184,8 @@ export class PurdueSSOFlow {
184
184
  const deadline = Date.now() + MFA_TIMEOUT_MS;
185
185
  let challenged = false;
186
186
  let announced = null;
187
+ /** True once onMfaChallenge has been told about this login, number or not. */
188
+ let announcedToCaller = false;
187
189
  try {
188
190
  while (Date.now() < deadline) {
189
191
  if (await this.duoMfa.handle(page))
@@ -197,10 +199,17 @@ export class PurdueSSOFlow {
197
199
  if (challengeVisible && !challenged) {
198
200
  challenged = true;
199
201
  log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
202
+ this.config.onMfaChallenge?.(number);
203
+ if (number)
204
+ announcedToCaller = true;
200
205
  }
201
206
  if (number && number !== announced) {
202
207
  announced = number;
203
208
  log("WARN", `Number match: ${number}. Enter it in Microsoft Authenticator.`);
209
+ if (!announcedToCaller) {
210
+ announcedToCaller = true;
211
+ this.config.onMfaChallenge?.(number);
212
+ }
204
213
  }
205
214
  if (await this.isAuthenticated(page)) {
206
215
  log("INFO", "Login successful - verified Brightspace home");
@@ -35,13 +35,14 @@ export class MfaApprovalError extends BrowserAuthError {
35
35
  * else uses the default flow, which already covers the common Shibboleth,
36
36
  * CAS, and Microsoft Entra forms.
37
37
  */
38
- export function createSSOFlow(config, requestMfaCode) {
38
+ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
39
39
  const credentials = {
40
40
  username: config.username,
41
41
  password: config.password,
42
42
  baseUrl: config.baseUrl,
43
43
  headless: config.headless,
44
44
  requestMfaCode,
45
+ onMfaChallenge,
45
46
  };
46
47
  if (isSunyBrightspace(config.baseUrl)) {
47
48
  return new SunySSOFlow({ ...credentials, campus: config.campus });
@@ -56,6 +56,7 @@ export class SunySSOFlow {
56
56
  baseUrl: `https://${SUNY_BRIGHTSPACE_HOST}`,
57
57
  headless: config.headless,
58
58
  requestMfaCode: config.requestMfaCode,
59
+ onMfaChallenge: config.onMfaChallenge,
59
60
  });
60
61
  }
61
62
  hasCredentials() {
package/build/auth-cli.js CHANGED
@@ -56,7 +56,14 @@ async function main() {
56
56
  tokenTtl: config.tokenTtl,
57
57
  });
58
58
  const codePrompt = config.headless && !automatic && process.stdin.isTTY ? requestMfaCode : undefined;
59
- await new BrowserAuth(config, codePrompt).authenticate({
59
+ // In automatic mode, tell the parent (AuthRunner) about an MFA challenge
60
+ // the moment it appears, so a blocked tool call can answer within
61
+ // seconds instead of waiting out the whole approval window. Stdout only
62
+ // — the parent parses stdout for structured markers, never stderr.
63
+ const onMfaChallenge = automatic
64
+ ? (number) => console.log(number ? `MFA_NUMBER:${number}` : "MFA_PENDING")
65
+ : undefined;
66
+ await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge }).authenticate({
60
67
  automatic,
61
68
  onAuthenticated: async (token) => {
62
69
  await tokenManager.setToken(token);
package/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
16
16
  import { readFileSync } from "node:fs";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { dirname, resolve } from "node:path";
19
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
19
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
  const PKG_VERSION = (() => {
@@ -106,6 +106,7 @@ else {
106
106
  registerGetAnnouncements(server, apiClient, config);
107
107
  registerGetAssignments(server, apiClient, config);
108
108
  registerGetAssignmentFiles(server, apiClient, config.baseUrl);
109
+ registerGetAnnouncementFiles(server, apiClient);
109
110
  registerGetCourseContent(server, apiClient);
110
111
  registerDownloadFile(server, apiClient);
111
112
  registerGetClasslistEmails(server, apiClient);
@@ -114,11 +115,11 @@ else {
114
115
  registerGetDiscussions(server, apiClient);
115
116
  registerGetVideoTranscript(server, apiClient);
116
117
  registerGetServerInfo(server, config, PKG_VERSION);
117
- log("DEBUG", "MCP tools registered (14 tools)");
118
+ log("DEBUG", "MCP tools registered (15 tools)");
118
119
  // Connect stdio transport
119
120
  const transport = new StdioServerTransport();
120
121
  await server.connect(transport);
121
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (14 tools registered)");
122
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (15 tools registered)");
122
123
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
123
124
  }
124
125
  catch (error) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT. See LICENSE file for details.
5
+ */
6
+ import { extractPdfText } from "../utils/pdf-extractor.js";
7
+ import { officeDocumentText } from "../utils/zip-extract.js";
8
+ const KIND_BY_EXTENSION = {
9
+ pdf: "pdf",
10
+ docx: "docx",
11
+ doc: "other",
12
+ xlsx: "xlsx",
13
+ xls: "other",
14
+ pptx: "pptx",
15
+ ppt: "other",
16
+ png: "image",
17
+ jpg: "image",
18
+ jpeg: "image",
19
+ gif: "image",
20
+ webp: "image",
21
+ txt: "text",
22
+ md: "text",
23
+ csv: "text",
24
+ json: "text",
25
+ };
26
+ export function fileKind(fileName) {
27
+ const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
28
+ return KIND_BY_EXTENSION[extension] ?? "other";
29
+ }
30
+ export function describeAttachment(attachment) {
31
+ return {
32
+ fileId: attachment.FileId,
33
+ fileName: attachment.FileName,
34
+ size: attachment.Size,
35
+ kind: fileKind(attachment.FileName),
36
+ };
37
+ }
38
+ /**
39
+ * Read one attachment from the path that serves its bytes. The text is best
40
+ * effort: a scanned PDF or an image yields nothing, and that is reported
41
+ * rather than treated as a failure.
42
+ */
43
+ export async function readAttachment(apiClient, sourcePath, attachment, extract, maxChars) {
44
+ const base = describeAttachment(attachment);
45
+ if (!extract)
46
+ return { ...base, text: null, note: "Text extraction was not requested." };
47
+ const response = await apiClient.getRaw(sourcePath);
48
+ const buffer = Buffer.from(await response.arrayBuffer());
49
+ let text = null;
50
+ let note;
51
+ switch (base.kind) {
52
+ case "pdf": {
53
+ const extracted = await extractPdfText(buffer);
54
+ text = extracted?.text?.trim() || null;
55
+ if (!text)
56
+ note = "No text layer in this PDF. It may be a scan.";
57
+ break;
58
+ }
59
+ case "docx":
60
+ case "xlsx":
61
+ case "pptx": {
62
+ text = officeDocumentText(buffer);
63
+ if (!text)
64
+ note = "No readable text found in this Office document.";
65
+ break;
66
+ }
67
+ case "text": {
68
+ text = buffer.toString("utf-8").trim() || null;
69
+ break;
70
+ }
71
+ default: {
72
+ note = `Cannot extract text from a ${base.kind} file. Use download_file to save it.`;
73
+ }
74
+ }
75
+ const truncated = text !== null && text.length > maxChars;
76
+ return {
77
+ ...base,
78
+ bytes: buffer.length,
79
+ text: truncated ? text.slice(0, maxChars) : text,
80
+ truncated,
81
+ ...(note ? { note } : {}),
82
+ };
83
+ }
@@ -19,13 +19,13 @@ import path from "node:path";
19
19
  export function registerDownloadFile(server, apiClient) {
20
20
  server.registerTool("download_file", {
21
21
  title: "Download File",
22
- description: "Download a file from course content or assignment submissions to a local directory. Use this when the user wants to download, save, or get a file from Brightspace course content or dropbox submissions. IMPORTANT: You MUST ask the user where they want to save the file before calling this tool. Never guess or assume a download directory. After identifying the file to download, suggest a clean readable filename to the user (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and ask if they'd like to rename it. Pass their preferred name as customFilename, or omit it to keep the original.",
22
+ description: "Download a file from course content, assignment submissions, or an announcement's attachments to a local directory. Use this when the user wants to download, save, or get a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool. Never guess or assume a download directory. After identifying the file to download, suggest a clean readable filename to the user (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and ask if they'd like to rename it. Pass their preferred name as customFilename, or omit it to keep the original.",
23
23
  inputSchema: DownloadFileSchema,
24
24
  }, async (args) => {
25
25
  try {
26
26
  log("DEBUG", "download_file tool called", { args });
27
27
  // Parse and validate input
28
- const { courseId, topicId, folderId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
28
+ const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
29
29
  // Validate courseId
30
30
  validateContentId(courseId);
31
31
  // Validate download path is absolute
@@ -57,8 +57,14 @@ export function registerDownloadFile(server, apiClient) {
57
57
  validateContentId(fileId);
58
58
  return await downloadSubmissionFile(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
59
59
  }
60
+ else if (newsId !== undefined && fileId !== undefined) {
61
+ // Announcement attachment download
62
+ validateContentId(newsId);
63
+ validateContentId(fileId);
64
+ return await downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename);
65
+ }
60
66
  else {
61
- return errorResponse("Either topicId (for content files) or both folderId and fileId (for submission files) must be provided");
67
+ return errorResponse("Either topicId (for content files), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
62
68
  }
63
69
  }
64
70
  catch (error) {
@@ -202,3 +208,50 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
202
208
  message: `File downloaded successfully to ${result.path}`,
203
209
  });
204
210
  }
211
+ /**
212
+ * Download an announcement attachment using newsId + fileId
213
+ */
214
+ async function downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename) {
215
+ log("INFO", `Downloading announcement attachment: courseId=${courseId}, newsId=${newsId}, fileId=${fileId}`);
216
+ const newsItem = await apiClient.get(apiClient.le(courseId, `/news/${newsId}`));
217
+ const attachments = newsItem?.Attachments ?? [];
218
+ const file = attachments.find((f) => f.FileId === fileId);
219
+ if (!file) {
220
+ return errorResponse(`File ID ${fileId} not found on this announcement. Available files: ${attachments.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
221
+ }
222
+ if (file.Size > MAX_FILE_SIZE) {
223
+ return errorResponse(`File too large (${Math.round(file.Size / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
224
+ }
225
+ // GET /d2l/api/le/(version)/(orgUnitId)/news/(newsItemId)/attachments/(fileId)
226
+ const response = await apiClient.getRaw(apiClient.le(courseId, `/news/${newsId}/attachments/${fileId}`));
227
+ // Check Content-Length BEFORE downloading body (prevent memory exhaustion)
228
+ const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
229
+ if (contentLength > MAX_FILE_SIZE) {
230
+ return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
231
+ }
232
+ const disposition = response.headers.get("Content-Disposition") ?? "";
233
+ const filename = parseContentDispositionFilename(disposition) ?? file.FileName;
234
+ // Download body as buffer
235
+ const buffer = Buffer.from(await response.arrayBuffer());
236
+ // Double-check actual size
237
+ if (buffer.length > MAX_FILE_SIZE) {
238
+ return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
239
+ }
240
+ const originalFilename = filename;
241
+ const effectiveFilename = customFilename || filename;
242
+ // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
243
+ const result = await secureDownload({
244
+ targetDir: downloadPath,
245
+ filename: effectiveFilename,
246
+ data: buffer,
247
+ });
248
+ log("INFO", `Announcement attachment downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
249
+ return toolResponse({
250
+ success: true,
251
+ filePath: result.path,
252
+ fileSize: result.size,
253
+ mimeType: result.mime,
254
+ originalFilename,
255
+ message: `File downloaded successfully to ${result.path}`,
256
+ });
257
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT. See LICENSE file for details.
5
+ */
6
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { GetAnnouncementFilesSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { describeAttachment, readAttachment } from "./attachment-reader.js";
10
+ import { effectiveDate, isPublishedNewsItem } from "./get-announcements.js";
11
+ import { log } from "../utils/logger.js";
12
+ /**
13
+ * The files an instructor attached to an announcement: field-notes prompts,
14
+ * a rubric, an updated schedule. They hang off the news item, not course
15
+ * content, so get_course_content never lists them. Same shape as
16
+ * get_assignment_files: list first, then read one file by id.
17
+ */
18
+ /** Every posted announcement in the course, narrowed to one when newsId is given. */
19
+ async function listNews(apiClient, courseId, newsId) {
20
+ const items = await apiClient.get(apiClient.le(courseId, "/news/"), {
21
+ ttl: DEFAULT_CACHE_TTLS.announcements,
22
+ });
23
+ return items
24
+ .filter(isPublishedNewsItem)
25
+ .filter((item) => (newsId === undefined ? true : item.Id === newsId));
26
+ }
27
+ export function registerGetAnnouncementFiles(server, apiClient) {
28
+ server.registerTool("get_announcement_files", {
29
+ title: "Get Announcement Files",
30
+ description: "Read the files an instructor attached to an announcement: prompt questions, a rubric, an updated schedule, slides. Call it with just courseId to see which announcements have attachments, then with newsId and fileId to read one. Use this when the user asks what a file attached to an announcement says. Returns the text itself. Use download_file (newsId + fileId) instead when the user wants the file saved to disk.",
31
+ inputSchema: GetAnnouncementFilesSchema,
32
+ }, async (args) => {
33
+ try {
34
+ log("DEBUG", "get_announcement_files tool called", { args });
35
+ const { courseId, newsId, fileId, extractText, maxChars } = GetAnnouncementFilesSchema.parse(args);
36
+ if (fileId !== undefined && newsId === undefined) {
37
+ return toolResponse({
38
+ courseId,
39
+ error: "newsId is required when fileId is given.",
40
+ });
41
+ }
42
+ const items = await listNews(apiClient, courseId, newsId);
43
+ if (newsId !== undefined && items.length === 0) {
44
+ return toolResponse({
45
+ courseId,
46
+ newsId,
47
+ error: `No announcement with id ${newsId} in course ${courseId}.`,
48
+ });
49
+ }
50
+ // Read one file.
51
+ if (fileId !== undefined) {
52
+ const item = items[0];
53
+ const attachments = item.Attachments ?? [];
54
+ const attachment = attachments.find((a) => a.FileId === fileId);
55
+ if (!attachment) {
56
+ return toolResponse({
57
+ courseId,
58
+ newsId,
59
+ fileId,
60
+ error: `No attachment with id ${fileId} on announcement "${item.Title}".`,
61
+ available: attachments.map(describeAttachment),
62
+ });
63
+ }
64
+ const file = await readAttachment(apiClient, apiClient.le(courseId, `/news/${item.Id}/attachments/${fileId}`), attachment, extractText, maxChars);
65
+ return toolResponse({ courseId, newsId, title: item.Title, file });
66
+ }
67
+ // Discovery: which announcements have files, without downloading any.
68
+ const withFiles = items
69
+ .filter((item) => (item.Attachments ?? []).length > 0)
70
+ .map((item) => ({
71
+ newsId: item.Id,
72
+ title: item.Title,
73
+ date: effectiveDate(item),
74
+ attachments: (item.Attachments ?? []).map(describeAttachment),
75
+ }));
76
+ log("INFO", `get_announcement_files: ${withFiles.length} announcements with attachments in course ${courseId}`);
77
+ return toolResponse({
78
+ courseId,
79
+ announcements: withFiles,
80
+ ...(withFiles.length === 0
81
+ ? { note: "No announcement in this course has an attached file." }
82
+ : {}),
83
+ });
84
+ }
85
+ catch (error) {
86
+ return sanitizeError(error);
87
+ }
88
+ });
89
+ }
@@ -56,6 +56,11 @@ export function newestFirst(a, b) {
56
56
  * Map a raw D2L news item to a clean announcement object.
57
57
  */
58
58
  export function mapNewsItem(item) {
59
+ const attachments = (item.Attachments ?? []).map((file) => ({
60
+ fileId: file.FileId,
61
+ fileName: file.FileName,
62
+ size: file.Size,
63
+ }));
59
64
  return {
60
65
  id: item.Id,
61
66
  title: item.Title,
@@ -64,6 +69,7 @@ export function mapNewsItem(item) {
64
69
  date: effectiveDate(item),
65
70
  isPinned: item.IsPinned,
66
71
  lastModified: item.LastModifiedDate ?? null,
72
+ ...(attachments.length > 0 ? { attachments } : {}),
67
73
  };
68
74
  }
69
75
  /**
@@ -72,7 +78,7 @@ export function mapNewsItem(item) {
72
78
  export function registerGetAnnouncements(server, apiClient, config) {
73
79
  server.registerTool("get_announcements", {
74
80
  title: "Get Announcements",
75
- description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said.",
81
+ description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
76
82
  inputSchema: GetAnnouncementsSchema,
77
83
  }, async (args) => {
78
84
  try {
@@ -6,44 +6,14 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetAssignmentFilesSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
- import { extractPdfText } from "../utils/pdf-extractor.js";
10
- import { officeDocumentText } from "../utils/zip-extract.js";
9
+ import { describeAttachment, readAttachment, } from "./attachment-reader.js";
11
10
  import { assignmentUrl } from "../utils/deep-links.js";
12
11
  import { log } from "../utils/logger.js";
12
+ export { fileKind } from "./attachment-reader.js";
13
13
  /** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
14
14
  function unwrapList(raw) {
15
15
  return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
16
16
  }
17
- const KIND_BY_EXTENSION = {
18
- pdf: "pdf",
19
- docx: "docx",
20
- doc: "other",
21
- xlsx: "xlsx",
22
- xls: "other",
23
- pptx: "pptx",
24
- ppt: "other",
25
- png: "image",
26
- jpg: "image",
27
- jpeg: "image",
28
- gif: "image",
29
- webp: "image",
30
- txt: "text",
31
- md: "text",
32
- csv: "text",
33
- json: "text",
34
- };
35
- export function fileKind(fileName) {
36
- const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
37
- return KIND_BY_EXTENSION[extension] ?? "other";
38
- }
39
- function describeAttachment(attachment) {
40
- return {
41
- fileId: attachment.FileId,
42
- fileName: attachment.FileName,
43
- size: attachment.Size,
44
- kind: fileKind(attachment.FileName),
45
- };
46
- }
47
17
  /** Every visible folder in the course that has at least one attachment. */
48
18
  async function listFolders(apiClient, courseId, folderId) {
49
19
  const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), {
@@ -53,51 +23,6 @@ async function listFolders(apiClient, courseId, folderId) {
53
23
  .filter((folder) => folder.IsHidden !== true)
54
24
  .filter((folder) => (folderId === undefined ? true : folder.Id === folderId));
55
25
  }
56
- /**
57
- * Read one attachment. The text is best effort: a scanned PDF or an image
58
- * yields nothing, and that is reported rather than treated as a failure.
59
- */
60
- async function readAttachment(apiClient, courseId, folderId, attachment, extract, maxChars) {
61
- const base = describeAttachment(attachment);
62
- if (!extract)
63
- return { ...base, text: null, note: "Text extraction was not requested." };
64
- const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${attachment.FileId}`));
65
- const buffer = Buffer.from(await response.arrayBuffer());
66
- let text = null;
67
- let note;
68
- switch (base.kind) {
69
- case "pdf": {
70
- const extracted = await extractPdfText(buffer);
71
- text = extracted?.text?.trim() || null;
72
- if (!text)
73
- note = "No text layer in this PDF. It may be a scan.";
74
- break;
75
- }
76
- case "docx":
77
- case "xlsx":
78
- case "pptx": {
79
- text = officeDocumentText(buffer);
80
- if (!text)
81
- note = "No readable text found in this Office document.";
82
- break;
83
- }
84
- case "text": {
85
- text = buffer.toString("utf-8").trim() || null;
86
- break;
87
- }
88
- default: {
89
- note = `Cannot extract text from a ${base.kind} file. Use download_file to save it.`;
90
- }
91
- }
92
- const truncated = text !== null && text.length > maxChars;
93
- return {
94
- ...base,
95
- bytes: buffer.length,
96
- text: truncated ? text.slice(0, maxChars) : text,
97
- truncated,
98
- ...(note ? { note } : {}),
99
- };
100
- }
101
26
  export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
102
27
  server.registerTool("get_assignment_files", {
103
28
  title: "Get Assignment Files",
@@ -134,7 +59,7 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
134
59
  available: (folder.Attachments ?? []).map(describeAttachment),
135
60
  });
136
61
  }
137
- const file = await readAttachment(apiClient, courseId, folderId, attachment, extractText, maxChars);
62
+ const file = await readAttachment(apiClient, apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`), attachment, extractText, maxChars);
138
63
  return toolResponse({
139
64
  courseId,
140
65
  folderId,
@@ -10,6 +10,7 @@ export { registerGetMyGrades } from "./get-my-grades.js";
10
10
  export { registerGetAnnouncements } from "./get-announcements.js";
11
11
  export { registerGetAssignments } from "./get-assignments.js";
12
12
  export { registerGetAssignmentFiles } from "./get-assignment-files.js";
13
+ export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
13
14
  export { registerGetCourseContent } from "./get-course-content.js";
14
15
  export { registerDownloadFile } from "./download-file.js";
15
16
  export { registerGetClasslistEmails } from "./get-classlist-emails.js";
@@ -59,7 +59,9 @@ export const DownloadFileSchema = z.object({
59
59
  folderId: z.coerce.number().int().positive().optional()
60
60
  .describe("Dropbox folder ID (for submission/feedback file downloads)."),
61
61
  fileId: z.coerce.number().int().positive().optional()
62
- .describe("Specific file ID within a dropbox submission."),
62
+ .describe("Specific file ID within a dropbox submission, or an announcement attachment's file ID (with newsId)."),
63
+ newsId: z.coerce.number().int().positive().optional()
64
+ .describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
63
65
  downloadPath: z.string().min(1)
64
66
  .describe("Absolute path to the directory where the file should be saved."),
65
67
  customFilename: z.string().max(255).optional()
@@ -91,6 +93,18 @@ export const GetAssignmentFilesSchema = z.object({
91
93
  maxChars: z.coerce.number().int().positive().max(100000).default(12000)
92
94
  .describe("Maximum characters of extracted text to return. The response reports whether it was truncated."),
93
95
  });
96
+ export const GetAnnouncementFilesSchema = z.object({
97
+ courseId: z.coerce.number().int().positive()
98
+ .describe("Course ID whose announcement attachments to look at."),
99
+ newsId: z.coerce.number().int().positive().optional()
100
+ .describe("Announcement (news item) ID. Omit to list every announcement in the course that has attachments."),
101
+ fileId: z.coerce.number().int().positive().optional()
102
+ .describe("Attachment file ID to read. Requires newsId. Omit to list the files without reading them."),
103
+ extractText: z.boolean().default(true)
104
+ .describe("Extract readable text from the file. Works for PDF, DOCX, XLSX, PPTX, and plain text."),
105
+ maxChars: z.coerce.number().int().positive().max(100000).default(12000)
106
+ .describe("Maximum characters of extracted text to return. The response reports whether it was truncated."),
107
+ });
94
108
  export const GetVideoTranscriptSchema = z.object({
95
109
  courseId: z.coerce.number().int().positive().optional()
96
110
  .describe("Course ID the video belongs to. Required together with topicId unless videoUrl is given directly."),
@@ -59,19 +59,20 @@ export function errorResponse(message) {
59
59
  const AUTH_FAILURE_GUIDANCE = {
60
60
  busy: "A sign-in is already running in another process. Let it finish, then try again.",
61
61
  cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
62
- `Run \`${AUTH_COMMAND}\` in a terminal to retry now and see the number to enter.`,
62
+ `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to retry now and see the number to enter.`,
63
63
  unsupported: "This login needs something your AI client cannot supply, usually a code from an "
64
64
  + "authenticator app. " +
65
- `Run \`${AUTH_COMMAND}\` in a terminal and sign in there.`,
65
+ `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) and sign in there.`,
66
66
  secureStorage: "The operating system credential store is locked or unavailable, so the saved " +
67
67
  "password could not be read. Unlock your keychain or keyring, then try again.",
68
68
  transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
69
69
  "Check your connection and try again in a few minutes.",
70
70
  timeout: "The sign-in did not finish in time, usually a missed MFA prompt. " +
71
- `Run \`${AUTH_COMMAND}\` in a terminal to complete it with the number visible.`,
72
- failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal to see why, ` +
71
+ `Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
72
+ failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
73
73
  "or `brightspace-setup` if your saved school or username is wrong.",
74
- mfaPending: "A Microsoft Authenticator approval was not completed in time. Try again.",
74
+ mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo), " +
75
+ "then call this tool again — the sign-in is finishing in the background.",
75
76
  };
76
77
  /**
77
78
  * mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
@@ -82,7 +83,8 @@ const AUTH_FAILURE_GUIDANCE = {
82
83
  */
83
84
  function authFailureMessage(error) {
84
85
  if (error.kind === "mfaPending" && error.numberMatch) {
85
- return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes, then run this again.`;
86
+ return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes, ` +
87
+ "then call this tool again — the sign-in is finishing in the background.";
86
88
  }
87
89
  return AUTH_FAILURE_GUIDANCE[error.kind];
88
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.4.1",
3
+ "version": "3.6.0",
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",