brightspace-mcp-server 5.1.28 → 5.1.30
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-exit-code.js +25 -0
- package/build/auth/auth-runner.js +26 -2
- package/build/auth/browser-auth.js +77 -7
- package/build/auth/error-cause.js +38 -0
- package/build/auth-cli.js +5 -9
- package/build/tools/schemas.js +1 -1
- package/build/tools/sign-in.js +7 -3
- package/build/tools/tool-helpers.js +5 -1
- package/build/utils/dev-activity.js +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
import { BrowserInstallError } from "../utils/browser-install.js";
|
|
7
|
+
import { NativeCredentialStoreError } from "./credential-store.js";
|
|
8
|
+
import { MfaApprovalError } from "./sso-flow.js";
|
|
9
|
+
/**
|
|
10
|
+
* The exit code auth-cli ends with for a failed sign-in. AuthRunner
|
|
11
|
+
* maps each code back to a failure kind (see its `failures` table), so the two
|
|
12
|
+
* must stay in step: 2 busy, 3 cooldown, 4 unsupported, 5 secureStorage,
|
|
13
|
+
* 6 transport, 7 MFA not approved, 8/9 browser install, 10 stuck, 1 anything else.
|
|
14
|
+
*/
|
|
15
|
+
export function authExitCode(error) {
|
|
16
|
+
const code = error?.code;
|
|
17
|
+
return error instanceof BrowserInstallError ? (error.dependencyMissing ? 9 : 8)
|
|
18
|
+
: error instanceof NativeCredentialStoreError ? 5
|
|
19
|
+
: code === "AUTH_IN_PROGRESS" ? 2
|
|
20
|
+
: code === "AUTH_COOLDOWN" ? 3
|
|
21
|
+
: code === "AUTH_UNSUPPORTED" ? 4
|
|
22
|
+
: code === "AUTH_TRANSPORT" ? 6
|
|
23
|
+
: code === "AUTH_STUCK" ? 10
|
|
24
|
+
: error instanceof MfaApprovalError ? 7 : 1;
|
|
25
|
+
}
|
|
@@ -134,6 +134,15 @@ function mfaPendingFailure(numberMatch) {
|
|
|
134
134
|
* send the user looking for a prompt that will never arrive.
|
|
135
135
|
*/
|
|
136
136
|
const automaticPendingFailure = (relayed = false) => new AuthProcessError("automaticPending", "Sign-in is entering its own verification code in the background. Try again.", undefined, false, relayed);
|
|
137
|
+
/** What every caller is told when the school's sign-in page never settled. */
|
|
138
|
+
const STUCK_MESSAGE = "Sign-in got stuck on the school's sign-in page and stopped. Call sign_in again.";
|
|
139
|
+
/**
|
|
140
|
+
* Stuck failures from a child that never showed the user anything (no number,
|
|
141
|
+
* no automatic-code notice, no held prompt). Only these are retried by
|
|
142
|
+
* themselves: after a prompt reached the user's phone, a second sign-in could
|
|
143
|
+
* send a second one.
|
|
144
|
+
*/
|
|
145
|
+
const retryableStuck = new WeakSet();
|
|
137
146
|
/**
|
|
138
147
|
* Microsoft is refusing a new prompt for the account until an earlier one
|
|
139
148
|
* expires. Nothing is on the user's phone to approve, and calling again does
|
|
@@ -733,7 +742,16 @@ export class AuthRunner {
|
|
|
733
742
|
// prompt starts a fresh attempt rather than inheriting the stale
|
|
734
743
|
// rejection. Ownership is checked because a caller could in principle
|
|
735
744
|
// clear the latch while this flow is still running.
|
|
736
|
-
const flow = this.spawnAuth().
|
|
745
|
+
const flow = this.spawnAuth().catch((error) => {
|
|
746
|
+
// The sign-in page jammed before anything reached the user: one more
|
|
747
|
+
// try, in the same call. Anything else (a number shown, a prompt sent)
|
|
748
|
+
// is reported as it is.
|
|
749
|
+
if (error instanceof AuthProcessError && retryableStuck.has(error)) {
|
|
750
|
+
log("WARN", "Sign-in got stuck before any challenge; trying once more");
|
|
751
|
+
return this.spawnAuth();
|
|
752
|
+
}
|
|
753
|
+
throw error;
|
|
754
|
+
}).finally(() => {
|
|
737
755
|
if (this.inFlight === flow)
|
|
738
756
|
this.inFlight = null;
|
|
739
757
|
});
|
|
@@ -1163,6 +1181,9 @@ export class AuthRunner {
|
|
|
1163
1181
|
4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
|
|
1164
1182
|
5: ["secureStorage", "The native credential store is unavailable or locked. Unlock it and retry."],
|
|
1165
1183
|
6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
|
|
1184
|
+
// The page never settled (BrowserAuthStuckError): Brightspace was
|
|
1185
|
+
// reachable, so this is not the network. runOnce retries it once.
|
|
1186
|
+
10: ["stuck", STUCK_MESSAGE],
|
|
1166
1187
|
// The MFA loop gave up (MfaApprovalError) and recorded the
|
|
1167
1188
|
// cooldown: the number it showed no longer works, so a caller
|
|
1168
1189
|
// still polling hears what the next call would, not "enter 42".
|
|
@@ -1187,7 +1208,10 @@ export class AuthRunner {
|
|
|
1187
1208
|
const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
|
|
1188
1209
|
kill("SIGKILL");
|
|
1189
1210
|
const challenged = kind === "mfaPending" || kind === "automaticPending" || kind === "inProgress";
|
|
1190
|
-
|
|
1211
|
+
const failure = new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined, false, relayed && challenged);
|
|
1212
|
+
if (kind === "stuck" && !challengeSeen && !automaticSeen && !heldSeen && !relayed)
|
|
1213
|
+
retryableStuck.add(failure);
|
|
1214
|
+
finishChild(failure);
|
|
1191
1215
|
}
|
|
1192
1216
|
});
|
|
1193
1217
|
});
|
|
@@ -18,6 +18,9 @@ import { launchWithBrowserRecovery } from "../utils/browser-install.js";
|
|
|
18
18
|
import { signInCommand } from "../utils/commands.js";
|
|
19
19
|
const SILENT_SSO_TIMEOUT_MS = 30000;
|
|
20
20
|
const SILENT_SSO_POLL_MS = 1000;
|
|
21
|
+
/** How long navigateAndLogin keeps looking for a challenge once silent SSO ends on a page it does not recognise. */
|
|
22
|
+
const CHALLENGE_SETTLE_POLLS = 5;
|
|
23
|
+
const LOAD_STATE_WAIT_MS = 2000;
|
|
21
24
|
const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
|
|
22
25
|
const VISIBLE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
23
26
|
const SILENT_SSO = {
|
|
@@ -50,6 +53,29 @@ export class BrowserAuthTransportError extends Error {
|
|
|
50
53
|
this.name = "BrowserAuthTransportError";
|
|
51
54
|
}
|
|
52
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The school's sign-in page never settled on anything the sign-in can act on:
|
|
58
|
+
* Brightspace was reachable, the page just kept moving or showed something
|
|
59
|
+
* unrecognized. Still a BrowserAuthTransportError for existing callers, but
|
|
60
|
+
* its own code, so the user is told to call sign_in again rather than that the
|
|
61
|
+
* network is down.
|
|
62
|
+
*/
|
|
63
|
+
export class BrowserAuthStuckError extends BrowserAuthTransportError {
|
|
64
|
+
code = "AUTH_STUCK";
|
|
65
|
+
constructor(message, options) {
|
|
66
|
+
super(message, options);
|
|
67
|
+
this.name = "BrowserAuthStuckError";
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Playwright's errors for a page that navigated while it was being read. They
|
|
72
|
+
* are the normal state of a redirect chain, not a dead browser: the next poll
|
|
73
|
+
* reads the page that arrived.
|
|
74
|
+
*/
|
|
75
|
+
const NAVIGATION_RACE = /Execution context was destroyed|because of a navigation|Cannot find context with specified id|frame was detached/i;
|
|
76
|
+
export function isNavigationRace(error) {
|
|
77
|
+
return error instanceof Error && NAVIGATION_RACE.test(error.message);
|
|
78
|
+
}
|
|
53
79
|
export class BrowserAuth {
|
|
54
80
|
config;
|
|
55
81
|
ssoFlow;
|
|
@@ -409,9 +435,22 @@ export class BrowserAuth {
|
|
|
409
435
|
log("INFO", "Saved session is active");
|
|
410
436
|
return true;
|
|
411
437
|
}
|
|
412
|
-
|
|
413
|
-
|
|
438
|
+
let pendingMfa = await this.hasMfaChallenge(page);
|
|
439
|
+
let credentialPrompt = await this.hasCredentialPrompt(page);
|
|
414
440
|
const hasCredentials = this.ssoFlow.hasCredentials();
|
|
441
|
+
// A page caught mid-redirect shows nothing yet; give it a few more seconds
|
|
442
|
+
// before calling it unsupported. Without credentials there is nothing to
|
|
443
|
+
// do with a challenge anyway, so that path does not wait.
|
|
444
|
+
for (let i = 0; hasCredentials && this.config.headless !== false && !pendingMfa && !credentialPrompt && i < CHALLENGE_SETTLE_POLLS; i++) {
|
|
445
|
+
// A closed browser is classified by the probe below, not by the wait.
|
|
446
|
+
await page.waitForTimeout(SILENT_SSO_POLL_MS).catch(() => { });
|
|
447
|
+
if (await this.hasLiveSession(page)) {
|
|
448
|
+
log("INFO", "Saved session is active");
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
pendingMfa = await this.hasMfaChallenge(page);
|
|
452
|
+
credentialPrompt = await this.hasCredentialPrompt(page);
|
|
453
|
+
}
|
|
415
454
|
if (!hasCredentials && !pendingMfa) {
|
|
416
455
|
if (this.config.headless === false) {
|
|
417
456
|
await this.waitForVisibleLogin(page);
|
|
@@ -424,7 +463,7 @@ export class BrowserAuth {
|
|
|
424
463
|
await this.waitForVisibleLogin(page);
|
|
425
464
|
return false;
|
|
426
465
|
}
|
|
427
|
-
throw new
|
|
466
|
+
throw new BrowserAuthStuckError("The sign-in page has not settled on a supported login challenge. Retry shortly; saved state is preserved.", navigationError === undefined ? undefined : { cause: navigationError });
|
|
428
467
|
}
|
|
429
468
|
const loginStartedAt = Date.now();
|
|
430
469
|
this.challengeSeenAt = undefined;
|
|
@@ -478,7 +517,10 @@ export class BrowserAuth {
|
|
|
478
517
|
let accountHintAttempted = false;
|
|
479
518
|
let passwordPromptPolls = 0;
|
|
480
519
|
let passwordSwitchAttempted = false;
|
|
481
|
-
|
|
520
|
+
// The poll that starts at or after the deadline is the last: it still looks
|
|
521
|
+
// for a challenge, so a password field first seen on it is acted on.
|
|
522
|
+
for (;;) {
|
|
523
|
+
const finalPoll = Date.now() >= deadline;
|
|
482
524
|
if (await this.hasLiveSession(page))
|
|
483
525
|
return true;
|
|
484
526
|
// Only a visible supported challenge justifies entering credentials or
|
|
@@ -520,7 +562,7 @@ export class BrowserAuth {
|
|
|
520
562
|
// Entra can briefly expose a password-shaped control while its email
|
|
521
563
|
// view initializes. Brightspace Bar polls the evolving page instead of
|
|
522
564
|
// concluding from that first transient snapshot.
|
|
523
|
-
if (passwordPromptPolls >= 2) {
|
|
565
|
+
if (passwordPromptPolls >= 2 || finalPoll) {
|
|
524
566
|
log("INFO", "The identity provider requires a login challenge");
|
|
525
567
|
return false;
|
|
526
568
|
}
|
|
@@ -528,6 +570,8 @@ export class BrowserAuth {
|
|
|
528
570
|
continue;
|
|
529
571
|
}
|
|
530
572
|
passwordPromptPolls = 0;
|
|
573
|
+
if (finalPoll)
|
|
574
|
+
break;
|
|
531
575
|
try {
|
|
532
576
|
await this.clickSilentSurfaces(page);
|
|
533
577
|
await page.waitForTimeout(SILENT_SSO_POLL_MS);
|
|
@@ -535,12 +579,18 @@ export class BrowserAuth {
|
|
|
535
579
|
catch (error) {
|
|
536
580
|
if (error instanceof BrowserAuthError || error instanceof BrowserAuthTransportError)
|
|
537
581
|
throw error;
|
|
582
|
+
// The page navigated under the click: read the page that arrived.
|
|
583
|
+
if (isNavigationRace(error)) {
|
|
584
|
+
log("DEBUG", "The sign-in page navigated during a poll; polling again");
|
|
585
|
+
await page.waitForTimeout(SILENT_SSO_POLL_MS).catch(() => { });
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
538
588
|
throw new BrowserAuthTransportError("Silent sign-in could not reach the identity provider. Saved state is preserved.", { cause: error });
|
|
539
589
|
}
|
|
540
|
-
}
|
|
590
|
+
}
|
|
541
591
|
if (this.config.headless === false)
|
|
542
592
|
return false;
|
|
543
|
-
throw new
|
|
593
|
+
throw new BrowserAuthStuckError("Silent sign-in did not reach Brightspace or a supported login challenge within 30 seconds. Retry shortly; saved state is preserved.");
|
|
544
594
|
}
|
|
545
595
|
async hasCredentialPrompt(page) {
|
|
546
596
|
return await this.isAnyOnScreen(page, SILENT_SSO.emailFields) ||
|
|
@@ -572,6 +622,18 @@ export class BrowserAuth {
|
|
|
572
622
|
* cookie and a reachable D2L JS context are required.
|
|
573
623
|
*/
|
|
574
624
|
async hasLiveSession(page) {
|
|
625
|
+
// A page caught mid-navigation is read once more after the page that
|
|
626
|
+
// arrived has loaded: the check right after a sign-in has no next poll, so
|
|
627
|
+
// a race there would otherwise fail a login the user just approved.
|
|
628
|
+
for (let attempt = 1;; attempt++) {
|
|
629
|
+
const live = await this.readLiveSession(page);
|
|
630
|
+
if (live !== "raced")
|
|
631
|
+
return live;
|
|
632
|
+
if (attempt >= 2)
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
async readLiveSession(page) {
|
|
575
637
|
try {
|
|
576
638
|
const location = new URL(page.url());
|
|
577
639
|
if (location.origin !== new URL(this.config.baseUrl).origin)
|
|
@@ -584,12 +646,18 @@ export class BrowserAuth {
|
|
|
584
646
|
if (!cookies.some((c) => c.name === "d2lSessionVal" && Boolean(c.value))) {
|
|
585
647
|
return false;
|
|
586
648
|
}
|
|
649
|
+
// An expired session answers /d2l/home with a stub that redirects to the
|
|
650
|
+
// login page while already carrying the cookie; read it once it loads.
|
|
651
|
+
await page.waitForLoadState("domcontentloaded", { timeout: LOAD_STATE_WAIT_MS }).catch(() => { });
|
|
587
652
|
return await page.evaluate(() => {
|
|
588
653
|
const d2l = window.D2L;
|
|
589
654
|
return d2l !== undefined && Boolean(d2l.LP);
|
|
590
655
|
});
|
|
591
656
|
}
|
|
592
657
|
catch (error) {
|
|
658
|
+
// Mid-navigation is "not live yet", not a dead browser.
|
|
659
|
+
if (isNavigationRace(error))
|
|
660
|
+
return "raced";
|
|
593
661
|
throw new BrowserAuthTransportError("The browser could not verify the saved Brightspace session. Saved state is preserved.", { cause: error });
|
|
594
662
|
}
|
|
595
663
|
}
|
|
@@ -644,6 +712,8 @@ export class BrowserAuth {
|
|
|
644
712
|
return await page.locator(selector).first().isVisible();
|
|
645
713
|
}
|
|
646
714
|
catch (error) {
|
|
715
|
+
if (isNavigationRace(error))
|
|
716
|
+
return false;
|
|
647
717
|
throw new BrowserAuthTransportError("The browser could not inspect the sign-in page. Saved state is preserved.", { cause: error });
|
|
648
718
|
}
|
|
649
719
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
const MAX_CAUSES = 4;
|
|
7
|
+
const MAX_LINE = 200;
|
|
8
|
+
/**
|
|
9
|
+
* One safe line per link of an error's `cause` chain, outermost first, so a
|
|
10
|
+
* failure reported as a wrapper ("could not verify the session") also logs
|
|
11
|
+
* what actually broke. Only the first line of each message is kept, any URL
|
|
12
|
+
* is cut to its origin (query strings and paths can carry tokens), and the
|
|
13
|
+
* line is length-capped. The top error itself is not included.
|
|
14
|
+
*/
|
|
15
|
+
export function causeSummaries(error) {
|
|
16
|
+
const lines = [];
|
|
17
|
+
const seen = new Set([error]);
|
|
18
|
+
let current = error?.cause;
|
|
19
|
+
while (current !== undefined && current !== null && lines.length < MAX_CAUSES && !seen.has(current)) {
|
|
20
|
+
seen.add(current);
|
|
21
|
+
const raw = current instanceof Error ? current.message : typeof current === "string" ? current : "";
|
|
22
|
+
const name = current instanceof Error && current.name !== "Error" ? `${current.name}: ` : "";
|
|
23
|
+
const firstLine = raw.split(/\r?\n/, 1)[0] ?? "";
|
|
24
|
+
const safe = `${name}${firstLine}`
|
|
25
|
+
.replace(/\b[a-z][a-z0-9+.-]*:\/\/[^\s/?#)'"]+[^\s)'"]*/gi, (url) => {
|
|
26
|
+
try {
|
|
27
|
+
return new URL(url).origin;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "[url]";
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
.slice(0, MAX_LINE);
|
|
34
|
+
lines.push(safe || "(no message)");
|
|
35
|
+
current = current.cause;
|
|
36
|
+
}
|
|
37
|
+
return lines;
|
|
38
|
+
}
|
package/build/auth-cli.js
CHANGED
|
@@ -8,7 +8,6 @@ import dotenv from "dotenv";
|
|
|
8
8
|
import { loadConfig } from "./utils/config.js";
|
|
9
9
|
import { BrowserAuth, TokenManager } from "./auth/index.js";
|
|
10
10
|
import { MfaApprovalError } from "./auth/sso-flow.js";
|
|
11
|
-
import { NativeCredentialStoreError } from "./auth/credential-store.js";
|
|
12
11
|
import { retireLegacyProfile } from "./auth/legacy-profile.js";
|
|
13
12
|
import { formatPhaseMarker } from "./auth/auth-phases.js";
|
|
14
13
|
import { AuthenticationInProgressError } from "./auth/auth-lock.js";
|
|
@@ -20,6 +19,8 @@ import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
|
|
|
20
19
|
import { reexecLatestIfStale } from "./utils/self-update.js";
|
|
21
20
|
import { detectSkew } from "./utils/install-sites.js";
|
|
22
21
|
import { BrowserInstallError } from "./utils/browser-install.js";
|
|
22
|
+
import { causeSummaries } from "./auth/error-cause.js";
|
|
23
|
+
import { authExitCode } from "./auth/auth-exit-code.js";
|
|
23
24
|
dotenv.config({ quiet: true });
|
|
24
25
|
const pkg = JSON.parse(readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
|
|
25
26
|
async function requestMfaCode() {
|
|
@@ -104,7 +105,6 @@ async function main() {
|
|
|
104
105
|
console.error("\nAuthentication successful. Your encrypted session is ready for the MCP server.");
|
|
105
106
|
}
|
|
106
107
|
catch (error) {
|
|
107
|
-
const code = error?.code;
|
|
108
108
|
// A pending number-match approval gets its own exit code so the caller
|
|
109
109
|
// can tell "still waiting on the phone" apart from a hard failure, and
|
|
110
110
|
// its own stdout marker so the digits — already validated to 1-3 digits
|
|
@@ -122,14 +122,10 @@ async function main() {
|
|
|
122
122
|
for (const line of relayedLockLines(error.challenge, error.ownerMode))
|
|
123
123
|
console.log(line);
|
|
124
124
|
}
|
|
125
|
-
process.exitCode =
|
|
126
|
-
: error instanceof NativeCredentialStoreError ? 5
|
|
127
|
-
: code === "AUTH_IN_PROGRESS" ? 2
|
|
128
|
-
: code === "AUTH_COOLDOWN" ? 3
|
|
129
|
-
: code === "AUTH_UNSUPPORTED" ? 4
|
|
130
|
-
: code === "AUTH_TRANSPORT" ? 6
|
|
131
|
-
: error instanceof MfaApprovalError ? 7 : 1;
|
|
125
|
+
process.exitCode = authExitCode(error);
|
|
132
126
|
console.error("\nAuthentication failed:", error instanceof Error ? error.message : "Unknown authentication error");
|
|
127
|
+
for (const cause of causeSummaries(error))
|
|
128
|
+
console.error(` caused by: ${cause}`);
|
|
133
129
|
if (!(error instanceof BrowserInstallError)) {
|
|
134
130
|
console.error(`Run \`${SETUP_COMMAND}\` to update saved credentials.`);
|
|
135
131
|
console.error(`Run \`${AUTH_COMMAND}\` to retry explicitly. This bypasses the automatic MFA cooldown.`);
|
package/build/tools/schemas.js
CHANGED
|
@@ -164,7 +164,7 @@ export const GetServerInfoSchema = z.object({});
|
|
|
164
164
|
export const SignOutSchema = z.object({});
|
|
165
165
|
export const SignInSchema = z.object({
|
|
166
166
|
visible: z.boolean().optional()
|
|
167
|
-
.describe("Open a browser window for the user to sign in by hand. Omit
|
|
167
|
+
.describe("Open a browser window for the user to sign in by hand. Omit it: a window then opens by itself only when no password is saved on this computer, because a sign-in with no window has nothing to type into the school's form and would only time out. Pass true ONLY when the user asks for a window, or an error says a step needs the user's own hands (an unsupported sign-in, or no saved password). Do NOT pass true after a \"could not be reached\", stuck, or timeout failure: just call sign_in again, because the saved password is typed in the background and a window adds nothing. Pass false to insist on a background sign-in."),
|
|
168
168
|
});
|
|
169
169
|
// ── Instructor / TA dropbox tools ──────────────────────────────────────────
|
|
170
170
|
// Read-only, instructor/TA-side views of a course's dropbox (assignment)
|
package/build/tools/sign-in.js
CHANGED
|
@@ -14,7 +14,10 @@ const ENV_CREDENTIAL = "Nothing to do. This server authenticates with a credenti
|
|
|
14
14
|
const NO_RUNNER = `This server cannot start a sign-in itself. Tell the user to run \`${AUTH_COMMAND}\` in a terminal, prefixed with D2L_HEADLESS=false if they need to see the sign-in page and finish it by hand.`;
|
|
15
15
|
const SUCCESS = "Signed in to Brightspace. The session is saved on this computer, so other Brightspace apps here can use it too.";
|
|
16
16
|
/** What an open window means for the caller: this one waits on a person, not on a retry. */
|
|
17
|
-
const
|
|
17
|
+
const WINDOW_FINISH = "Tell the user to complete it, including any 2FA, then ask them to say when they are done.";
|
|
18
|
+
const WINDOW_ASKED = `A browser window is opening on the user's screen because a window was requested. ${WINDOW_FINISH}`;
|
|
19
|
+
const WINDOW_NO_PASSWORD = `A browser window is opening on the user's screen because no password is saved on this computer, so the user has to type it themselves. ${WINDOW_FINISH}`;
|
|
20
|
+
const WINDOW_JOINED = "No window was opened: a sign-in was already running, and this call joined it.";
|
|
18
21
|
const NEEDS_PASSWORD_FOR_HEADLESS = `No password is saved on this computer, so a sign-in with no window has nothing to type into the school's form and would only wait and time out. Either call sign_in again with visible true, or tell the user to run \`${SETUP_COMMAND}\` to save one.`;
|
|
19
22
|
/**
|
|
20
23
|
* A sign-in still working is progress, not a failure, so these are not marked
|
|
@@ -58,7 +61,7 @@ export function registerSignIn(server, config, tokenManager, authRunner) {
|
|
|
58
61
|
server.registerTool("sign_in", {
|
|
59
62
|
title: "Sign In to Brightspace",
|
|
60
63
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
61
|
-
description: "Sign in to Brightspace on this computer so the other tools work. Does nothing and says so if the saved session is already valid, so it is safe to call first when you are unsure. Opens a browser window for the user to sign in by hand when no password is saved (nothing to type means a windowless sign-in can only time out); otherwise it signs in in the background and reports the 2FA number for the user to approve on their phone. Call this when a Brightspace tool failed because of authentication, or when the user asks to sign in or log in to Brightspace. Every answer says what to do next, including whether to call again.",
|
|
64
|
+
description: "Sign in to Brightspace on this computer so the other tools work. Does nothing and says so if the saved session is already valid, so it is safe to call first when you are unsure. Opens a browser window for the user to sign in by hand when no password is saved (nothing to type means a windowless sign-in can only time out); otherwise it signs in in the background and reports the 2FA number for the user to approve on their phone. A window is for the user's own hands only (no saved password, or when the user asks to see one): after a \"could not be reached\", stuck, or timeout answer, call sign_in again without visible. Call this when a Brightspace tool failed because of authentication, or when the user asks to sign in or log in to Brightspace. Every answer says what to do next, including whether to call again.",
|
|
62
65
|
inputSchema: SignInSchema,
|
|
63
66
|
}, async ({ visible }) => {
|
|
64
67
|
if (config.envAccessToken || config.envSessionCookie)
|
|
@@ -98,7 +101,8 @@ export function registerSignIn(server, config, tokenManager, authRunner) {
|
|
|
98
101
|
catch (error) {
|
|
99
102
|
if (error instanceof AuthProcessError) {
|
|
100
103
|
const guidance = authPendingNotice(error);
|
|
101
|
-
return text(windowOpened ? `${
|
|
104
|
+
return text(windowOpened ? `${visible === true ? WINDOW_ASKED : WINDOW_NO_PASSWORD} ${guidance}`
|
|
105
|
+
: wantWindow ? `${WINDOW_JOINED} ${guidance}` : guidance, !STILL_WORKING.has(error.kind));
|
|
102
106
|
}
|
|
103
107
|
// Everything else goes through the house path, which logs it and
|
|
104
108
|
// keeps stack traces and raw responses out of the answer.
|
|
@@ -139,9 +139,13 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
139
139
|
secureStorage: "The operating system credential store is locked or unavailable, so the saved " +
|
|
140
140
|
"password could not be read. Unlock your keychain or keyring, then try again.",
|
|
141
141
|
transport: "Brightspace could not be reached to sign in. The saved session was kept. " +
|
|
142
|
-
"
|
|
142
|
+
"Call sign_in once more without visible; forcing a browser window does not help. " +
|
|
143
|
+
"If it fails again, ask the user to check their connection and try again in a few minutes.",
|
|
144
|
+
stuck: "Sign-in got stuck on the school's sign-in page and stopped; the saved session was kept. " +
|
|
145
|
+
"Call sign_in again — no window is needed.",
|
|
143
146
|
timeout: "The sign-in did not finish in time, usually a missed MFA prompt, and has stopped: the prompt it " +
|
|
144
147
|
"sent no longer works. The next call starts a new sign-in, or says automatic sign-in is paused. " +
|
|
148
|
+
"Don't open a window to retry; a window does not skip the prompt. " +
|
|
145
149
|
`Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to complete it with the number visible.`,
|
|
146
150
|
failed: `The sign-in did not complete. Run \`${AUTH_COMMAND}\` in a terminal (from your home folder) to see why, ` +
|
|
147
151
|
"or `brightspace-setup` if your saved school or username is wrong.",
|
|
@@ -103,7 +103,7 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
|
|
|
103
103
|
if (["success", "error", "sessionExpired", "transport"].includes(fields.outcome ?? ""))
|
|
104
104
|
record.outcome = fields.outcome;
|
|
105
105
|
if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending",
|
|
106
|
-
"automaticPending", "inProgress", "mfaHeld"].includes(fields.reason ?? ""))
|
|
106
|
+
"automaticPending", "inProgress", "mfaHeld", "stuck"].includes(fields.reason ?? ""))
|
|
107
107
|
record.reason = fields.reason;
|
|
108
108
|
if (AUTH_PHASES.includes(fields.phase ?? ""))
|
|
109
109
|
record.phase = fields.phase;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.30",
|
|
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",
|