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 +4 -2
- package/build/api/client.js +5 -2
- package/build/api/rate-limiter.js +2 -2
- package/build/auth/auth-cooldown.js +1 -1
- package/build/auth/auth-runner.js +194 -27
- package/build/auth/browser-auth.js +2 -2
- package/build/auth/duo-mfa.js +10 -1
- package/build/auth/purdue-sso.js +9 -0
- package/build/auth/sso-flow.js +2 -1
- package/build/auth/suny-sso.js +1 -0
- package/build/auth-cli.js +8 -1
- package/build/index.js +4 -3
- package/build/tools/attachment-reader.js +83 -0
- package/build/tools/download-file.js +56 -3
- package/build/tools/get-announcement-files.js +89 -0
- package/build/tools/get-announcements.js +7 -1
- package/build/tools/get-assignment-files.js +3 -78
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +15 -1
- package/build/tools/tool-helpers.js +8 -6
- package/package.json +1 -1
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
|
-
|
|
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" |
|
package/build/api/client.js
CHANGED
|
@@ -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:
|
|
108
|
-
refillRate:
|
|
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
|
-
//
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
186
|
-
if (
|
|
289
|
+
const settleCaller = (error) => {
|
|
290
|
+
if (callerSettled)
|
|
187
291
|
return;
|
|
188
|
-
|
|
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
|
-
|
|
356
|
+
rejectChildDone(error);
|
|
195
357
|
else
|
|
196
|
-
|
|
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
|
-
|
|
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
|
|
217
|
-
if (
|
|
218
|
-
numberMatch =
|
|
219
|
-
|
|
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 (
|
|
392
|
+
if (childFinished)
|
|
224
393
|
return;
|
|
225
394
|
log("ERROR", "Auto-auth process failed", error.message);
|
|
226
395
|
kill("SIGKILL");
|
|
227
|
-
|
|
396
|
+
finishChild(new AuthProcessError("failed", `Could not start authentication. Run ${AUTH_COMMAND} for details.`));
|
|
228
397
|
});
|
|
229
398
|
child.on("close", (code) => {
|
|
230
|
-
if (
|
|
399
|
+
if (childFinished)
|
|
231
400
|
return;
|
|
232
401
|
if (timedOut) {
|
|
233
402
|
kill("SIGKILL");
|
|
234
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
}
|
package/build/auth/duo-mfa.js
CHANGED
|
@@ -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;
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -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");
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -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 });
|
package/build/auth/suny-sso.js
CHANGED
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
|
-
|
|
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 (
|
|
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 (
|
|
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
|
|
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)
|
|
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 {
|
|
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,
|
package/build/tools/index.js
CHANGED
|
@@ -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";
|
package/build/tools/schemas.js
CHANGED
|
@@ -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: "
|
|
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,
|
|
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.
|
|
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",
|