brightspace-mcp-server 3.4.1 → 3.5.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 +3 -1
- 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/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
|
|
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);
|
|
@@ -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.5.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",
|