brightspace-mcp-server 3.11.6 → 3.11.8
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/build/auth/auth-phases.js +21 -0
- package/build/auth/auth-runner.js +35 -13
- package/build/auth/browser-auth.js +44 -5
- package/build/auth-cli.js +6 -1
- package/build/tools/get-announcements.js +31 -12
- package/build/tools/schemas.js +2 -1
- package/build/tools/tool-helpers.js +4 -0
- package/build/utils/dev-activity.js +4 -1
- package/package.json +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The browser sign-in stages whose durations the automatic child reports to
|
|
8
|
+
* the server (issue #182). A fixed list, so a stage name is never arbitrary
|
|
9
|
+
* child output by the time it reaches the dev activity log.
|
|
10
|
+
*/
|
|
11
|
+
export const AUTH_PHASES = ["launch", "navigation", "silentSso", "credentials", "approvalWait", "token"];
|
|
12
|
+
const PHASE_MARKER = new RegExp(`^AUTH_PHASE:(${AUTH_PHASES.join("|")}):(\\d{1,9})$`);
|
|
13
|
+
/** The stdout line the automatic child prints when a stage ends. */
|
|
14
|
+
export function formatPhaseMarker(phase, elapsedMs) {
|
|
15
|
+
return `AUTH_PHASE:${phase}:${Math.max(0, Math.round(elapsedMs))}`;
|
|
16
|
+
}
|
|
17
|
+
/** The stage and duration from a whole stdout line, or null for anything else. */
|
|
18
|
+
export function parsePhaseMarker(line) {
|
|
19
|
+
const match = PHASE_MARKER.exec(line);
|
|
20
|
+
return match ? { phase: match[1], elapsedMs: Number(match[2]) } : null;
|
|
21
|
+
}
|
|
@@ -10,6 +10,7 @@ import { log } from "../utils/logger.js";
|
|
|
10
10
|
import { AuthError } from "../utils/errors.js";
|
|
11
11
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
12
12
|
import { devActivity } from "../utils/dev-activity.js";
|
|
13
|
+
import { parsePhaseMarker } from "./auth-phases.js";
|
|
13
14
|
/**
|
|
14
15
|
* Timeout for the auth process. It has to outlast the child's own MFA wait,
|
|
15
16
|
* which is five minutes: a person has to find their phone, unlock it, and read
|
|
@@ -31,12 +32,13 @@ const KILL_GRACE_MS = 5000;
|
|
|
31
32
|
*/
|
|
32
33
|
const MFA_POLL_MS = 45000;
|
|
33
34
|
/**
|
|
34
|
-
* The latest
|
|
35
|
+
* The latest any caller is answered, counted from when it called run().
|
|
35
36
|
* Browser launch and the SSO pages can take half a minute before the
|
|
36
|
-
* challenge even appears, so the poll is cut short to fit this budget
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* challenge even appears, so the poll is cut short to fit this budget, and a
|
|
38
|
+
* sign-in that has not reached MFA by then answers "still signing in" while
|
|
39
|
+
* it keeps running. It stays under the 60-second request timeout MCP clients
|
|
40
|
+
* commonly apply, so a client that never shows the mid-call notice still gets
|
|
41
|
+
* the number in the answer.
|
|
40
42
|
*/
|
|
41
43
|
const CALL_BUDGET_MS = 55000;
|
|
42
44
|
/** How long to poll after a challenge seen elapsedMs into the call. */
|
|
@@ -44,11 +46,12 @@ function pollWindowMs(elapsedMs) {
|
|
|
44
46
|
return Math.min(MFA_POLL_MS, CALL_BUDGET_MS - elapsedMs);
|
|
45
47
|
}
|
|
46
48
|
/**
|
|
47
|
-
* The only two lines auth-cli.ts is allowed to hand back as structured data
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* The only two lines auth-cli.ts is allowed to hand back as structured data
|
|
50
|
+
* for a tool response; parsePhaseMarker adds stage timings for the dev
|
|
51
|
+
* activity log only. Deliberately strict (whole line, 1-3 digits or the
|
|
52
|
+
* literal word) so this can never become a channel for arbitrary
|
|
53
|
+
* child-process text to reach a tool response — anything that doesn't match
|
|
54
|
+
* exactly is just another log line.
|
|
52
55
|
*/
|
|
53
56
|
const MFA_NUMBER_MARKER = /^MFA_NUMBER:(\d{1,3})$/;
|
|
54
57
|
const MFA_PENDING_MARKER = /^MFA_PENDING$/;
|
|
@@ -75,6 +78,19 @@ export class AuthProcessError extends AuthError {
|
|
|
75
78
|
this.name = "AuthProcessError";
|
|
76
79
|
}
|
|
77
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Settle with work's outcome, or with inProgress once CALL_BUDGET_MS has
|
|
83
|
+
* passed since startedAt. Only the caller stops waiting: work carries on.
|
|
84
|
+
*/
|
|
85
|
+
function withinCallBudget(work, startedAt) {
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
const budgetTimer = setTimeout(() => {
|
|
88
|
+
reject(new AuthProcessError("inProgress", "Sign-in is still running in the background. Try again."));
|
|
89
|
+
}, Math.max(0, CALL_BUDGET_MS - (Date.now() - startedAt)));
|
|
90
|
+
budgetTimer.unref?.();
|
|
91
|
+
work.then((value) => { clearTimeout(budgetTimer); resolve(value); }, (error) => { clearTimeout(budgetTimer); reject(error); });
|
|
92
|
+
});
|
|
93
|
+
}
|
|
78
94
|
/** Chromium can lead a separate process group, so a forced stop needs its PID. */
|
|
79
95
|
function descendantPids(parentPid) {
|
|
80
96
|
const rows = execFileSync("ps", ["-eo", "pid=,ppid="], {
|
|
@@ -185,12 +201,14 @@ export class AuthRunner {
|
|
|
185
201
|
* and the first call itself keeps waiting the same way. Either way an
|
|
186
202
|
* approval within the window completes the caller's original request; past
|
|
187
203
|
* it, the caller gets the same mfaPending answer again and is expected to
|
|
188
|
-
* call once more.
|
|
204
|
+
* call once more. No caller waits past CALL_BUDGET_MS: a sign-in still
|
|
205
|
+
* short of its challenge by then answers inProgress and keeps running for
|
|
206
|
+
* the next call to join.
|
|
189
207
|
*/
|
|
190
208
|
async run(onChallenge) {
|
|
191
209
|
const startedAt = Date.now();
|
|
192
210
|
try {
|
|
193
|
-
return await this.runOnce(startedAt);
|
|
211
|
+
return await withinCallBudget(this.runOnce(startedAt), startedAt);
|
|
194
212
|
}
|
|
195
213
|
catch (error) {
|
|
196
214
|
const childDone = this.childDone;
|
|
@@ -440,7 +458,11 @@ export class AuthRunner {
|
|
|
440
458
|
// immediately, without killing the child — see the class doc comment.
|
|
441
459
|
forwardLines(child.stdout, (line) => {
|
|
442
460
|
const numberMarker = MFA_NUMBER_MARKER.exec(line);
|
|
443
|
-
|
|
461
|
+
const phaseMarker = parsePhaseMarker(line);
|
|
462
|
+
if (phaseMarker) {
|
|
463
|
+
devActivity("auth_phase", phaseMarker);
|
|
464
|
+
}
|
|
465
|
+
else if (numberMarker) {
|
|
444
466
|
numberMatch = numberMarker[1];
|
|
445
467
|
publishChallenge(numberMatch);
|
|
446
468
|
}
|
|
@@ -51,12 +51,37 @@ export class BrowserAuth {
|
|
|
51
51
|
ssoFlow;
|
|
52
52
|
stateStore;
|
|
53
53
|
cooldown;
|
|
54
|
+
onPhase;
|
|
55
|
+
/** When the current login first reported an MFA challenge; splits credentials from approvalWait. */
|
|
56
|
+
challengeSeenAt;
|
|
54
57
|
constructor(config, options = {}) {
|
|
55
58
|
this.config = config;
|
|
56
|
-
this.
|
|
59
|
+
this.onPhase = options.onPhase;
|
|
60
|
+
const { onMfaChallenge } = options;
|
|
61
|
+
this.ssoFlow = createSSOFlow(config, options.requestMfaCode, onMfaChallenge && ((number) => {
|
|
62
|
+
this.challengeSeenAt ??= Date.now();
|
|
63
|
+
onMfaChallenge(number);
|
|
64
|
+
}));
|
|
57
65
|
this.stateStore = new BrowserStateStore(config.sessionDir);
|
|
58
66
|
this.cooldown = new AuthCooldown(config.sessionDir);
|
|
59
67
|
}
|
|
68
|
+
/** Report a stage that ran from startedAt to endedAt; a listener failure never interrupts sign-in. */
|
|
69
|
+
phaseEnded(phase, startedAt, endedAt = Date.now()) {
|
|
70
|
+
try {
|
|
71
|
+
this.onPhase?.(phase, endedAt - startedAt);
|
|
72
|
+
}
|
|
73
|
+
catch { /* Reporting must not interrupt authentication. */ }
|
|
74
|
+
}
|
|
75
|
+
/** Run one stage and report its duration however it ends. */
|
|
76
|
+
async timed(phase, work) {
|
|
77
|
+
const startedAt = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
return await work();
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
this.phaseEnded(phase, startedAt);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
60
85
|
static isWSLOrDocker() {
|
|
61
86
|
try {
|
|
62
87
|
if (/microsoft|wsl/i.test(readFileSync("/proc/version", "utf8")))
|
|
@@ -100,6 +125,7 @@ export class BrowserAuth {
|
|
|
100
125
|
let interrupted = false;
|
|
101
126
|
let state;
|
|
102
127
|
let stateSaved = false;
|
|
128
|
+
let tokenStartedAt;
|
|
103
129
|
const closeOnSignal = () => {
|
|
104
130
|
interrupted = true;
|
|
105
131
|
void browser?.close().catch(() => { });
|
|
@@ -112,7 +138,7 @@ export class BrowserAuth {
|
|
|
112
138
|
args.push("--no-sandbox", "--disable-setuid-sandbox");
|
|
113
139
|
// Use Playwright's own timeout, which cleans up an unsuccessful launch.
|
|
114
140
|
try {
|
|
115
|
-
browser = await chromium.launch({ headless: this.config.headless, timeout: 60000, args });
|
|
141
|
+
browser = await this.timed("launch", () => chromium.launch({ headless: this.config.headless, timeout: 60000, args }));
|
|
116
142
|
}
|
|
117
143
|
catch (launchError) {
|
|
118
144
|
// Keep the remedy attached to the failure. Without this the hint is
|
|
@@ -142,6 +168,7 @@ export class BrowserAuth {
|
|
|
142
168
|
// away newly renewed Entra or Brightspace cookies.
|
|
143
169
|
await this.saveBrowserState(await context.storageState());
|
|
144
170
|
stateSaved = true;
|
|
171
|
+
tokenStartedAt = Date.now();
|
|
145
172
|
const material = await this.harvestSessionMaterial(page, context);
|
|
146
173
|
let token = null;
|
|
147
174
|
if (material.cookieHeader && material.csrfToken) {
|
|
@@ -182,6 +209,8 @@ export class BrowserAuth {
|
|
|
182
209
|
throw error;
|
|
183
210
|
}
|
|
184
211
|
finally {
|
|
212
|
+
if (tokenStartedAt !== undefined)
|
|
213
|
+
this.phaseEnded("token", tokenStartedAt);
|
|
185
214
|
process.removeListener("SIGINT", closeOnSignal);
|
|
186
215
|
process.removeListener("SIGTERM", closeOnSignal);
|
|
187
216
|
if (page && listener)
|
|
@@ -327,10 +356,10 @@ export class BrowserAuth {
|
|
|
327
356
|
let navigationError;
|
|
328
357
|
let response;
|
|
329
358
|
try {
|
|
330
|
-
response = await page.goto(`${this.config.baseUrl}/d2l/home`, {
|
|
359
|
+
response = await this.timed("navigation", () => page.goto(`${this.config.baseUrl}/d2l/home`, {
|
|
331
360
|
waitUntil: "domcontentloaded",
|
|
332
361
|
timeout: INITIAL_NAVIGATION_TIMEOUT_MS,
|
|
333
|
-
});
|
|
362
|
+
}));
|
|
334
363
|
}
|
|
335
364
|
catch (error) {
|
|
336
365
|
// Brightspace Bar tolerates a navigation timeout because SAML may keep
|
|
@@ -347,7 +376,7 @@ export class BrowserAuth {
|
|
|
347
376
|
if (response && (response.status() >= 500 || response.status() === 429)) {
|
|
348
377
|
throw new BrowserAuthTransportError(`Brightspace temporarily returned HTTP ${response.status()}. Saved state is preserved.`);
|
|
349
378
|
}
|
|
350
|
-
if (await this.awaitSilentSSO(page)) {
|
|
379
|
+
if (await this.timed("silentSso", () => this.awaitSilentSSO(page))) {
|
|
351
380
|
log("INFO", "Saved session is active");
|
|
352
381
|
return true;
|
|
353
382
|
}
|
|
@@ -368,6 +397,8 @@ export class BrowserAuth {
|
|
|
368
397
|
}
|
|
369
398
|
throw new BrowserAuthTransportError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
|
|
370
399
|
}
|
|
400
|
+
const loginStartedAt = Date.now();
|
|
401
|
+
this.challengeSeenAt = undefined;
|
|
371
402
|
try {
|
|
372
403
|
if (!await this.ssoFlow.login(page)) {
|
|
373
404
|
throw new UnsupportedAuthenticationError("The identity provider could not complete automatic sign-in.");
|
|
@@ -378,6 +409,14 @@ export class BrowserAuth {
|
|
|
378
409
|
await this.cooldown.recordMfaFailure();
|
|
379
410
|
throw error;
|
|
380
411
|
}
|
|
412
|
+
finally {
|
|
413
|
+
if (this.challengeSeenAt === undefined)
|
|
414
|
+
this.phaseEnded("credentials", loginStartedAt);
|
|
415
|
+
else {
|
|
416
|
+
this.phaseEnded("credentials", loginStartedAt, this.challengeSeenAt);
|
|
417
|
+
this.phaseEnded("approvalWait", this.challengeSeenAt);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
381
420
|
if (!await this.hasLiveSession(page)) {
|
|
382
421
|
throw new BrowserAuthError("Sign-in did not produce a verified Brightspace session.", "session_validation");
|
|
383
422
|
}
|
package/build/auth-cli.js
CHANGED
|
@@ -10,6 +10,7 @@ import { BrowserAuth, TokenManager } from "./auth/index.js";
|
|
|
10
10
|
import { MfaApprovalError } from "./auth/sso-flow.js";
|
|
11
11
|
import { NativeCredentialStoreError } from "./auth/credential-store.js";
|
|
12
12
|
import { retireLegacyProfile } from "./auth/legacy-profile.js";
|
|
13
|
+
import { formatPhaseMarker } from "./auth/auth-phases.js";
|
|
13
14
|
import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
|
|
14
15
|
import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
|
|
15
16
|
import { reexecLatestIfStale } from "./utils/self-update.js";
|
|
@@ -63,7 +64,11 @@ async function main() {
|
|
|
63
64
|
const onMfaChallenge = automatic
|
|
64
65
|
? (number) => console.log(number ? `MFA_NUMBER:${number}` : "MFA_PENDING")
|
|
65
66
|
: undefined;
|
|
66
|
-
|
|
67
|
+
// Stage timings for the server's dev activity log, same stdout channel.
|
|
68
|
+
const onPhase = automatic
|
|
69
|
+
? (phase, elapsedMs) => console.log(formatPhaseMarker(phase, elapsedMs))
|
|
70
|
+
: undefined;
|
|
71
|
+
await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge, onPhase }).authenticate({
|
|
67
72
|
automatic,
|
|
68
73
|
onAuthenticated: async (token) => {
|
|
69
74
|
await tokenManager.setToken(token);
|
|
@@ -10,6 +10,18 @@ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable,
|
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { matchesModifiedSince } from "../utils/modified-since.js";
|
|
13
|
+
function pageInfo(offset, count, returned, total, unavailableCourseIds = []) {
|
|
14
|
+
const nextOffset = total !== null && offset + returned < total ? offset + returned : null;
|
|
15
|
+
return { offset, count, returned, total, nextOffset, truncated: nextOffset !== null,
|
|
16
|
+
coverageComplete: total !== null && unavailableCourseIds.length === 0, unavailableCourseIds };
|
|
17
|
+
}
|
|
18
|
+
// Preserve the original array in content[0] and existing auth notices, while
|
|
19
|
+
// exposing machine-readable continuation metadata in a separate text block.
|
|
20
|
+
function arrayPage(items, pagination, notice) {
|
|
21
|
+
const result = notice ? toolResponseWithNotice(items, notice) : toolResponse(items);
|
|
22
|
+
result.content.push({ type: "text", text: JSON.stringify({ pagination }) });
|
|
23
|
+
return result;
|
|
24
|
+
}
|
|
13
25
|
/**
|
|
14
26
|
* A date the runtime can actually order, or null. Unreadable and absent are the
|
|
15
27
|
* same answer, so a caller can fall through to the next best.
|
|
@@ -87,13 +99,13 @@ export function mapNewsItem(item) {
|
|
|
87
99
|
export function registerGetAnnouncements(server, apiClient, config) {
|
|
88
100
|
server.registerTool("get_announcements", {
|
|
89
101
|
title: "Get Announcements",
|
|
90
|
-
description: "Fetch
|
|
102
|
+
description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
|
|
91
103
|
inputSchema: GetAnnouncementsSchema,
|
|
92
104
|
}, async (args) => {
|
|
93
105
|
try {
|
|
94
106
|
log("DEBUG", "get_announcements tool called", { args });
|
|
95
107
|
// Parse and validate input
|
|
96
|
-
const { courseId, count, modifiedSince } = GetAnnouncementsSchema.parse(args);
|
|
108
|
+
const { courseId, count, offset, modifiedSince } = GetAnnouncementsSchema.parse(args);
|
|
97
109
|
const cutoff = modifiedSince ? new Date(modifiedSince) : null;
|
|
98
110
|
// Single course case
|
|
99
111
|
if (courseId) {
|
|
@@ -104,16 +116,18 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
104
116
|
const matched = cutoff
|
|
105
117
|
? published.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
|
|
106
118
|
: published;
|
|
107
|
-
const announcements = matched.sort(newestFirst).slice(
|
|
119
|
+
const announcements = matched.sort(newestFirst).slice(offset, offset + count);
|
|
120
|
+
const pagination = pageInfo(offset, count, announcements.length, matched.length);
|
|
108
121
|
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
|
|
109
|
-
return
|
|
110
|
-
? {
|
|
122
|
+
return modifiedSince
|
|
123
|
+
? toolResponse({
|
|
111
124
|
announcements,
|
|
125
|
+
pagination,
|
|
112
126
|
modifiedSince,
|
|
113
127
|
returned: announcements.length,
|
|
114
128
|
filteredOut: published.length - matched.length,
|
|
115
|
-
}
|
|
116
|
-
: announcements);
|
|
129
|
+
})
|
|
130
|
+
: arrayPage(announcements, pagination);
|
|
117
131
|
}
|
|
118
132
|
catch (error) {
|
|
119
133
|
// A pending sign-in is not an empty course: the route never
|
|
@@ -137,10 +151,11 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
137
151
|
returned: 0,
|
|
138
152
|
filteredOut: 0,
|
|
139
153
|
authPending: true,
|
|
154
|
+
pagination: pageInfo(offset, count, 0, null, [courseId]),
|
|
140
155
|
notice,
|
|
141
156
|
});
|
|
142
157
|
}
|
|
143
|
-
return
|
|
158
|
+
return arrayPage([], pageInfo(offset, count, 0, null, [courseId]), notice);
|
|
144
159
|
}
|
|
145
160
|
throw error;
|
|
146
161
|
}
|
|
@@ -181,7 +196,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
181
196
|
// 403 means no access (past course, etc) - log and skip
|
|
182
197
|
if (error?.status === 403) {
|
|
183
198
|
log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
|
|
184
|
-
return
|
|
199
|
+
return { unavailable: true, courseId: item.OrgUnit.Id };
|
|
185
200
|
}
|
|
186
201
|
// A pending sign-in only means this course's route never
|
|
187
202
|
// answered — it says nothing about the other courses, whose
|
|
@@ -201,13 +216,16 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
201
216
|
.map((r) => r.value);
|
|
202
217
|
const pending = settled.filter((v) => !Array.isArray(v) && v?.authPending === true);
|
|
203
218
|
const allAnnouncements = settled.filter((v) => Array.isArray(v)).flat();
|
|
219
|
+
const unavailableCourseIds = results.flatMap((result, index) => result.status === "rejected" || (!Array.isArray(result.value) && result.value?.courseId)
|
|
220
|
+
? [filteredEnrollments[index].OrgUnit.Id] : []);
|
|
204
221
|
const allMatched = cutoff
|
|
205
222
|
? allAnnouncements.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
|
|
206
223
|
: allAnnouncements;
|
|
207
224
|
// Sort by the scheduled date and slice to count
|
|
208
225
|
const announcements = allMatched
|
|
209
226
|
.sort(newestFirst)
|
|
210
|
-
.slice(
|
|
227
|
+
.slice(offset, offset + count);
|
|
228
|
+
const pagination = pageInfo(offset, count, announcements.length, allMatched.length, unavailableCourseIds);
|
|
211
229
|
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total ` +
|
|
212
230
|
`across ${enrollmentItems.length} courses${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
|
|
213
231
|
// With modifiedSince the response is already an object (the shape
|
|
@@ -218,14 +236,15 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
218
236
|
// the notice instead of changing content[0].
|
|
219
237
|
if (!modifiedSince) {
|
|
220
238
|
if (pending.length === 0)
|
|
221
|
-
return
|
|
222
|
-
return
|
|
239
|
+
return arrayPage(announcements, pagination);
|
|
240
|
+
return arrayPage(announcements, pagination, "Sign-in to Brightspace is still in progress, so announcements for " +
|
|
223
241
|
`${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
|
|
224
242
|
`fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
|
|
225
243
|
"sign-in finishes.");
|
|
226
244
|
}
|
|
227
245
|
const response = {
|
|
228
246
|
announcements,
|
|
247
|
+
pagination,
|
|
229
248
|
modifiedSince,
|
|
230
249
|
returned: announcements.length,
|
|
231
250
|
filteredOut: allAnnouncements.length - allMatched.length,
|
package/build/tools/schemas.js
CHANGED
|
@@ -37,7 +37,8 @@ export const GetMyGradesSchema = z.object({
|
|
|
37
37
|
});
|
|
38
38
|
export const GetAnnouncementsSchema = z.object({
|
|
39
39
|
courseId: z.coerce.number().int().positive().optional().describe("Course ID to get announcements for. If omitted, returns recent announcements across all courses."),
|
|
40
|
-
count: z.coerce.number().int().min(1).max(50).default(10).describe("
|
|
40
|
+
count: z.coerce.number().int().min(1).max(50).default(10).describe("Announcements per page: 1-50 (default 10)."),
|
|
41
|
+
offset: z.coerce.number().int().min(0).default(0).describe("Announcement offset after filtering and sorting. Continue with pagination.nextOffset; restart at 0 after a failed course becomes available."),
|
|
41
42
|
modifiedSince: z.string().datetime({ offset: true, message: "modifiedSince must be an ISO 8601 datetime, e.g. 2026-01-15T00:00:00Z" }).optional()
|
|
42
43
|
.describe("Only return announcements last modified at or after this ISO 8601 datetime (e.g. 2026-01-15T00:00:00Z). Announcements with no modified timestamp are always included. When set, the response reports how many announcements were filtered out."),
|
|
43
44
|
});
|
|
@@ -102,6 +102,10 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
102
102
|
"Tell the user that, then call this tool again right away without waiting for them to " +
|
|
103
103
|
"confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
|
|
104
104
|
"as the sign-in completes. Keep calling until it succeeds or reports a different error.",
|
|
105
|
+
inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
|
|
106
|
+
"school's login pages). Call this tool again right away: the next call joins the same " +
|
|
107
|
+
"sign-in, so it sends no second MFA prompt, and returns the number to approve as soon as " +
|
|
108
|
+
"one appears. Don't ask the user to do anything yet.",
|
|
105
109
|
};
|
|
106
110
|
/**
|
|
107
111
|
* mfaPending is the one kind whose guidance is partly dynamic: `numberMatch`
|
|
@@ -2,6 +2,7 @@ import { AsyncLocalStorage } from "node:async_hooks";
|
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { appendFileSync, mkdirSync, statSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { AUTH_PHASES } from "../auth/auth-phases.js";
|
|
5
6
|
const calls = new AsyncLocalStorage();
|
|
6
7
|
const runId = randomUUID();
|
|
7
8
|
const MAX_BYTES = 5 * 1024 * 1024;
|
|
@@ -23,7 +24,7 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
23
24
|
if (!directory)
|
|
24
25
|
return;
|
|
25
26
|
if (!["tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
|
|
26
|
-
"token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished"].includes(event))
|
|
27
|
+
"token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished", "auth_phase"].includes(event))
|
|
27
28
|
return;
|
|
28
29
|
const at = new Date().toISOString();
|
|
29
30
|
const record = { at, runId, pid: process.pid, event };
|
|
@@ -37,6 +38,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
37
38
|
record.outcome = fields.outcome;
|
|
38
39
|
if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending"].includes(fields.reason ?? ""))
|
|
39
40
|
record.reason = fields.reason;
|
|
41
|
+
if (AUTH_PHASES.includes(fields.phase ?? ""))
|
|
42
|
+
record.phase = fields.phase;
|
|
40
43
|
try {
|
|
41
44
|
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
42
45
|
const file = join(directory, `activity-${at.slice(0, 10)}-${process.pid}-${runId}.jsonl`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.8",
|
|
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",
|