brightspace-mcp-server 3.9.26 → 3.10.1
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 +1 -1
- package/build/auth/auth-runner.js +60 -5
- package/build/index.js +6 -3
- package/build/tools/index.js +1 -0
- package/build/tools/mfa-relay.js +54 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
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);
|
package/build/tools/index.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.10.1",
|
|
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",
|