brightspace-mcp-server 3.9.26 → 3.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -104,7 +104,7 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
104
104
 
105
105
  ## When it asks you to sign in
106
106
 
107
- There's no separate login — asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up right in the answer; approve it on your phone and ask again. If it ever gets stuck, run this in a terminal:
107
+ There's no separate login — asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up right in the answer; approve it on your phone and ask again. If your AI client shows tool progress messages, the number appears while the question is still running instead, and approving within 45 seconds (less if sign-in was slow to reach two-factor) finishes the original answer without asking again. If it ever gets stuck, run this in a terminal:
108
108
 
109
109
  ```bash
110
110
  npx -y brightspace-mcp-server@latest auth
@@ -28,6 +28,25 @@ const KILL_GRACE_MS = 5000;
28
28
  * approval window.
29
29
  */
30
30
  const JOIN_GRACE_MS = 5000;
31
+ /**
32
+ * How long a caller that can be told the challenge mid-call (see run()'s
33
+ * onChallenge) keeps waiting for the background sign-in after the challenge
34
+ * appears: long enough to find a phone and approve.
35
+ */
36
+ const MFA_POLL_MS = 45000;
37
+ /**
38
+ * The latest a polling caller is answered, counted from when it called run().
39
+ * Browser launch and the SSO pages can take half a minute before the
40
+ * challenge even appears, so the poll is cut short to fit this budget. It
41
+ * stays under the 60-second request timeout MCP clients commonly apply, so a
42
+ * client that never shows the mid-call notice still gets the number in the
43
+ * answer.
44
+ */
45
+ const CALL_BUDGET_MS = 55000;
46
+ /** How long to poll after a challenge seen elapsedMs into the call. */
47
+ function pollWindowMs(elapsedMs) {
48
+ return Math.min(MFA_POLL_MS, CALL_BUDGET_MS - elapsedMs);
49
+ }
31
50
  /**
32
51
  * The only two lines auth-cli.ts is allowed to hand back as structured data.
33
52
  * Deliberately strict (whole line, 1-3 digits or the literal word) so this
@@ -161,8 +180,35 @@ export class AuthRunner {
161
180
  /**
162
181
  * Authenticate, joining the login this process already started if there is
163
182
  * one. Returns true on success and throws a useful error on failure.
183
+ *
184
+ * Without onChallenge, an MFA challenge answers the caller at once with an
185
+ * mfaPending error. With it, the caller is handed the challenge (the
186
+ * number to enter, if any) and keeps waiting on the background sign-in for
187
+ * up to MFA_POLL_MS, and never past CALL_BUDGET_MS from the call, so an
188
+ * approval within that window completes the caller's original request;
189
+ * past it, the caller gets the same mfaPending answer it would have had up
190
+ * front.
164
191
  */
165
- async run() {
192
+ async run(onChallenge) {
193
+ const startedAt = Date.now();
194
+ try {
195
+ return await this.runOnce();
196
+ }
197
+ catch (error) {
198
+ const childDone = this.childDone;
199
+ if (!onChallenge || !childDone || !(error instanceof AuthProcessError) || error.kind !== "mfaPending")
200
+ throw error;
201
+ const windowMs = pollWindowMs(Date.now() - startedAt);
202
+ if (windowMs <= 0)
203
+ throw error;
204
+ try {
205
+ onChallenge(error.numberMatch);
206
+ }
207
+ catch { /* Announcing must not interrupt authentication. */ }
208
+ return this.awaitBackgroundChild(childDone, windowMs);
209
+ }
210
+ }
211
+ runOnce() {
166
212
  if (this.inFlight) {
167
213
  log("DEBUG", "Joining the authentication already in flight");
168
214
  return this.inFlight;
@@ -204,17 +250,26 @@ export class AuthRunner {
204
250
  if (!this.pendingChallenge && this.challengeSignal) {
205
251
  await Promise.race([childDone.catch(() => { }), this.challengeSignal]);
206
252
  }
207
- const challenge = this.pendingChallenge;
208
- if (!challenge)
253
+ if (!this.pendingChallenge)
209
254
  return childDone;
255
+ return this.awaitBackgroundChild(childDone, JOIN_GRACE_MS);
256
+ }
257
+ /**
258
+ * Race a background child already past its MFA challenge against a wait
259
+ * of graceMs. The child's own outcome wins if it lands in time; otherwise
260
+ * the caller is re-answered with the latest challenge the child reported.
261
+ */
262
+ awaitBackgroundChild(childDone, graceMs) {
263
+ const challenge = this.pendingChallenge;
210
264
  return new Promise((resolve, reject) => {
211
265
  let settled = false;
212
266
  const graceTimer = setTimeout(() => {
213
267
  if (settled)
214
268
  return;
215
269
  settled = true;
216
- reject(new AuthProcessError(...mfaPendingFailure(challenge.numberMatch), challenge.numberMatch));
217
- }, JOIN_GRACE_MS);
270
+ const numberMatch = this.pendingChallenge?.numberMatch ?? challenge?.numberMatch;
271
+ reject(new AuthProcessError(...mfaPendingFailure(numberMatch), numberMatch));
272
+ }, graceMs);
218
273
  graceTimer.unref?.();
219
274
  childDone.then((value) => {
220
275
  if (settled)
package/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
16
16
  import { readFileSync } from "node:fs";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { dirname, resolve } from "node:path";
19
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, } from "./tools/index.js";
19
+ 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";
20
20
  import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
21
21
  const __filename = fileURLToPath(import.meta.url);
22
22
  const __dirname = dirname(__filename);
@@ -92,7 +92,7 @@ else {
92
92
  const apiClient = new D2LApiClient({
93
93
  baseUrl: config.baseUrl,
94
94
  tokenManager,
95
- onAuthExpired: authRunner ? () => authRunner.run() : undefined,
95
+ onAuthExpired: authRunner ? () => authRunner.run(currentMfaAnnouncer()) : undefined,
96
96
  authExpiredMessage: config.envAccessToken
97
97
  ? "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set."
98
98
  : config.envSessionCookie
@@ -118,7 +118,10 @@ else {
118
118
  activeOnly: config.courseFilter.activeOnly,
119
119
  });
120
120
  }
121
- // Register MCP tools
121
+ // Register MCP tools. Each one, when its client sends a progress token,
122
+ // shows an MFA challenge mid-call and waits for the approval instead of
123
+ // asking to be called again (see relayMfaChallenges).
124
+ relayMfaChallenges(server);
122
125
  registerGetMyCourses(server, apiClient, config);
123
126
  registerGetUpcomingDueDates(server, apiClient, config);
124
127
  registerGetCalendarEvents(server, apiClient, config);
@@ -32,4 +32,5 @@ export { registerGetRubricsForObject } from "./get-rubrics-for-object.js";
32
32
  export { registerDownloadDropboxSubmissionFile } from "./download-dropbox-submission-file.js";
33
33
  // Re-export shared helpers and schemas for convenience
34
34
  export { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
35
+ export { relayMfaChallenges, currentMfaAnnouncer } from "./mfa-relay.js";
35
36
  export * from "./schemas.js";
@@ -0,0 +1,54 @@
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 { AsyncLocalStorage } from "node:async_hooks";
7
+ const announcers = new AsyncLocalStorage();
8
+ /**
9
+ * The announcer for the tool call currently running, if its client can be
10
+ * told about an MFA challenge before the call returns. The API client's
11
+ * re-auth hook hands this to AuthRunner.run(), which then keeps the call
12
+ * waiting on the sign-in instead of answering "call this tool again".
13
+ */
14
+ export function currentMfaAnnouncer() {
15
+ return announcers.getStore();
16
+ }
17
+ /**
18
+ * The text of the mid-call notice. numberMatch is the 1-3 digit value
19
+ * AuthRunner parsed from a strictly matched marker, never free child output.
20
+ */
21
+ function challengeNotice(numberMatch) {
22
+ const step = numberMatch
23
+ ? `Open Microsoft Authenticator and enter ${numberMatch} within 5 minutes.`
24
+ : "Approve the sign-in request on your phone (Microsoft Authenticator or Duo).";
25
+ return `${step} Waiting for the sign-in to finish…`;
26
+ }
27
+ /**
28
+ * Make every tool registered on this server from here on announce MFA
29
+ * challenges mid-call.
30
+ *
31
+ * A client that sends a progress token with tools/call can show progress
32
+ * messages while the call is still open, so the challenge goes out as a
33
+ * progress notification on that call. A client that sends none gets no
34
+ * announcer, and its calls keep answering the moment a challenge appears.
35
+ */
36
+ export function relayMfaChallenges(server) {
37
+ const register = server.registerTool.bind(server);
38
+ server.registerTool = ((name, config, callback) => register(name, config, ((...args) => {
39
+ // The request context is always the handler's last argument.
40
+ const extra = args[args.length - 1];
41
+ const progressToken = extra?._meta?.progressToken;
42
+ let progress = 0;
43
+ const announce = progressToken === undefined
44
+ ? undefined
45
+ : (numberMatch) => {
46
+ progress += 1;
47
+ void extra.sendNotification({
48
+ method: "notifications/progress",
49
+ params: { progressToken, progress, message: challengeNotice(numberMatch) },
50
+ }).catch(() => { });
51
+ };
52
+ return announcers.run(announce, () => callback(...args));
53
+ })));
54
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.26",
3
+ "version": "3.10.0",
4
4
  "mcpName": "io.github.rohanmuppa/brightspace",
5
5
  "description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
6
6
  "type": "module",