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 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`. 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. An AI app that is already running can keep answering until its current access token expires; restart the app to drop it 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
+ **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
 
@@ -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.
@@ -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 new SessionStore(sessionDir).remove())
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 = "A running Brightspace MCP server may still hold an access token in memory until it expires. Restart your AI client to drop it right away.";
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 (current && !this.sameToken(current, stale)) {
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
- this.cachedToken = await this.sessionStore.load();
233
- if (this.cachedToken && this.isUsable(this.cachedToken))
234
- return this.cachedToken;
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 (25 tools)");
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 (25 tools, 4 prompts registered)");
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",
@@ -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";
@@ -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.10",
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",