brightspace-mcp-server 3.13.10 → 3.13.12
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-runner.js +10 -0
- package/build/auth/logout.js +15 -3
- package/build/auth/session-store.js +23 -0
- package/build/auth/token-manager.js +163 -5
- package/build/index.js +4 -3
- package/build/tools/get-upcoming-due-dates.js +6 -0
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +1 -0
- package/build/tools/sign-out.js +82 -0
- 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
|
}
|
|
@@ -322,6 +322,16 @@ export class AuthRunner {
|
|
|
322
322
|
this.onProgress = options.onProgress;
|
|
323
323
|
this.sessionDir = options.sessionDir;
|
|
324
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
|
+
}
|
|
325
335
|
/**
|
|
326
336
|
* Authenticate, joining the login this process already started if there is
|
|
327
337
|
* one. Returns true on success and throws a useful error on failure.
|
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
|
|
@@ -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/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) {
|
|
@@ -101,6 +101,12 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
101
101
|
continue;
|
|
102
102
|
if (!folder.DueDate)
|
|
103
103
|
continue;
|
|
104
|
+
// Check the window before resolving the link: a group folder's link
|
|
105
|
+
// costs group-membership requests, wasted on an item the handler's
|
|
106
|
+
// window filter would drop anyway (issue #230).
|
|
107
|
+
const due = new Date(folder.DueDate).getTime();
|
|
108
|
+
if (!Number.isFinite(due) || due < from || due > to)
|
|
109
|
+
continue;
|
|
104
110
|
const link = await assignmentLink(folder);
|
|
105
111
|
items.push({
|
|
106
112
|
type: "assignment",
|
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
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.13.
|
|
3
|
+
"version": "3.13.12",
|
|
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",
|