brightspace-mcp-server 3.13.9 → 3.13.11
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 +2 -1
- package/build/api/client.js +21 -2
- package/build/auth/auth-lock.js +18 -0
- package/build/auth/auth-runner.js +159 -23
- package/build/auth/browser-auth.js +4 -2
- package/build/auth/logout.js +15 -3
- package/build/auth/mfa-challenge.js +20 -0
- package/build/auth/session-store.js +23 -0
- package/build/auth/token-manager.js +163 -5
- package/build/auth-cli.js +7 -6
- package/build/index.js +4 -3
- package/build/tools/get-announcement-files.js +1 -0
- package/build/tools/get-announcements.js +1 -0
- package/build/tools/get-assignment-files.js +1 -0
- package/build/tools/get-assignment-rubric.js +1 -0
- package/build/tools/get-assignments.js +1 -0
- package/build/tools/get-calendar-events.js +1 -0
- package/build/tools/get-classlist-emails.js +1 -0
- package/build/tools/get-course-content.js +1 -0
- package/build/tools/get-discussions.js +1 -0
- package/build/tools/get-dropbox-feedback.js +1 -0
- package/build/tools/get-dropbox-folders.js +1 -0
- package/build/tools/get-dropbox-submissions.js +1 -0
- package/build/tools/get-dropbox-user-submissions.js +1 -0
- package/build/tools/get-my-courses.js +1 -0
- package/build/tools/get-my-grades.js +1 -0
- package/build/tools/get-my-groups.js +1 -0
- package/build/tools/get-roster.js +1 -0
- package/build/tools/get-rubrics-for-object.js +1 -0
- package/build/tools/get-server-info.js +1 -0
- package/build/tools/get-upcoming-due-dates.js +1 -0
- package/build/tools/get-video-transcript.js +1 -0
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +1 -0
- package/build/tools/search-course.js +1 -0
- package/build/tools/sign-out.js +82 -0
- package/build/tools/tool-helpers.js +3 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -77,6 +77,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
|
|
77
77
|
| Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura, including BoilerCast LTI links, and YouTube) |
|
|
78
78
|
| Calendar | "When is my midterm?" · "Is lab cancelled on Thursday?" |
|
|
79
79
|
| For instructors and TAs | "Which students haven't submitted Lab 4 yet?" · "What feedback did I leave on this student's homework?" · "Download that student's submitted PDF" (students see a clear "instructor access required" note) |
|
|
80
|
+
| Signing out | "Sign me out of Brightspace" (your AI app may ask you to confirm first; details under [When it asks you to sign in](#when-it-asks-you-to-sign-in)) |
|
|
80
81
|
|
|
81
82
|
## Prompts
|
|
82
83
|
|
|
@@ -124,7 +125,7 @@ sits beside your password, so on **this** computer your two factors become one.
|
|
|
124
125
|
anyone who only has your password, and it changes nothing for your other devices. Full enrollment
|
|
125
126
|
steps: [docs/sign-in.md](docs/sign-in.md#answering-code-challenges-without-your-phone).
|
|
126
127
|
|
|
127
|
-
**To sign out early** (on a shared computer, say), run `npx -y brightspace-mcp-server@latest auth --logout
|
|
128
|
+
**To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. If a sign-in is in progress, it refuses and deletes nothing.
|
|
128
129
|
|
|
129
130
|
How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
|
|
130
131
|
|
package/build/api/client.js
CHANGED
|
@@ -132,6 +132,13 @@ export class D2LApiClient {
|
|
|
132
132
|
* Adapted from JhostinAleck/brightspace-mcp (MIT) — RequestCoalescer.ts.
|
|
133
133
|
*/
|
|
134
134
|
pendingGets = new Map();
|
|
135
|
+
/**
|
|
136
|
+
* Bumped by clearCache(). A GET remembers the value it started under and
|
|
137
|
+
* stores its result only if it is unchanged, so a read begun with the old
|
|
138
|
+
* session cannot refill a cache that was cleared (sign-out) while it was in
|
|
139
|
+
* flight.
|
|
140
|
+
*/
|
|
141
|
+
cacheGeneration = 0;
|
|
135
142
|
statsData = {
|
|
136
143
|
statusClasses: { "2xx": 0, "401": 0, "403": 0, "404": 0, "429": 0, "5xx": 0, other: 0 },
|
|
137
144
|
networkErrors: 0,
|
|
@@ -258,6 +265,13 @@ export class D2LApiClient {
|
|
|
258
265
|
// ten minutes of due dates to get_upcoming_due_dates. Trusting the
|
|
259
266
|
// surviving entry served the longer caller's staleness to the shorter one.
|
|
260
267
|
if (options?.ttl) {
|
|
268
|
+
// The saved session may have been deleted by another process (logout),
|
|
269
|
+
// or replaced by a different account's. A signed-out server must not
|
|
270
|
+
// answer from memory, so drop every cached response and let this read
|
|
271
|
+
// take the normal path. Only asked when something is cached, so a cold
|
|
272
|
+
// cache costs nothing.
|
|
273
|
+
if (this.cache.size > 0 && await this.tokenManager.sessionChanged?.())
|
|
274
|
+
this.clearCache();
|
|
261
275
|
const age = this.cache.ageOf(path);
|
|
262
276
|
if (age !== undefined && age <= options.ttl) {
|
|
263
277
|
log("DEBUG", `Cache hit: ${path}`);
|
|
@@ -275,10 +289,11 @@ export class D2LApiClient {
|
|
|
275
289
|
log("DEBUG", `Coalescing GET onto an in-flight request: ${path}`);
|
|
276
290
|
return pending;
|
|
277
291
|
}
|
|
292
|
+
const generation = this.cacheGeneration;
|
|
278
293
|
const inFlight = (async () => {
|
|
279
294
|
const resolved = await this.resolvePath(path);
|
|
280
295
|
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
|
|
281
|
-
if (options?.ttl) {
|
|
296
|
+
if (options?.ttl && generation === this.cacheGeneration) {
|
|
282
297
|
this.cache.set(path, data, options.ttl);
|
|
283
298
|
log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
|
|
284
299
|
}
|
|
@@ -633,9 +648,13 @@ export class D2LApiClient {
|
|
|
633
648
|
return `/d2l/api/le/${LE_VERSION}${path}`;
|
|
634
649
|
}
|
|
635
650
|
/**
|
|
636
|
-
* Clear all cached responses
|
|
651
|
+
* Clear all cached responses, and stop later callers joining GETs already
|
|
652
|
+
* in flight (they began under the session being discarded; those callers
|
|
653
|
+
* still get their own answer, but it is not remembered).
|
|
637
654
|
*/
|
|
638
655
|
clearCache() {
|
|
656
|
+
this.cacheGeneration++;
|
|
657
|
+
this.pendingGets.clear();
|
|
639
658
|
this.cache.clear();
|
|
640
659
|
log("DEBUG", "Cache cleared");
|
|
641
660
|
}
|
package/build/auth/auth-lock.js
CHANGED
|
@@ -52,6 +52,12 @@ export class AuthenticationInProgressError extends Error {
|
|
|
52
52
|
* CHALLENGE_FILE from this one.
|
|
53
53
|
*/
|
|
54
54
|
challenge;
|
|
55
|
+
/**
|
|
56
|
+
* How the auth lock's owner holds it, when the caller looked it up (see
|
|
57
|
+
* BrowserAuth.authenticate). Set only for the auth lock, never for the
|
|
58
|
+
* other locks that throw this same error.
|
|
59
|
+
*/
|
|
60
|
+
ownerMode;
|
|
55
61
|
constructor() {
|
|
56
62
|
super("Authentication already in progress. Retry after the current authentication finishes.");
|
|
57
63
|
this.name = "AuthenticationInProgressError";
|
|
@@ -98,6 +104,18 @@ export const lockOps = {
|
|
|
98
104
|
kill: (pid, signal) => { process.kill(pid, signal); },
|
|
99
105
|
isDead: isDeadImpl,
|
|
100
106
|
};
|
|
107
|
+
/**
|
|
108
|
+
* What a contender can tell about a lock's owner: how it holds the lock
|
|
109
|
+
* ("exclusive" reads back as "explicit", see readOwner) and whether that
|
|
110
|
+
* process is gone. undefined when there is no readable owner record, which
|
|
111
|
+
* is not proof the owner is gone (it may still be writing it).
|
|
112
|
+
*/
|
|
113
|
+
export async function inspectLockOwner(lockPath) {
|
|
114
|
+
const owner = await readOwner(lockPath);
|
|
115
|
+
if (!owner)
|
|
116
|
+
return undefined;
|
|
117
|
+
return { mode: owner.mode === "automatic" ? "automatic" : "explicit", gone: lockOps.isDead(owner) };
|
|
118
|
+
}
|
|
101
119
|
/** Remove only our fixed metadata names, after the directory was retired. */
|
|
102
120
|
async function cleanRetired(lockPath) {
|
|
103
121
|
await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
|
|
@@ -12,7 +12,8 @@ import { AuthError } from "../utils/errors.js";
|
|
|
12
12
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
13
13
|
import { devActivity } from "../utils/dev-activity.js";
|
|
14
14
|
import { parsePhaseMarker } from "./auth-phases.js";
|
|
15
|
-
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
|
|
16
|
+
import { inspectLockOwner } from "./auth-lock.js";
|
|
16
17
|
/**
|
|
17
18
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
18
19
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
@@ -68,6 +69,27 @@ const RELAY_POLL_MS = 1000;
|
|
|
68
69
|
* is over; the same digits showing up again are a new challenge.
|
|
69
70
|
*/
|
|
70
71
|
const RELAY_TOLD_MS = 5 * 60 * 1000;
|
|
72
|
+
/**
|
|
73
|
+
* A call that arrives this soon after another call was answered with an MFA
|
|
74
|
+
* challenge is that call's sibling, not a retry. Claude Code runs tool calls
|
|
75
|
+
* that are not marked read-only one at a time, so a batch the model sent
|
|
76
|
+
* together arrives as a queue: each call lands moments after the previous one
|
|
77
|
+
* answered. Treated as retries, they each waited up to 45 seconds for an
|
|
78
|
+
* approval the user could not give yet, because the model cannot show the
|
|
79
|
+
* number until the whole batch is done. A real retry comes after the model
|
|
80
|
+
* has read the answer and written to the user, which takes longer than this.
|
|
81
|
+
* Measured from when the call arrived, and from the previous answer of either
|
|
82
|
+
* kind, so a queue of siblings chains (see SIBLING_CHAIN_MS).
|
|
83
|
+
*/
|
|
84
|
+
const SIBLING_GRACE_MS = 1500;
|
|
85
|
+
/**
|
|
86
|
+
* How long a chain of sibling answers may run after the last answer that was
|
|
87
|
+
* not a sibling's. A queue of a few tool calls fits easily; a loop of calls
|
|
88
|
+
* that never stops to wait is cut off here and has to wait like a retry.
|
|
89
|
+
*/
|
|
90
|
+
const SIBLING_CHAIN_MS = 20000;
|
|
91
|
+
/** What a call is told when the sign-in it waited on is still short of its challenge. */
|
|
92
|
+
const STILL_SIGNING_IN = "Sign-in is still running in the background. Try again.";
|
|
71
93
|
/**
|
|
72
94
|
* Calls arriving within this long of a batch's first call count as that batch
|
|
73
95
|
* (issue #212). A client running tool calls in parallel sends them within
|
|
@@ -152,7 +174,7 @@ export class AuthProcessError extends AuthError {
|
|
|
152
174
|
function withinCallBudget(work, startedAt) {
|
|
153
175
|
return new Promise((resolve, reject) => {
|
|
154
176
|
const budgetTimer = setTimeout(() => {
|
|
155
|
-
reject(new AuthProcessError("inProgress",
|
|
177
|
+
reject(new AuthProcessError("inProgress", STILL_SIGNING_IN));
|
|
156
178
|
}, Math.max(0, CALL_BUDGET_MS - (Date.now() - startedAt)));
|
|
157
179
|
budgetTimer.unref?.();
|
|
158
180
|
work.then((value) => { clearTimeout(budgetTimer); resolve(value); }, (error) => { clearTimeout(budgetTimer); reject(error); });
|
|
@@ -223,6 +245,13 @@ function dedupeChallenge(error, wave) {
|
|
|
223
245
|
}
|
|
224
246
|
return new AuthProcessError(error.kind, error.message, error.numberMatch, true, error.relayed);
|
|
225
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* Answers given to a queued sibling (see SIBLING_GRACE_MS). They extend the
|
|
250
|
+
* chain (Telling.last) but never restart it (Telling.anchor).
|
|
251
|
+
*/
|
|
252
|
+
const siblingAnswers = new WeakSet();
|
|
253
|
+
/** Whether a call arriving at arrivedAt is a queued sibling of that telling. */
|
|
254
|
+
const isQueuedSibling = (told, arrivedAt) => told !== undefined && arrivedAt - told.last < SIBLING_GRACE_MS && arrivedAt - told.anchor < SIBLING_CHAIN_MS;
|
|
226
255
|
/** Identifies a challenge for "was this already reported to a caller". */
|
|
227
256
|
const relayKey = (kind, numberMatch) => `${kind}:${numberMatch ?? ""}`;
|
|
228
257
|
export class AuthRunner {
|
|
@@ -269,6 +298,18 @@ export class AuthRunner {
|
|
|
269
298
|
* that sign-in instead of repeating the answer (see attendRelay).
|
|
270
299
|
*/
|
|
271
300
|
relayTold = new Map();
|
|
301
|
+
/**
|
|
302
|
+
* When callers here were answered with this process's own pending
|
|
303
|
+
* challenge. A call arriving just after is a queued sibling and is
|
|
304
|
+
* answered at once (see SIBLING_GRACE_MS).
|
|
305
|
+
*/
|
|
306
|
+
challengeTold = null;
|
|
307
|
+
/**
|
|
308
|
+
* What the current child reported when it only relayed another process's
|
|
309
|
+
* sign-in. A later call answers from it instead of joining that child,
|
|
310
|
+
* which can linger for seconds after it has said everything it will say.
|
|
311
|
+
*/
|
|
312
|
+
relayProbe = null;
|
|
272
313
|
scriptPath;
|
|
273
314
|
timeoutMs;
|
|
274
315
|
onProgress;
|
|
@@ -281,6 +322,16 @@ export class AuthRunner {
|
|
|
281
322
|
this.onProgress = options.onProgress;
|
|
282
323
|
this.sessionDir = options.sessionDir;
|
|
283
324
|
}
|
|
325
|
+
/**
|
|
326
|
+
* Whether this process has a sign-in child running: true from the moment it
|
|
327
|
+
* is spawned (before the child has taken the cross-process sign-in lock, so
|
|
328
|
+
* a lock check alone would miss it) until the child exits, even after the
|
|
329
|
+
* caller that started it was answered early with an MFA challenge. Read-only;
|
|
330
|
+
* sign_out uses it to refuse to delete a session a sign-in is about to write.
|
|
331
|
+
*/
|
|
332
|
+
busy() {
|
|
333
|
+
return this.inFlight !== null || this.childDone !== null;
|
|
334
|
+
}
|
|
284
335
|
/**
|
|
285
336
|
* Authenticate, joining the login this process already started if there is
|
|
286
337
|
* one. Returns true on success and throws a useful error on failure.
|
|
@@ -298,6 +349,9 @@ export class AuthRunner {
|
|
|
298
349
|
* the next call to join.
|
|
299
350
|
*/
|
|
300
351
|
async run(onChallenge) {
|
|
352
|
+
const sibling = this.queuedSiblingAnswer(Date.now());
|
|
353
|
+
if (sibling)
|
|
354
|
+
throw sibling;
|
|
301
355
|
// Synchronous, before any await: calls in one tick cannot split a batch.
|
|
302
356
|
const wave = this.joinWave();
|
|
303
357
|
// Also synchronous: what was already told BEFORE this call, so siblings in
|
|
@@ -310,31 +364,61 @@ export class AuthRunner {
|
|
|
310
364
|
}
|
|
311
365
|
catch (error) {
|
|
312
366
|
this.noteRelayReported(error);
|
|
313
|
-
|
|
367
|
+
const answer = dedupeChallenge(error, wave);
|
|
368
|
+
if (answer instanceof AuthProcessError && answer.kind === "mfaPending" && !answer.duplicate && !answer.relayed) {
|
|
369
|
+
const now = Date.now();
|
|
370
|
+
this.challengeTold = { anchor: now, last: now };
|
|
371
|
+
}
|
|
372
|
+
throw answer;
|
|
314
373
|
}
|
|
315
374
|
finally {
|
|
316
375
|
this.waiting -= 1;
|
|
317
376
|
this.lastAttendedAt = Date.now();
|
|
318
377
|
}
|
|
319
378
|
}
|
|
379
|
+
/**
|
|
380
|
+
* The answer for a call queued right behind one that was just answered
|
|
381
|
+
* with this process's pending challenge
|
|
382
|
+
* (see SIBLING_GRACE_MS), or null when this call should run normally.
|
|
383
|
+
*/
|
|
384
|
+
queuedSiblingAnswer(arrivedAt) {
|
|
385
|
+
const pending = this.pendingChallenge;
|
|
386
|
+
const told = this.challengeTold;
|
|
387
|
+
if (!this.childDone || pending?.kind !== "mfaPending" || !told || !isQueuedSibling(told, arrivedAt))
|
|
388
|
+
return null;
|
|
389
|
+
told.last = arrivedAt;
|
|
390
|
+
// In full, not the short sibling line: if the answer before it was lost,
|
|
391
|
+
// this one still carries the digits (#201). It never reaches catch in
|
|
392
|
+
// run(), so it extends the chain without restarting it.
|
|
393
|
+
return new AuthProcessError(...mfaPendingFailure(pending.numberMatch), pending.numberMatch);
|
|
394
|
+
}
|
|
395
|
+
/** Relayed challenges already told to a caller here, and when (a snapshot). */
|
|
320
396
|
relayChallengesTold() {
|
|
321
397
|
const now = Date.now();
|
|
322
|
-
const told = new
|
|
398
|
+
const told = new Map();
|
|
323
399
|
for (const [key, at] of this.relayTold) {
|
|
324
|
-
if (now - at > RELAY_TOLD_MS)
|
|
400
|
+
if (now - at.last > RELAY_TOLD_MS)
|
|
325
401
|
this.relayTold.delete(key);
|
|
326
402
|
else
|
|
327
|
-
told.
|
|
403
|
+
told.set(key, { ...at });
|
|
328
404
|
}
|
|
329
405
|
return told;
|
|
330
406
|
}
|
|
331
407
|
/** A relayed challenge that went back to a caller counts as told. */
|
|
332
408
|
noteRelayReported(error) {
|
|
333
|
-
if (!(error instanceof AuthProcessError) || !error.relayed)
|
|
409
|
+
if (!(error instanceof AuthProcessError) || !error.relayed || error.duplicate)
|
|
334
410
|
return;
|
|
335
411
|
if (error.kind !== "mfaPending" && error.kind !== "automaticPending")
|
|
336
412
|
return;
|
|
337
|
-
|
|
413
|
+
const key = relayKey(error.kind, error.numberMatch);
|
|
414
|
+
const now = Date.now();
|
|
415
|
+
const told = this.relayTold.get(key);
|
|
416
|
+
// A sibling's answer extends the chain but does not restart it, or a loop
|
|
417
|
+
// of quick calls could keep every one of them from waiting.
|
|
418
|
+
if (told && siblingAnswers.has(error))
|
|
419
|
+
told.last = now;
|
|
420
|
+
else
|
|
421
|
+
this.relayTold.set(key, { anchor: now, last: now });
|
|
338
422
|
}
|
|
339
423
|
joinWave() {
|
|
340
424
|
const now = Date.now();
|
|
@@ -393,14 +477,25 @@ export class AuthRunner {
|
|
|
393
477
|
* relay, so every caller waits.
|
|
394
478
|
*/
|
|
395
479
|
attendRelay(error, startedAt, onChallenge, toldBefore) {
|
|
396
|
-
const
|
|
480
|
+
const told = toldBefore.get(relayKey(error.kind, error.numberMatch));
|
|
481
|
+
// startedAt is when this call arrived, not when its child finished: a
|
|
482
|
+
// queued call is a sibling however long the lock check took.
|
|
483
|
+
if (error.kind === "mfaPending" && isQueuedSibling(told, startedAt)) {
|
|
484
|
+
// Queued right behind the call that was told (see SIBLING_GRACE_MS):
|
|
485
|
+
// answer in full at once.
|
|
486
|
+
const sibling = new AuthProcessError(error.kind, error.message, error.numberMatch, false, true);
|
|
487
|
+
siblingAnswers.add(sibling);
|
|
488
|
+
throw sibling;
|
|
489
|
+
}
|
|
490
|
+
const retry = told !== undefined;
|
|
397
491
|
if (error.kind === "mfaPending" && onChallenge && !retry) {
|
|
398
492
|
try {
|
|
399
493
|
onChallenge(error.numberMatch);
|
|
400
494
|
}
|
|
401
495
|
catch { /* Announcing must not interrupt authentication. */ }
|
|
402
496
|
}
|
|
403
|
-
|
|
497
|
+
// inProgress: the owner has not reached a challenge yet. Wait for one.
|
|
498
|
+
const waits = error.kind === "automaticPending" || error.kind === "inProgress" || (error.kind === "mfaPending" && retry);
|
|
404
499
|
if (!waits)
|
|
405
500
|
throw error;
|
|
406
501
|
const windowMs = pollWindowMs(Date.now() - startedAt);
|
|
@@ -426,21 +521,30 @@ export class AuthRunner {
|
|
|
426
521
|
async awaitRelayedOwner(error, windowMs) {
|
|
427
522
|
const lockPath = authLockPath(this.sessionDir);
|
|
428
523
|
const deadline = Date.now() + windowMs;
|
|
429
|
-
let latest = {
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
};
|
|
524
|
+
let latest = error.kind === "inProgress" ? { phase: "starting" }
|
|
525
|
+
: error.kind === "automaticPending" ? { phase: "automatic" }
|
|
526
|
+
: { phase: "approval", numberMatch: error.numberMatch };
|
|
433
527
|
for (;;) {
|
|
434
528
|
const held = await fs.access(lockPath).then(() => true, () => false);
|
|
435
529
|
if (!held) {
|
|
436
530
|
this.relayTold.clear();
|
|
437
531
|
return true;
|
|
438
532
|
}
|
|
533
|
+
// The owner died without releasing its lock. Waiting longer only runs
|
|
534
|
+
// the clock down; the next call's sign-in reclaims the dead lock.
|
|
535
|
+
if ((await inspectLockOwner(lockPath))?.gone)
|
|
536
|
+
throw new AuthProcessError("inProgress", STILL_SIGNING_IN);
|
|
439
537
|
const relayed = await relayChallenge(lockPath);
|
|
440
538
|
if (relayed) {
|
|
441
|
-
|
|
442
|
-
? {
|
|
443
|
-
: {
|
|
539
|
+
const next = relayed.kind === "automatic"
|
|
540
|
+
? { phase: "automatic" }
|
|
541
|
+
: { phase: "approval", numberMatch: relayed.numberMatch ?? (latest.phase === "approval" ? latest.numberMatch : undefined) };
|
|
542
|
+
// A challenge appearing while this caller waited on a sign-in that had
|
|
543
|
+
// none yet is what the user needs right now: answer with it at once.
|
|
544
|
+
if (next.phase === "approval" && latest.phase !== "approval") {
|
|
545
|
+
throw new AuthProcessError(...mfaPendingFailure(next.numberMatch), next.numberMatch, false, true);
|
|
546
|
+
}
|
|
547
|
+
latest = next;
|
|
444
548
|
}
|
|
445
549
|
const remaining = deadline - Date.now();
|
|
446
550
|
if (remaining <= 0)
|
|
@@ -450,7 +554,9 @@ export class AuthRunner {
|
|
|
450
554
|
timer.unref?.();
|
|
451
555
|
});
|
|
452
556
|
}
|
|
453
|
-
if (latest.
|
|
557
|
+
if (latest.phase === "starting")
|
|
558
|
+
throw new AuthProcessError("inProgress", STILL_SIGNING_IN, undefined, false, true);
|
|
559
|
+
if (latest.phase === "automatic")
|
|
454
560
|
throw automaticPendingFailure(true);
|
|
455
561
|
throw new AuthProcessError(...mfaPendingFailure(latest.numberMatch), latest.numberMatch, false, true);
|
|
456
562
|
}
|
|
@@ -463,6 +569,12 @@ export class AuthRunner {
|
|
|
463
569
|
// running in the background (waiting on the user's phone). Join it
|
|
464
570
|
// instead of spawning a second child, which would only hit the
|
|
465
571
|
// cross-process lock and return "busy".
|
|
572
|
+
// That child only relayed another process's sign-in, and has said all it
|
|
573
|
+
// will: answer from its report rather than wait for it to exit.
|
|
574
|
+
if (this.childDone && this.relayProbe) {
|
|
575
|
+
const probe = this.relayProbe;
|
|
576
|
+
return Promise.reject(new AuthProcessError(probe.kind, probe.message, probe.numberMatch, false, true));
|
|
577
|
+
}
|
|
466
578
|
if (this.childDone) {
|
|
467
579
|
log("DEBUG", "Joining the background sign-in still running from an earlier call");
|
|
468
580
|
return this.joinBackgroundChild(this.childDone, startedAt);
|
|
@@ -633,11 +745,14 @@ export class AuthRunner {
|
|
|
633
745
|
this.pendingChallenge = null;
|
|
634
746
|
this.challengeSignal = null;
|
|
635
747
|
this.wave = null;
|
|
748
|
+
this.challengeTold = null;
|
|
749
|
+
this.relayProbe = null;
|
|
636
750
|
}
|
|
637
751
|
});
|
|
638
752
|
trackedCompletion.catch(() => { });
|
|
639
753
|
this.childDone = trackedCompletion;
|
|
640
754
|
this.pendingChallenge = null;
|
|
755
|
+
this.relayProbe = null;
|
|
641
756
|
let resolveChallengeSignal;
|
|
642
757
|
this.challengeSignal = new Promise((res) => { resolveChallengeSignal = res; });
|
|
643
758
|
// Records the challenge (number or not) and wakes a joiner waiting in
|
|
@@ -683,9 +798,11 @@ export class AuthRunner {
|
|
|
683
798
|
}
|
|
684
799
|
if (firstChallenge)
|
|
685
800
|
resolveChallengeSignal();
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
801
|
+
const answer = new AuthProcessError(...mfaPendingFailure(this.pendingChallenge?.numberMatch), this.pendingChallenge?.numberMatch, false, relayed);
|
|
802
|
+
if (relayed)
|
|
803
|
+
this.relayProbe = answer;
|
|
804
|
+
if (!callerSettled)
|
|
805
|
+
settleCaller(answer);
|
|
689
806
|
};
|
|
690
807
|
/**
|
|
691
808
|
* The child is typing its own verification code. Watched for abandonment
|
|
@@ -700,7 +817,10 @@ export class AuthRunner {
|
|
|
700
817
|
this.pendingChallenge = { kind: "automaticPending" };
|
|
701
818
|
startWatching();
|
|
702
819
|
resolveChallengeSignal();
|
|
703
|
-
|
|
820
|
+
const answer = automaticPendingFailure(relayed);
|
|
821
|
+
if (relayed)
|
|
822
|
+
this.relayProbe = answer;
|
|
823
|
+
settleCaller(answer);
|
|
704
824
|
};
|
|
705
825
|
// Runs once the child is actually done. Settles the caller too, if an
|
|
706
826
|
// early answer had not already done so; otherwise this is just the
|
|
@@ -768,6 +888,18 @@ export class AuthRunner {
|
|
|
768
888
|
// marker that follows it settles the caller.
|
|
769
889
|
relayed = true;
|
|
770
890
|
}
|
|
891
|
+
else if (line === RELAYED_STARTING_MARKER) {
|
|
892
|
+
// Another process's background sign-in holds the lock and has no
|
|
893
|
+
// challenge yet. With a lock to watch, answer now so the caller can
|
|
894
|
+
// wait on it (attendRelay); the child may linger a few seconds more.
|
|
895
|
+
// Without one, the close handler answers "busy" as it always has.
|
|
896
|
+
relayed = true;
|
|
897
|
+
if (this.sessionDir) {
|
|
898
|
+
const answer = new AuthProcessError("inProgress", STILL_SIGNING_IN, undefined, false, true);
|
|
899
|
+
this.relayProbe = answer;
|
|
900
|
+
settleCaller(answer);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
771
903
|
else {
|
|
772
904
|
log("DEBUG", line);
|
|
773
905
|
}
|
|
@@ -807,9 +939,13 @@ export class AuthRunner {
|
|
|
807
939
|
// nothing to approve, so say that rather than "busy".
|
|
808
940
|
else if (code === 2 && automaticSeen)
|
|
809
941
|
failures[2] = ["automaticPending", automaticPendingFailure().message];
|
|
942
|
+
// Busy because another process's sign-in has not reached a
|
|
943
|
+
// challenge yet: the caller can wait on it (see attendRelay).
|
|
944
|
+
else if (code === 2 && relayed && this.sessionDir)
|
|
945
|
+
failures[2] = ["inProgress", STILL_SIGNING_IN];
|
|
810
946
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
811
947
|
kill("SIGKILL");
|
|
812
|
-
const challenged = kind === "mfaPending" || kind === "automaticPending";
|
|
948
|
+
const challenged = kind === "mfaPending" || kind === "automaticPending" || kind === "inProgress";
|
|
813
949
|
finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged));
|
|
814
950
|
}
|
|
815
951
|
});
|
|
@@ -10,7 +10,7 @@ import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError, Automa
|
|
|
10
10
|
import { isDuoPrompt } from "./duo-mfa.js";
|
|
11
11
|
import { BrowserStateStore } from "./browser-state-store.js";
|
|
12
12
|
import { hasNewerEntraState, recordMicrosoftSession } from "./microsoft-session.js";
|
|
13
|
-
import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
|
|
13
|
+
import { acquireProcessLock, AuthenticationInProgressError, inspectLockOwner } from "./auth-lock.js";
|
|
14
14
|
import { authLockPath, publishAutomaticProgress, publishChallenge, relayChallenge } from "./mfa-challenge.js";
|
|
15
15
|
import { AuthCooldown } from "./auth-cooldown.js";
|
|
16
16
|
import { mintAccessToken } from "./token-mint.js";
|
|
@@ -126,8 +126,10 @@ export class BrowserAuth {
|
|
|
126
126
|
const release = await acquireProcessLock(this.lockPath, { mode: options.automatic ? "automatic" : "explicit" }).catch(async (error) => {
|
|
127
127
|
// Hand back the number the owner is showing, so this caller's user can
|
|
128
128
|
// approve that sign-in instead of being told only to wait for it.
|
|
129
|
-
if (error instanceof AuthenticationInProgressError)
|
|
129
|
+
if (error instanceof AuthenticationInProgressError) {
|
|
130
130
|
error.challenge = await relayChallenge(this.lockPath);
|
|
131
|
+
error.ownerMode = (await inspectLockOwner(this.lockPath))?.mode;
|
|
132
|
+
}
|
|
131
133
|
throw error;
|
|
132
134
|
});
|
|
133
135
|
try {
|
package/build/auth/logout.js
CHANGED
|
@@ -20,7 +20,7 @@ import { AUTH_COMMAND } from "../utils/commands.js";
|
|
|
20
20
|
import { resolveSessionLocation } from "../utils/config.js";
|
|
21
21
|
/** Browser state files other than the session token itself, in the order they are deleted. */
|
|
22
22
|
const BROWSER_STATE_FILES = [BROWSER_STATE_FILE, LEGACY_BROWSER_STATE_FILE, MICROSOFT_SESSION_FILE];
|
|
23
|
-
class RemoveError extends Error {
|
|
23
|
+
export class RemoveError extends Error {
|
|
24
24
|
file;
|
|
25
25
|
constructor(file, cause) {
|
|
26
26
|
super(`Could not remove ${file}: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
@@ -39,6 +39,18 @@ async function removeIfPresent(file) {
|
|
|
39
39
|
throw new RemoveError(path.basename(file), error);
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
|
+
/** SessionStore.remove() throws SessionStoreError; report it like every other file that would not delete. */
|
|
43
|
+
async function removeSessionFile(sessionDir) {
|
|
44
|
+
try {
|
|
45
|
+
return await new SessionStore(sessionDir).remove();
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
// A session write that holds its lock too long is still "in progress", not a file that won't delete.
|
|
49
|
+
if (error instanceof AuthenticationInProgressError)
|
|
50
|
+
throw error;
|
|
51
|
+
throw new RemoveError(SESSION_FILE, error);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
42
54
|
/**
|
|
43
55
|
* Delete the saved session files in one account's session directory.
|
|
44
56
|
*
|
|
@@ -67,7 +79,7 @@ export async function clearSavedSession(sessionDir) {
|
|
|
67
79
|
const release = await acquireProcessLock(authLockPath(sessionDir), { mode: "exclusive" });
|
|
68
80
|
try {
|
|
69
81
|
const removed = [];
|
|
70
|
-
if (await
|
|
82
|
+
if (await removeSessionFile(sessionDir))
|
|
71
83
|
removed.push(SESSION_FILE);
|
|
72
84
|
for (const name of BROWSER_STATE_FILES) {
|
|
73
85
|
if (await removeIfPresent(path.join(sessionDir, name)))
|
|
@@ -80,7 +92,7 @@ export async function clearSavedSession(sessionDir) {
|
|
|
80
92
|
}
|
|
81
93
|
}
|
|
82
94
|
const KEPT = "Kept: your saved password, config.json, and any authenticator or credential-store entries.";
|
|
83
|
-
const MEMORY_NOTE = "
|
|
95
|
+
const MEMORY_NOTE = "Other Brightspace apps open on this computer sign out on their next request. One you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.";
|
|
84
96
|
const LOCAL_ONLY = "This only clears files on this computer. It does not sign you out of Microsoft or Brightspace on their servers.";
|
|
85
97
|
/**
|
|
86
98
|
* The `auth --logout` command. Resolves to the process exit code: 0 when the
|
|
@@ -86,6 +86,26 @@ export const AUTOMATIC_PENDING_MARKER = "AUTH_AUTOMATIC_PENDING";
|
|
|
86
86
|
* needed.
|
|
87
87
|
*/
|
|
88
88
|
export const RELAYED_CHALLENGE_MARKER = "AUTH_RELAYED";
|
|
89
|
+
/**
|
|
90
|
+
* Printed alone, instead of the relay marker and a challenge, when the lock
|
|
91
|
+
* belongs to another server's background sign-in that has not reached a
|
|
92
|
+
* challenge yet. The parent can wait on that sign-in (AuthRunner.attendRelay).
|
|
93
|
+
*/
|
|
94
|
+
export const RELAYED_STARTING_MARKER = "AUTH_RELAYED_STARTING";
|
|
95
|
+
/**
|
|
96
|
+
* What an automatic child prints when its sign-in found the auth lock held:
|
|
97
|
+
* the owner's challenge after the relay marker; or, when a background sign-in
|
|
98
|
+
* (mode "automatic") holds the lock with no challenge yet, the starting
|
|
99
|
+
* marker. Nothing for anything else, so the parent answers "busy": an
|
|
100
|
+
* explicit `auth` run in a terminal never publishes a challenge, and an owner
|
|
101
|
+
* that cannot be identified may be a stuck lock someone has to clear.
|
|
102
|
+
*/
|
|
103
|
+
export function relayedLockLines(challenge, ownerMode) {
|
|
104
|
+
if (challenge) {
|
|
105
|
+
return [RELAYED_CHALLENGE_MARKER, challenge.kind === "automatic" ? AUTOMATIC_PENDING_MARKER : challengeMarker(challenge.numberMatch)];
|
|
106
|
+
}
|
|
107
|
+
return ownerMode === "automatic" ? [RELAYED_STARTING_MARKER] : [];
|
|
108
|
+
}
|
|
89
109
|
/** The stdout marker AuthRunner parses for a challenge. */
|
|
90
110
|
export function challengeMarker(numberMatch) {
|
|
91
111
|
return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
|
|
@@ -164,6 +164,29 @@ export class SessionStore {
|
|
|
164
164
|
this.storeError("save", error);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
/**
|
|
168
|
+
* A cheap identity for the saved session file, so a running server can tell
|
|
169
|
+
* "same file" from "signed out" from "signed out and signed in again". A
|
|
170
|
+
* single stat: nothing is read or decrypted and no lock is taken, so it is
|
|
171
|
+
* cheap enough to ask before every request.
|
|
172
|
+
*
|
|
173
|
+
* - `null`: the file definitely does not exist (ENOENT). Signed out.
|
|
174
|
+
* - a string: the file exists; it changes whenever the file is replaced or
|
|
175
|
+
* rewritten (inode, size, and the modified, changed and birth times in
|
|
176
|
+
* nanoseconds; Windows may report inode 0, which the times and size still
|
|
177
|
+
* distinguish).
|
|
178
|
+
* - `undefined`: the stat failed for any other reason. An unreadable
|
|
179
|
+
* directory is not evidence of a sign-out, so callers keep what they have.
|
|
180
|
+
*/
|
|
181
|
+
async fingerprint() {
|
|
182
|
+
try {
|
|
183
|
+
const stat = await fs.stat(this.sessionFilePath, { bigint: true });
|
|
184
|
+
return `${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}:${stat.birthtimeNs}`;
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
return error.code === "ENOENT" ? null : undefined;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
167
190
|
/** Missing files return null. Damaged or inaccessible secrets fail without MFA. */
|
|
168
191
|
async load() {
|
|
169
192
|
try {
|
|
@@ -30,6 +30,21 @@ export class TokenManager {
|
|
|
30
30
|
/** Single in-flight mint, so concurrent callers share one request. */
|
|
31
31
|
mintInFlight = null;
|
|
32
32
|
rejectedAccessToken = null;
|
|
33
|
+
/**
|
|
34
|
+
* The saved session this process last read or wrote, as the account it
|
|
35
|
+
* belongs to. Kept even while cachedToken is null (an unusable token on
|
|
36
|
+
* disk), so a later sign-in can still be compared with the account that
|
|
37
|
+
* the in-memory state, and the API client's response cache, were built for.
|
|
38
|
+
* Null once the session is known to be gone.
|
|
39
|
+
*/
|
|
40
|
+
identityToken = null;
|
|
41
|
+
/** SessionStore.fingerprint() of the file when identityToken was read or written. */
|
|
42
|
+
observedFingerprint;
|
|
43
|
+
/**
|
|
44
|
+
* Set when the session was signed out or replaced by a different account
|
|
45
|
+
* since sessionChanged() last answered. Errs toward true.
|
|
46
|
+
*/
|
|
47
|
+
sessionReplaced = false;
|
|
33
48
|
constructor(sessionDirOrOptions) {
|
|
34
49
|
const options = typeof sessionDirOrOptions === "string" || sessionDirOrOptions === undefined
|
|
35
50
|
? { sessionDir: sessionDirOrOptions }
|
|
@@ -64,13 +79,20 @@ export class TokenManager {
|
|
|
64
79
|
}
|
|
65
80
|
return token;
|
|
66
81
|
}
|
|
67
|
-
// Check memory cache first
|
|
82
|
+
// Check memory cache first, after asking whether the saved session is
|
|
83
|
+
// still the one this process remembers. Another process may have signed
|
|
84
|
+
// out (deleted it), or signed in again, since this token was cached; when
|
|
85
|
+
// it has, the memory is dropped or refreshed from disk here. Skipped when
|
|
86
|
+
// nothing is remembered, so a cold start costs no extra stat.
|
|
87
|
+
await this.syncWithDisk();
|
|
68
88
|
if (this.cachedToken && this.isUsable(this.cachedToken)) {
|
|
69
89
|
log("DEBUG", "Returning cached token");
|
|
70
90
|
return this.cachedToken;
|
|
71
91
|
}
|
|
72
92
|
// Try loading from disk
|
|
73
93
|
const storedToken = await this.sessionStore.load();
|
|
94
|
+
if (storedToken)
|
|
95
|
+
await this.observe(storedToken);
|
|
74
96
|
if (storedToken && this.isUsable(storedToken)) {
|
|
75
97
|
log("DEBUG", "Loaded valid token from session store");
|
|
76
98
|
this.cachedToken = storedToken;
|
|
@@ -89,6 +111,120 @@ export class TokenManager {
|
|
|
89
111
|
log("DEBUG", "No valid token available");
|
|
90
112
|
return null;
|
|
91
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Whether the saved session was signed out or replaced by a different
|
|
116
|
+
* account since this was last asked, for example by `brightspace-auth
|
|
117
|
+
* --logout` or the sign_out tool running in another process, or a new
|
|
118
|
+
* sign-in as someone else. When it was, anything built under the old
|
|
119
|
+
* session (the API client's response cache) must not be trusted. Costs one
|
|
120
|
+
* stat while the file is unchanged.
|
|
121
|
+
*
|
|
122
|
+
* True when the file is missing, and when a reload shows a different
|
|
123
|
+
* account than the one remembered. Names are compared when both sides have
|
|
124
|
+
* one, otherwise the session cookie; with neither, the answer is true,
|
|
125
|
+
* because when unsure the safe move is to clear. The same account's token
|
|
126
|
+
* re-minted by another process (hourly) is not a change, so it does not
|
|
127
|
+
* wipe a cache that is still valid. The answer is true once per change.
|
|
128
|
+
* Always false for D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE, which have no
|
|
129
|
+
* saved session. A failed stat answers false; a replaced file this process
|
|
130
|
+
* cannot read answers true (the in-memory token is still served, but cached
|
|
131
|
+
* responses are not trusted until the file reads again).
|
|
132
|
+
*/
|
|
133
|
+
async sessionChanged() {
|
|
134
|
+
if (this.envAccessToken || this.envSessionCookie)
|
|
135
|
+
return false;
|
|
136
|
+
await this.syncWithDisk();
|
|
137
|
+
const changed = this.sessionReplaced;
|
|
138
|
+
this.sessionReplaced = false;
|
|
139
|
+
return changed;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Drop everything remembered about the saved session without touching any
|
|
143
|
+
* file: the cached token, a remembered rejection and the recorded file
|
|
144
|
+
* identity. The next getToken() reads the disk afresh. Call it right after
|
|
145
|
+
* deleting the session files (the sign_out tool does), so this process stops
|
|
146
|
+
* acting signed in even when another app has already signed in again and
|
|
147
|
+
* recreated the file, which a plain existence check could not tell apart.
|
|
148
|
+
* Also makes the next sessionChanged() answer true, so cached API responses
|
|
149
|
+
* are not served either.
|
|
150
|
+
*/
|
|
151
|
+
forgetSession() {
|
|
152
|
+
this.cachedToken = null;
|
|
153
|
+
this.rejectedAccessToken = null;
|
|
154
|
+
this.identityToken = null;
|
|
155
|
+
this.observedFingerprint = undefined;
|
|
156
|
+
this.sessionReplaced = true;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Compare the saved file with what this process last saw of it, and bring
|
|
160
|
+
* memory in line: file gone, forget everything; file replaced or rewritten,
|
|
161
|
+
* reload it and adopt it (flagging a different account). A fingerprint
|
|
162
|
+
* that cannot be determined, or an unreadable replacement, keeps the
|
|
163
|
+
* current state, with an unreadable replacement flagged as changed.
|
|
164
|
+
* Does nothing, and costs nothing, when nothing is remembered.
|
|
165
|
+
*/
|
|
166
|
+
async syncWithDisk() {
|
|
167
|
+
if (!this.identityToken && !this.rejectedAccessToken)
|
|
168
|
+
return;
|
|
169
|
+
const fingerprint = await this.sessionStore.fingerprint?.();
|
|
170
|
+
if (fingerprint === null) {
|
|
171
|
+
log("DEBUG", "The saved session is gone; dropping the in-memory token");
|
|
172
|
+
this.forgetSession();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (typeof fingerprint !== "string" || !this.identityToken || fingerprint === this.observedFingerprint)
|
|
176
|
+
return;
|
|
177
|
+
log("DEBUG", "The saved session changed on disk; reloading it");
|
|
178
|
+
let loaded;
|
|
179
|
+
try {
|
|
180
|
+
loaded = await this.sessionStore.load();
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
// Cannot tell whose session this is. Keep serving the token in memory,
|
|
184
|
+
// do not record the fingerprint (so the next call tries again), and
|
|
185
|
+
// make sure cached responses are not trusted.
|
|
186
|
+
log("WARN", "The saved session changed but could not be read", error);
|
|
187
|
+
this.sessionReplaced = true;
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (!loaded) {
|
|
191
|
+
this.forgetSession();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
this.adopt(loaded, fingerprint);
|
|
195
|
+
this.cachedToken = this.isUsable(loaded) ? loaded : null;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Remember `token` as the session now on disk. `fingerprint` is the file's
|
|
199
|
+
* identity from a stat taken before it was read; without one, it is taken
|
|
200
|
+
* now, after the read or write that produced `token`. If another process
|
|
201
|
+
* rewrote the file in the instant between that read or write and the stat,
|
|
202
|
+
* this records their file's identity against our token, so the change goes
|
|
203
|
+
* unnoticed until the next rewrite. That is acceptable: the token held is
|
|
204
|
+
* itself valid, and the window is microseconds wide.
|
|
205
|
+
*/
|
|
206
|
+
adopt(token, fingerprint) {
|
|
207
|
+
if (this.identityToken && !this.sameAccount(this.identityToken, token))
|
|
208
|
+
this.sessionReplaced = true;
|
|
209
|
+
this.identityToken = token;
|
|
210
|
+
this.observedFingerprint = fingerprint ?? undefined;
|
|
211
|
+
}
|
|
212
|
+
async observe(token) {
|
|
213
|
+
this.adopt(token, await this.sessionStore.fingerprint?.());
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Whether two sessions belong to the same account on the same tenant.
|
|
217
|
+
* Unknown is not the same: with no name on both sides and no shared cookie,
|
|
218
|
+
* this answers false.
|
|
219
|
+
*/
|
|
220
|
+
sameAccount(a, b) {
|
|
221
|
+
if (a.tenantOrigin !== b.tenantOrigin)
|
|
222
|
+
return false;
|
|
223
|
+
if (a.uniqueName !== undefined && b.uniqueName !== undefined) {
|
|
224
|
+
return a.uniqueName.toLowerCase() === b.uniqueName.toLowerCase();
|
|
225
|
+
}
|
|
226
|
+
return a.cookieHeader !== undefined && a.cookieHeader === b.cookieHeader;
|
|
227
|
+
}
|
|
92
228
|
/**
|
|
93
229
|
* Build the token for D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE;
|
|
94
230
|
* D2L_ACCESS_TOKEN wins when both are set. expiresAt is pushed far into the
|
|
@@ -163,7 +299,15 @@ export class TokenManager {
|
|
|
163
299
|
// The auth CLI or another MCP process can finish a login while HTTP minting
|
|
164
300
|
// is in flight. Its newer session wins over either result of this request.
|
|
165
301
|
const current = await this.sessionStore.load();
|
|
166
|
-
if (
|
|
302
|
+
if (!current) {
|
|
303
|
+
// Signed out while the mint was in flight. Saving a token now would
|
|
304
|
+
// quietly undo that, so drop everything and let the caller sign in.
|
|
305
|
+
log("DEBUG", "The saved session was removed during the refresh");
|
|
306
|
+
this.forgetSession();
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
if (!this.sameToken(current, stale)) {
|
|
310
|
+
await this.observe(current);
|
|
167
311
|
this.cachedToken = current;
|
|
168
312
|
if (this.isUsable(current))
|
|
169
313
|
return current;
|
|
@@ -187,6 +331,7 @@ export class TokenManager {
|
|
|
187
331
|
};
|
|
188
332
|
if (!await this.sessionStore.saveIfCurrent(token, stale))
|
|
189
333
|
return this.afterConcurrentChange();
|
|
334
|
+
await this.observe(token);
|
|
190
335
|
this.cachedToken = token;
|
|
191
336
|
this.rejectedAccessToken = null;
|
|
192
337
|
log("INFO", "Minted a fresh access token from the session cookie");
|
|
@@ -197,6 +342,7 @@ export class TokenManager {
|
|
|
197
342
|
if (!await this.sessionStore.clearIfCurrent(stale))
|
|
198
343
|
return this.afterConcurrentChange();
|
|
199
344
|
this.cachedToken = null;
|
|
345
|
+
this.observedFingerprint = undefined;
|
|
200
346
|
return null;
|
|
201
347
|
}
|
|
202
348
|
throw new TokenRefreshError(result.detail ?? "unexpected token service response");
|
|
@@ -206,6 +352,7 @@ export class TokenManager {
|
|
|
206
352
|
*/
|
|
207
353
|
async setToken(token) {
|
|
208
354
|
await this.sessionStore.save(token);
|
|
355
|
+
await this.observe(token);
|
|
209
356
|
this.cachedToken = token;
|
|
210
357
|
this.rejectedAccessToken = null;
|
|
211
358
|
log("DEBUG", "Token cached and persisted");
|
|
@@ -215,6 +362,9 @@ export class TokenManager {
|
|
|
215
362
|
*/
|
|
216
363
|
async clearToken() {
|
|
217
364
|
this.cachedToken = null;
|
|
365
|
+
this.identityToken = null;
|
|
366
|
+
this.observedFingerprint = undefined;
|
|
367
|
+
this.sessionReplaced = true;
|
|
218
368
|
await this.sessionStore.clear();
|
|
219
369
|
log("DEBUG", "Token cleared from memory and disk");
|
|
220
370
|
}
|
|
@@ -229,9 +379,17 @@ export class TokenManager {
|
|
|
229
379
|
a.cookieHeader === b.cookieHeader && a.csrfToken === b.csrfToken && a.tenantOrigin === b.tenantOrigin;
|
|
230
380
|
}
|
|
231
381
|
async afterConcurrentChange() {
|
|
232
|
-
|
|
233
|
-
if (
|
|
234
|
-
|
|
382
|
+
const loaded = await this.sessionStore.load();
|
|
383
|
+
if (!loaded) {
|
|
384
|
+
// Nothing is saved any more: the user signed out. That is not a refresh
|
|
385
|
+
// failure; the caller proceeds to the ordinary sign-in.
|
|
386
|
+
this.forgetSession();
|
|
387
|
+
return null;
|
|
388
|
+
}
|
|
389
|
+
await this.observe(loaded);
|
|
390
|
+
this.cachedToken = loaded;
|
|
391
|
+
if (this.isUsable(loaded))
|
|
392
|
+
return loaded;
|
|
235
393
|
throw new TokenRefreshError("saved authentication changed during refresh");
|
|
236
394
|
}
|
|
237
395
|
/**
|
package/build/auth-cli.js
CHANGED
|
@@ -12,7 +12,7 @@ import { NativeCredentialStoreError } from "./auth/credential-store.js";
|
|
|
12
12
|
import { retireLegacyProfile } from "./auth/legacy-profile.js";
|
|
13
13
|
import { formatPhaseMarker } from "./auth/auth-phases.js";
|
|
14
14
|
import { AuthenticationInProgressError } from "./auth/auth-lock.js";
|
|
15
|
-
import { AUTOMATIC_PENDING_MARKER,
|
|
15
|
+
import { AUTOMATIC_PENDING_MARKER, challengeMarker, relayedLockLines } from "./auth/mfa-challenge.js";
|
|
16
16
|
import { runLogout } from "./auth/logout.js";
|
|
17
17
|
import { configureDevActivity } from "./utils/dev-activity.js";
|
|
18
18
|
import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
|
|
@@ -111,11 +111,12 @@ async function main() {
|
|
|
111
111
|
}
|
|
112
112
|
// Another process's sign-in holds the lock and is showing a challenge:
|
|
113
113
|
// pass it on, so this caller can tell its user what to approve.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
// Before it has one, say that it is starting, so the caller can wait on
|
|
115
|
+
// it. Only the auth lock's own contention sets challenge/ownerMode
|
|
116
|
+
// (BrowserAuth.authenticate); any other lock stays a plain "busy".
|
|
117
|
+
if (automatic && error instanceof AuthenticationInProgressError) {
|
|
118
|
+
for (const line of relayedLockLines(error.challenge, error.ownerMode))
|
|
119
|
+
console.log(line);
|
|
119
120
|
}
|
|
120
121
|
process.exitCode = error instanceof NativeCredentialStoreError ? 5
|
|
121
122
|
: code === "AUTH_IN_PROGRESS" ? 2
|
package/build/index.js
CHANGED
|
@@ -18,7 +18,7 @@ import { configureDevActivity, trackToolActivity } from "./utils/dev-activity.js
|
|
|
18
18
|
import { readFileSync } from "node:fs";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
import { dirname, resolve } from "node:path";
|
|
21
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
21
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
22
22
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
23
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
24
|
const __dirname = dirname(__filename);
|
|
@@ -145,6 +145,7 @@ else {
|
|
|
145
145
|
registerGetVideoTranscript(server, apiClient);
|
|
146
146
|
registerGetServerInfo(server, config, PKG_VERSION, apiClient);
|
|
147
147
|
registerSearchCourse(server, apiClient);
|
|
148
|
+
registerSignOut(server, config, apiClient, tokenManager, authRunner);
|
|
148
149
|
// Instructor / TA dropbox tools (read-only) — 403 for a student account.
|
|
149
150
|
registerGetDropboxFolders(server, apiClient);
|
|
150
151
|
registerGetDropboxSubmissions(server, apiClient);
|
|
@@ -152,7 +153,7 @@ else {
|
|
|
152
153
|
registerGetDropboxFeedback(server, apiClient);
|
|
153
154
|
registerGetRubricsForObject(server, apiClient);
|
|
154
155
|
registerDownloadDropboxSubmissionFile(server, apiClient);
|
|
155
|
-
log("DEBUG", "MCP tools registered (
|
|
156
|
+
log("DEBUG", "MCP tools registered (26 tools)");
|
|
156
157
|
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
157
158
|
// picker, distinct from tools. Each one is a canned user message that
|
|
158
159
|
// names our own tools by name, so a client with no idea what to ask
|
|
@@ -165,7 +166,7 @@ else {
|
|
|
165
166
|
// Connect stdio transport
|
|
166
167
|
const transport = new StdioServerTransport();
|
|
167
168
|
await server.connect(transport);
|
|
168
|
-
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (
|
|
169
|
+
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (26 tools, 4 prompts registered)");
|
|
169
170
|
log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
|
|
170
171
|
}
|
|
171
172
|
catch (error) {
|
|
@@ -27,6 +27,7 @@ async function listNews(apiClient, courseId, newsId) {
|
|
|
27
27
|
export function registerGetAnnouncementFiles(server, apiClient) {
|
|
28
28
|
server.registerTool("get_announcement_files", {
|
|
29
29
|
title: "Get Announcement Files",
|
|
30
|
+
annotations: { readOnlyHint: true },
|
|
30
31
|
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
32
|
inputSchema: GetAnnouncementFilesSchema,
|
|
32
33
|
}, async (args) => {
|
|
@@ -99,6 +99,7 @@ export function mapNewsItem(item) {
|
|
|
99
99
|
export function registerGetAnnouncements(server, apiClient, config) {
|
|
100
100
|
server.registerTool("get_announcements", {
|
|
101
101
|
title: "Get Announcements",
|
|
102
|
+
annotations: { readOnlyHint: true },
|
|
102
103
|
description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
|
|
103
104
|
inputSchema: GetAnnouncementsSchema,
|
|
104
105
|
}, async (args) => {
|
|
@@ -26,6 +26,7 @@ async function listFolders(apiClient, courseId, folderId) {
|
|
|
26
26
|
export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
|
|
27
27
|
server.registerTool("get_assignment_files", {
|
|
28
28
|
title: "Get Assignment Files",
|
|
29
|
+
annotations: { readOnlyHint: true },
|
|
29
30
|
description: "Read the files an instructor attached to an assignment: the spec or instructions PDF, a starter workbook, a rubric document. Call it with just courseId to see which assignments have attachments, then with folderId and fileId to read one. Use this when the user asks what an assignment requires, what the instructions say, or to summarize a handout. Returns the text itself. Use download_file instead when the user wants the file saved to disk.",
|
|
30
31
|
inputSchema: GetAssignmentFilesSchema,
|
|
31
32
|
}, async (args) => {
|
|
@@ -256,6 +256,7 @@ export async function fetchAssignmentRubrics(apiClient, courseId, assignmentId)
|
|
|
256
256
|
export function registerGetAssignmentRubric(server, apiClient) {
|
|
257
257
|
server.registerTool("get_assignment_rubric", {
|
|
258
258
|
title: "Get Assignment Rubric",
|
|
259
|
+
annotations: { readOnlyHint: true },
|
|
259
260
|
description: "Fetch the full grading rubric for a dropbox assignment: every criteria group, criterion, and achievement level with its points and description, plus the student's own graded outcome per criterion once it has been released. Use this when the user asks what a rubric wants, how an assignment will be graded, or why they got a particular score on a criterion.",
|
|
260
261
|
inputSchema: GetAssignmentRubricSchema,
|
|
261
262
|
}, async (args) => {
|
|
@@ -425,6 +425,7 @@ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
|
425
425
|
export function registerGetAssignments(server, apiClient, config) {
|
|
426
426
|
server.registerTool("get_assignments", {
|
|
427
427
|
title: "Get Assignments",
|
|
428
|
+
annotations: { readOnlyHint: true },
|
|
428
429
|
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics. " +
|
|
429
430
|
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace.",
|
|
430
431
|
inputSchema: GetAssignmentsSchema,
|
|
@@ -15,6 +15,7 @@ const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
15
15
|
export function registerGetCalendarEvents(server, apiClient, config) {
|
|
16
16
|
server.registerTool("get_calendar_events", {
|
|
17
17
|
title: "Get Calendar Events",
|
|
18
|
+
annotations: { readOnlyHint: true },
|
|
18
19
|
description: "Fetch course calendar events — exams, midterms, labs, recitations, review sessions, schedule changes, and deadlines instructors typed straight onto the calendar — across all your courses or one course, in a time window (default: the next 7 days). Use this when the user asks when an exam is, what's on their calendar, or what's happening this week.",
|
|
19
20
|
inputSchema: GetCalendarEventsSchema,
|
|
20
21
|
}, async (args) => {
|
|
@@ -14,6 +14,7 @@ import { log } from "../utils/logger.js";
|
|
|
14
14
|
export function registerGetClasslistEmails(server, apiClient) {
|
|
15
15
|
server.registerTool("get_classlist_emails", {
|
|
16
16
|
title: "Get Classlist Emails",
|
|
17
|
+
annotations: { readOnlyHint: true },
|
|
17
18
|
description: "Fetch all email addresses for everyone in a course — instructors, TAs, and students. " +
|
|
18
19
|
"Use this when the user wants a list of emails for a class, needs to email the whole class, " +
|
|
19
20
|
"or wants contact info for everyone enrolled.",
|
|
@@ -228,6 +228,7 @@ function countModules(tree) {
|
|
|
228
228
|
export function registerGetCourseContent(server, apiClient) {
|
|
229
229
|
server.registerTool("get_course_content", {
|
|
230
230
|
title: "Get Course Content",
|
|
231
|
+
annotations: { readOnlyHint: true },
|
|
231
232
|
description: "Fetch the content tree for a course showing modules, topics, files, and links. Use this when the user asks about course materials, lecture slides, uploaded files, content structure, or what's in a course module. Use moduleTitle to filter to a specific module (e.g. 'Labs', 'Staff', 'Homeworks') instead of fetching the entire tree. Use maxDepth to limit recursion depth for a table-of-contents view. A module or topic that isn't currently available carries isAvailable/availabilityStatus/availabilityMessage (and startDate/endDate when known) explaining why; absence of these fields means it's available now.",
|
|
232
233
|
inputSchema: GetCourseContentSchema,
|
|
233
234
|
}, async (args) => {
|
|
@@ -34,6 +34,7 @@ export async function fetchForumTopics(apiClient, courseId, forumId) {
|
|
|
34
34
|
export function registerGetDiscussions(server, apiClient) {
|
|
35
35
|
server.registerTool("get_discussions", {
|
|
36
36
|
title: "Get Discussions",
|
|
37
|
+
annotations: { readOnlyHint: true },
|
|
37
38
|
description: "Fetch discussion board content for a course including forums, topics, and posts. Use this when the user asks about discussion boards, forum posts, class discussions, or wants to see what's been posted. Provide just courseId to list all forums and their topics. Add forumId to get topics and posts for a specific forum. Add both forumId and topicId to get all posts in a specific discussion topic.",
|
|
38
39
|
inputSchema: GetDiscussionsSchema,
|
|
39
40
|
}, async (args) => {
|
|
@@ -14,6 +14,7 @@ import { log } from "../utils/logger.js";
|
|
|
14
14
|
export function registerGetDropboxFeedback(server, apiClient) {
|
|
15
15
|
server.registerTool("get_dropbox_feedback", {
|
|
16
16
|
title: "Get Dropbox Feedback",
|
|
17
|
+
annotations: { readOnlyHint: true },
|
|
17
18
|
description: "Retrieve the existing feedback already saved for a specific user or group in a dropbox folder, " +
|
|
18
19
|
"for an instructor or TA — score, graded state, feedback text, and rubric assessment detail. " +
|
|
19
20
|
"Use this before drafting new feedback to see what has already been recorded. Requires " +
|
|
@@ -23,6 +23,7 @@ function submissionTypeLabel(t) {
|
|
|
23
23
|
export function registerGetDropboxFolders(server, apiClient) {
|
|
24
24
|
server.registerTool("get_dropbox_folders", {
|
|
25
25
|
title: "Get Dropbox Folders",
|
|
26
|
+
annotations: { readOnlyHint: true },
|
|
26
27
|
description: "List all assignment/dropbox folders for a course, for an instructor or TA. Returns folder ID, " +
|
|
27
28
|
"name, due date, start/end dates, submission type, visibility, and whether rubrics are attached. " +
|
|
28
29
|
"Use this to discover folderId values before calling get_dropbox_submissions, " +
|
|
@@ -27,6 +27,7 @@ async function mapWithConcurrency(items, concurrency, fn) {
|
|
|
27
27
|
export function registerGetDropboxSubmissions(server, apiClient) {
|
|
28
28
|
server.registerTool("get_dropbox_submissions", {
|
|
29
29
|
title: "Get Dropbox Submissions",
|
|
30
|
+
annotations: { readOnlyHint: true },
|
|
30
31
|
description: "List every student's (or group's) submission to a dropbox folder, for an instructor or TA — " +
|
|
31
32
|
"submitter names, submission dates, late status, file lists, and feedback/grading status. " +
|
|
32
33
|
"Use get_dropbox_folders first to find folderId. Requires instructor or TA access; a student " +
|
|
@@ -13,6 +13,7 @@ import { log } from "../utils/logger.js";
|
|
|
13
13
|
export function registerGetDropboxUserSubmissions(server, apiClient) {
|
|
14
14
|
server.registerTool("get_dropbox_user_submissions", {
|
|
15
15
|
title: "Get Dropbox User Submissions",
|
|
16
|
+
annotations: { readOnlyHint: true },
|
|
16
17
|
description: "Retrieve all submissions made by one specific student (or group) in a dropbox folder, for an " +
|
|
17
18
|
"instructor or TA — useful for reviewing a single student's work before drafting feedback. " +
|
|
18
19
|
"Use get_dropbox_submissions or get_roster to find the userId first. Requires instructor or TA " +
|
|
@@ -15,6 +15,7 @@ import { applyCourseFilter } from "../utils/course-filter.js";
|
|
|
15
15
|
export function registerGetMyCourses(server, apiClient, config) {
|
|
16
16
|
server.registerTool("get_my_courses", {
|
|
17
17
|
title: "Get My Courses",
|
|
18
|
+
annotations: { readOnlyHint: true },
|
|
18
19
|
description: "Fetch your enrolled Brightspace courses with names, codes, and IDs. Use this when the user asks about their courses, enrolled classes, what they're taking this semester, or needs a course ID for other queries.",
|
|
19
20
|
inputSchema: GetMyCoursesSchema,
|
|
20
21
|
}, async (args) => {
|
|
@@ -71,6 +71,7 @@ function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
|
|
|
71
71
|
export function registerGetMyGrades(server, apiClient, config) {
|
|
72
72
|
server.registerTool("get_my_grades", {
|
|
73
73
|
title: "Get My Grades",
|
|
74
|
+
annotations: { readOnlyHint: true },
|
|
74
75
|
description: "Fetch your grade breakdown for a specific course or all enrolled courses. Shows grade items with points, percentages, and comments. Use this when the user asks about grades, scores, marks, GPA, academic performance, or how they're doing in a class.",
|
|
75
76
|
inputSchema: GetMyGradesSchema,
|
|
76
77
|
}, async (args) => {
|
|
@@ -19,6 +19,7 @@ function isMissingOrForbidden(error) {
|
|
|
19
19
|
export function registerGetMyGroups(server, apiClient) {
|
|
20
20
|
server.registerTool("get_my_groups", {
|
|
21
21
|
title: "Get My Groups",
|
|
22
|
+
annotations: { readOnlyHint: true },
|
|
22
23
|
description: "List the current user's project/discussion groups in a course, with each group's members. " +
|
|
23
24
|
"Use this when a student asks who is in their project group, lab group, or discussion group.",
|
|
24
25
|
inputSchema: GetMyGroupsSchema,
|
|
@@ -48,6 +48,7 @@ export async function fetchClasslistUsers(apiClient, courseId, options) {
|
|
|
48
48
|
export function registerGetRoster(server, apiClient) {
|
|
49
49
|
server.registerTool("get_roster", {
|
|
50
50
|
title: "Get Course Roster",
|
|
51
|
+
annotations: { readOnlyHint: true },
|
|
51
52
|
description: "Fetch the roster for a course including instructors, TAs, and optionally students with their names, emails, and roles. Use this when the user asks about classmates, instructor contact info, TA emails, professor names, or who's in a class. By default returns only instructors and TAs for privacy. Use includeStudents to get full class list.",
|
|
52
53
|
inputSchema: GetRosterSchema,
|
|
53
54
|
}, async (args) => {
|
|
@@ -13,6 +13,7 @@ import { log } from "../utils/logger.js";
|
|
|
13
13
|
export function registerGetRubricsForObject(server, apiClient) {
|
|
14
14
|
server.registerTool("get_rubrics_for_object", {
|
|
15
15
|
title: "Get Rubrics for Dropbox Folder",
|
|
16
|
+
annotations: { readOnlyHint: true },
|
|
16
17
|
description: "Retrieve the full rubric table (criteria, levels, and point values) attached to a dropbox " +
|
|
17
18
|
"folder, for an instructor or TA. Use get_dropbox_folders first to find folderId. Like " +
|
|
18
19
|
"get_dropbox_folders, this reads the folder listing's embedded rubric data, so it is not " +
|
|
@@ -56,6 +56,7 @@ async function defaultReadSignedInIdentity(sessionDir) {
|
|
|
56
56
|
export function registerGetServerInfo(server, config, version, apiClient, readSignedInIdentity = () => defaultReadSignedInIdentity(config.sessionDir)) {
|
|
57
57
|
server.registerTool("get_server_info", {
|
|
58
58
|
title: "Get Server Info",
|
|
59
|
+
annotations: { readOnlyHint: true },
|
|
59
60
|
description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, the server's local timezone and UTC offset, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, (when signed in) the account's uniqueName/displayName as signedInAs, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting, when the user asks which version they have, or what timezone/account dates are being computed against. Never contacts Brightspace and never returns secrets.",
|
|
60
61
|
inputSchema: GetServerInfoSchema,
|
|
61
62
|
}, async () => {
|
|
@@ -183,6 +183,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
183
183
|
export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
184
184
|
server.registerTool("get_upcoming_due_dates", {
|
|
185
185
|
title: "Get Upcoming Due Dates",
|
|
186
|
+
annotations: { readOnlyHint: true },
|
|
186
187
|
description: "Fetch upcoming due dates across all your courses, derived from the due dates on assignments (dropbox folders), quizzes, and graded discussion topics themselves, plus course calendar events such as exams and labs (type: event). Use this when the user asks about deadlines, what's due, upcoming work, or what they need to do this week.",
|
|
187
188
|
inputSchema: GetUpcomingDueDatesSchema,
|
|
188
189
|
}, async (args) => {
|
|
@@ -109,6 +109,7 @@ async function fetchTranscript(platform, videoUrl, fetchImpl) {
|
|
|
109
109
|
export function registerGetVideoTranscript(server, apiClient, fetchImpl = fetch) {
|
|
110
110
|
server.registerTool("get_video_transcript", {
|
|
111
111
|
title: "Get Video Transcript",
|
|
112
|
+
annotations: { readOnlyHint: true },
|
|
112
113
|
description: "Read the transcript of a video embedded in course content, such as a recorded lecture or explainer clip. " +
|
|
113
114
|
"Call it with courseId and topicId from get_course_content (typeFilter: 'video' or 'other'), or with videoUrl " +
|
|
114
115
|
"directly if you already have the link. Returns transcript text with timestamps, plus title and duration when " +
|
package/build/tools/index.js
CHANGED
|
@@ -23,6 +23,7 @@ export { registerGetDiscussions } from "./get-discussions.js";
|
|
|
23
23
|
export { registerGetVideoTranscript } from "./get-video-transcript.js";
|
|
24
24
|
export { registerGetServerInfo } from "./get-server-info.js";
|
|
25
25
|
export { registerSearchCourse } from "./search-course.js";
|
|
26
|
+
export { registerSignOut } from "./sign-out.js";
|
|
26
27
|
// Instructor / TA dropbox tools (read-only)
|
|
27
28
|
export { registerGetDropboxFolders } from "./get-dropbox-folders.js";
|
|
28
29
|
export { registerGetDropboxSubmissions } from "./get-dropbox-submissions.js";
|
package/build/tools/schemas.js
CHANGED
|
@@ -159,6 +159,7 @@ export const GetMyGroupsSchema = z.object({
|
|
|
159
159
|
.describe("Course ID to list the current user's project/discussion groups for."),
|
|
160
160
|
});
|
|
161
161
|
export const GetServerInfoSchema = z.object({});
|
|
162
|
+
export const SignOutSchema = z.object({});
|
|
162
163
|
// ── Instructor / TA dropbox tools ──────────────────────────────────────────
|
|
163
164
|
// Read-only, instructor/TA-side views of a course's dropbox (assignment)
|
|
164
165
|
// folders and the submissions inside them. These answer "who has/hasn't
|
|
@@ -172,6 +172,7 @@ function describeFailure(error) {
|
|
|
172
172
|
export function registerSearchCourse(server, apiClient) {
|
|
173
173
|
server.registerTool("search_course", {
|
|
174
174
|
title: "Search Course",
|
|
175
|
+
annotations: { readOnlyHint: true },
|
|
175
176
|
description: "Search a course's content (modules, topics, file names), announcements, and discussion forums/topics by keyword in a single call, instead of reading the whole content tree. Use this when the user wants to find something specific, e.g. 'find the midterm review slides' or 'did anyone post about office hours'. Results are ranked: a result matching every query term ranks above one matching only some, and within that, a match in the title ranks above one only in the body text. If one source (e.g. discussions) can't be read, it's skipped and named in `note` rather than failing the whole search. This fans out over the entire content tree, announcements, and every discussion forum, so it can be slower than calling a single tool like get_course_content directly.",
|
|
176
177
|
inputSchema: SearchCourseSchema,
|
|
177
178
|
}, async (args) => {
|
|
@@ -0,0 +1,82 @@
|
|
|
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 { AuthenticationInProgressError } from "../auth/auth-lock.js";
|
|
7
|
+
import { clearSavedSession, RemoveError } from "../auth/logout.js";
|
|
8
|
+
import { log } from "../utils/logger.js";
|
|
9
|
+
import { SignOutSchema } from "./schemas.js";
|
|
10
|
+
import { withUpdateNotice } from "./tool-helpers.js";
|
|
11
|
+
const KEPT = "Kept (if you set them up): your saved password, your settings, and your authenticator key.";
|
|
12
|
+
const OTHER_APPS = "Other Brightspace apps open on this computer are signed out too and will sign in again from scratch on their next question. An app you opened before updating to this version may keep answering for up to an hour; quit and reopen it to sign it out right away.";
|
|
13
|
+
const NEXT_SIGN_IN = "Because your saved password (and authenticator key, if you saved one) stay on this computer, that next sign-in may just ask you to approve it, or finish on its own if you saved an authenticator key. On a shared computer, also delete the saved \"brightspace-mcp-server\" entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows).";
|
|
14
|
+
const LOCAL_ONLY = "This only clears this computer. It does not sign you out of Microsoft or Brightspace on other devices.";
|
|
15
|
+
const IN_PROGRESS = "Not signed out: a sign-in is in progress on this computer, so nothing was deleted. If you don't want to sign in, don't approve the prompt. Tell the user to ask again after the sign-in finishes or times out; do not call sign_out again on your own.";
|
|
16
|
+
const ALREADY_SIGNED_OUT = "You're already signed out of Brightspace on this computer; there was no saved session to remove.";
|
|
17
|
+
const UNEXPECTED_FAILURE = "Could not clear the saved session because of an unexpected error. Nothing more was changed; try again, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal to see the details.";
|
|
18
|
+
function text(message, isError = false) {
|
|
19
|
+
const result = withUpdateNotice([{ type: "text", text: message }]);
|
|
20
|
+
return isError ? { ...result, isError: true } : result;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Register sign_out tool.
|
|
24
|
+
*
|
|
25
|
+
* The in-chat version of `auth --logout`: it deletes this computer's saved
|
|
26
|
+
* session for the account the server is configured with (clearSavedSession,
|
|
27
|
+
* under the sign-in lock, so a running sign-in is never disturbed), then
|
|
28
|
+
* empties the API client's response cache and this process's in-memory token
|
|
29
|
+
* so it serves nothing from the session it just signed out of. Answers are
|
|
30
|
+
* plain text, not JSON. A sign-in in progress and an env-supplied credential
|
|
31
|
+
* are ordinary answers rather than errors, so a model has no reason to retry
|
|
32
|
+
* in a loop; only a file that could not be deleted is reported as a failure.
|
|
33
|
+
*
|
|
34
|
+
* authRunner covers the moment between this server spawning a sign-in and
|
|
35
|
+
* that child taking the cross-process lock, when the lock alone still looks
|
|
36
|
+
* free. It is absent when the server uses a pasted credential (no runner).
|
|
37
|
+
*
|
|
38
|
+
* clear is an injection seam: tests point it at a temporary directory.
|
|
39
|
+
*/
|
|
40
|
+
export function registerSignOut(server, config, apiClient, tokenManager, authRunner, clear = clearSavedSession) {
|
|
41
|
+
server.registerTool("sign_out", {
|
|
42
|
+
title: "Sign Out of Brightspace",
|
|
43
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
44
|
+
description: "Sign out of Brightspace on this computer: delete the saved Brightspace session, so every Brightspace app here must sign in again from scratch on its next question. Keeps the saved password, settings, and authenticator key. Does not sign out of Microsoft or Brightspace on other devices. Call this ONLY when the user explicitly asks to sign out or log out of Brightspace (a shared computer, handing the machine on). Never call it to fix an error, never as part of answering another question, and never to retry or refresh a sign-in.",
|
|
45
|
+
inputSchema: SignOutSchema,
|
|
46
|
+
}, async () => {
|
|
47
|
+
if (config.envAccessToken || config.envSessionCookie) {
|
|
48
|
+
return text("Nothing was signed out. This server uses a credential from its configuration (D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE, set in your AI app's client configuration or a .env file), and sign_out cannot remove that. To stop using it, remove that setting and restart the app.");
|
|
49
|
+
}
|
|
50
|
+
if (authRunner?.busy())
|
|
51
|
+
return text(IN_PROGRESS);
|
|
52
|
+
const dropInMemory = () => {
|
|
53
|
+
apiClient.clearCache();
|
|
54
|
+
tokenManager.forgetSession();
|
|
55
|
+
};
|
|
56
|
+
try {
|
|
57
|
+
const { removed } = await clear(config.sessionDir);
|
|
58
|
+
dropInMemory();
|
|
59
|
+
if (removed.length === 0)
|
|
60
|
+
return text(ALREADY_SIGNED_OUT);
|
|
61
|
+
return text([
|
|
62
|
+
"Signed out of Brightspace on this computer.",
|
|
63
|
+
`Removed: ${removed.join(", ")}`,
|
|
64
|
+
KEPT,
|
|
65
|
+
OTHER_APPS,
|
|
66
|
+
NEXT_SIGN_IN,
|
|
67
|
+
LOCAL_ONLY,
|
|
68
|
+
].join("\n"));
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof AuthenticationInProgressError)
|
|
72
|
+
return text(IN_PROGRESS);
|
|
73
|
+
dropInMemory();
|
|
74
|
+
if (error instanceof RemoveError) {
|
|
75
|
+
log("ERROR", "sign_out could not remove a session file", error);
|
|
76
|
+
return text(`Sign-out is incomplete: ${error.file} could not be deleted, so part of the saved session is still on this computer. Close anything that may be using that file, then ask to sign out again to finish.`, true);
|
|
77
|
+
}
|
|
78
|
+
log("ERROR", "sign_out failed", error);
|
|
79
|
+
return text(UNEXPECTED_FAILURE, true);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -117,9 +117,9 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
117
117
|
"as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
|
|
118
118
|
MFA_SOLO_CALL,
|
|
119
119
|
inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
|
|
120
|
-
"school's login pages). Call this tool again right away: the next call
|
|
121
|
-
"sign-in
|
|
122
|
-
"
|
|
120
|
+
"school's login pages). Call this tool again right away: the next call picks up that " +
|
|
121
|
+
"sign-in and returns the number to approve as soon as one appears. Don't ask the user to " +
|
|
122
|
+
"do anything yet.",
|
|
123
123
|
};
|
|
124
124
|
/**
|
|
125
125
|
* mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.11",
|
|
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",
|