brightspace-mcp-server 3.3.0 → 3.4.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 -0
- package/build/api/cache.js +14 -1
- package/build/api/client.js +12 -3
- package/build/api/rate-limiter.js +22 -11
- package/build/auth/auth-cooldown.js +27 -3
- package/build/auth/credential-store.js +14 -1
- package/build/auth/duo-mfa.js +80 -5
- package/build/auth/purdue-sso.js +10 -2
- package/build/auth/session-store.js +22 -1
- package/build/auth/suny-sso.js +7 -0
- package/build/index.js +4 -3
- package/build/setup.js +123 -25
- package/build/tools/download-file.js +24 -6
- package/build/tools/get-announcements.js +11 -5
- package/build/tools/get-assignments.js +11 -5
- package/build/tools/get-course-content.js +12 -2
- package/build/tools/get-my-grades.js +11 -5
- package/build/tools/get-server-info.js +32 -0
- package/build/tools/get-upcoming-due-dates.js +15 -5
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +1 -0
- package/build/update.js +38 -5
- package/build/utils/atomic-write.js +38 -12
- package/build/utils/deep-links.js +17 -3
- package/build/utils/download-helpers.js +11 -5
- package/build/utils/file-validator.js +71 -5
- package/build/utils/html-converter.js +66 -0
- package/build/utils/logger.js +19 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -117,6 +117,7 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
117
117
|
| Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
|
|
118
118
|
| Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
|
|
119
119
|
| Video transcripts | "What did the professor say about pinch-off in Tuesday's lecture recording?" · "Summarize last week's BoilerCast video" — works for Kaltura and YouTube embeds; other platforms report that they aren't supported yet |
|
|
120
|
+
| Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, and whether a credential is stored, without contacting Brightspace or revealing secrets |
|
|
120
121
|
| Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, and graded discussion topics (any topic with a due date) |
|
|
121
122
|
|
|
122
123
|
|
package/build/api/cache.js
CHANGED
|
@@ -22,12 +22,25 @@ export class TTLCache {
|
|
|
22
22
|
timerId.unref();
|
|
23
23
|
}
|
|
24
24
|
// Store entry
|
|
25
|
-
this.cache.set(key, { data: value, timerId });
|
|
25
|
+
this.cache.set(key, { data: value, timerId, storedAt: Date.now() });
|
|
26
26
|
}
|
|
27
27
|
get(key) {
|
|
28
28
|
const entry = this.cache.get(key);
|
|
29
29
|
return entry?.data;
|
|
30
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* How long ago this key was written, in milliseconds, or undefined when it
|
|
33
|
+
* is not cached.
|
|
34
|
+
*
|
|
35
|
+
* One key can be written under several TTLs: two tools ask for the same
|
|
36
|
+
* path with different freshness requirements. An entry another caller kept
|
|
37
|
+
* alive for an hour is still too old for a caller that asked for ten
|
|
38
|
+
* minutes, and only its age can say so.
|
|
39
|
+
*/
|
|
40
|
+
ageOf(key) {
|
|
41
|
+
const entry = this.cache.get(key);
|
|
42
|
+
return entry ? Date.now() - entry.storedAt : undefined;
|
|
43
|
+
}
|
|
31
44
|
has(key) {
|
|
32
45
|
return this.cache.has(key);
|
|
33
46
|
}
|
package/build/api/client.js
CHANGED
|
@@ -183,9 +183,18 @@ export class D2LApiClient {
|
|
|
183
183
|
async get(path, options) {
|
|
184
184
|
// Checked before the path is resolved, and keyed by the path as the caller
|
|
185
185
|
// wrote it, so a cached read needs neither version discovery nor a token.
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
186
|
+
//
|
|
187
|
+
// The entry has to be younger than this caller's own TTL, not merely
|
|
188
|
+
// still alive. One path can be written under two TTLs: the discussion
|
|
189
|
+
// forum list is thirty minutes of course content to get_discussions and
|
|
190
|
+
// ten minutes of due dates to get_upcoming_due_dates. Trusting the
|
|
191
|
+
// surviving entry served the longer caller's staleness to the shorter one.
|
|
192
|
+
if (options?.ttl) {
|
|
193
|
+
const age = this.cache.ageOf(path);
|
|
194
|
+
if (age !== undefined && age <= options.ttl) {
|
|
195
|
+
log("DEBUG", `Cache hit: ${path}`);
|
|
196
|
+
return this.cache.get(path);
|
|
197
|
+
}
|
|
189
198
|
}
|
|
190
199
|
const resolved = await this.resolvePath(path);
|
|
191
200
|
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
|
|
@@ -25,21 +25,30 @@ export class TokenBucket {
|
|
|
25
25
|
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
|
|
26
26
|
this.lastRefill = now;
|
|
27
27
|
}
|
|
28
|
+
/**
|
|
29
|
+
* Take `count` tokens, waiting for them if the bucket is short.
|
|
30
|
+
*
|
|
31
|
+
* The tokens are deducted before the wait rather than after it, so
|
|
32
|
+
* concurrent callers queue behind one another. Deducting afterwards let
|
|
33
|
+
* every caller read the same empty bucket, wait for the same single token,
|
|
34
|
+
* and then wake together and take it: a fan-out across courses drained the
|
|
35
|
+
* burst and then admitted the whole remainder one refill later, which is the
|
|
36
|
+
* 429 storm this limiter exists to prevent. It also left the balance deeply
|
|
37
|
+
* negative, so the next unrelated request paid the whole debt in one wait.
|
|
38
|
+
*
|
|
39
|
+
* A negative balance is that debt, owed by the waiters already queued, and
|
|
40
|
+
* refill() pays it down at the refill rate.
|
|
41
|
+
*/
|
|
28
42
|
async consume(count = 1) {
|
|
29
43
|
this.refill();
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
44
|
+
const shortfall = count - this.tokens;
|
|
45
|
+
this.tokens -= count;
|
|
46
|
+
if (shortfall <= 0) {
|
|
47
|
+
// Enough tokens were already banked - proceed immediately.
|
|
33
48
|
return;
|
|
34
49
|
}
|
|
35
|
-
|
|
36
|
-
const tokensNeeded = count - this.tokens;
|
|
37
|
-
const waitTimeMs = (tokensNeeded / this.refillRate) * 1000;
|
|
38
|
-
// Wait for tokens to refill
|
|
50
|
+
const waitTimeMs = (shortfall / this.refillRate) * 1000;
|
|
39
51
|
await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
|
|
40
|
-
// Refill and consume
|
|
41
|
-
this.refill();
|
|
42
|
-
this.tokens -= count;
|
|
43
52
|
}
|
|
44
53
|
tryConsume(count = 1) {
|
|
45
54
|
this.refill();
|
|
@@ -49,8 +58,10 @@ export class TokenBucket {
|
|
|
49
58
|
}
|
|
50
59
|
return false;
|
|
51
60
|
}
|
|
61
|
+
/** Tokens free to take right now. Never negative: an outstanding
|
|
62
|
+
* reservation is a debt, not a negative supply. */
|
|
52
63
|
get availableTokens() {
|
|
53
64
|
this.refill();
|
|
54
|
-
return this.tokens;
|
|
65
|
+
return Math.max(0, this.tokens);
|
|
55
66
|
}
|
|
56
67
|
}
|
|
@@ -24,6 +24,20 @@ export class AuthenticationCooldownError extends Error {
|
|
|
24
24
|
this.name = "AuthenticationCooldownError";
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/** The recorded retry time, or undefined when the file says nothing usable. */
|
|
28
|
+
function readRetryAt(content) {
|
|
29
|
+
let status;
|
|
30
|
+
try {
|
|
31
|
+
status = JSON.parse(content);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
if (typeof status !== "object" || status === null || Array.isArray(status))
|
|
37
|
+
return undefined;
|
|
38
|
+
const retryAt = status.retryAt;
|
|
39
|
+
return typeof retryAt === "number" && Number.isFinite(retryAt) ? retryAt : undefined;
|
|
40
|
+
}
|
|
27
41
|
/** Non-secret retry metadata. Call only while holding the authentication lock. */
|
|
28
42
|
export class AuthCooldown {
|
|
29
43
|
file;
|
|
@@ -40,9 +54,19 @@ export class AuthCooldown {
|
|
|
40
54
|
return;
|
|
41
55
|
throw error;
|
|
42
56
|
}
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
57
|
+
const retryAt = readRetryAt(content);
|
|
58
|
+
if (retryAt === undefined) {
|
|
59
|
+
// This file is non-secret retry metadata, and only a readable retryAt is
|
|
60
|
+
// evidence of anything. A truncated or non-object file used to throw out
|
|
61
|
+
// of here, which runs before the sign-in attempt — and the automatic path
|
|
62
|
+
// only clears the file after a sign-in that then never started. One
|
|
63
|
+
// damaged file disabled background sign-in for good, with nothing on
|
|
64
|
+
// screen naming the cause. Discard it and let this attempt proceed.
|
|
65
|
+
await fs.unlink(this.file).catch(() => { });
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (retryAt > Date.now())
|
|
69
|
+
throw new AuthenticationCooldownError(retryAt);
|
|
46
70
|
}
|
|
47
71
|
async recordMfaFailure() {
|
|
48
72
|
await this.write({ retryAt: Date.now() + MFA_COOLDOWN_MS });
|
|
@@ -178,11 +178,24 @@ function decodeKey(value) {
|
|
|
178
178
|
}
|
|
179
179
|
return Buffer.from(value, "hex");
|
|
180
180
|
}
|
|
181
|
+
function sessionKeyAccount(canonicalDir) {
|
|
182
|
+
return `session-key:${createHash("sha256").update(canonicalDir).digest("hex")}`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Whether this session directory already holds a native encryption key, which
|
|
186
|
+
* is what separates an upgrade that has not run yet from one that finished.
|
|
187
|
+
* A locked or unavailable store raises instead of answering false, so a
|
|
188
|
+
* caller never mistakes "cannot tell" for "not migrated".
|
|
189
|
+
*/
|
|
190
|
+
export async function hasSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend) {
|
|
191
|
+
const canonicalDir = await fs.realpath(sessionDir);
|
|
192
|
+
return (await backend.getPassword(SERVICE, sessionKeyAccount(canonicalDir))) !== null;
|
|
193
|
+
}
|
|
181
194
|
/** The canonical session directory stays stable when DHCP changes the hostname. */
|
|
182
195
|
export async function getSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend, create = true) {
|
|
183
196
|
await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
|
|
184
197
|
const canonicalDir = await fs.realpath(sessionDir);
|
|
185
|
-
const account =
|
|
198
|
+
const account = sessionKeyAccount(canonicalDir);
|
|
186
199
|
const existing = await backend.getPassword(SERVICE, account);
|
|
187
200
|
if (existing !== null) {
|
|
188
201
|
try {
|
package/build/auth/duo-mfa.js
CHANGED
|
@@ -1,6 +1,42 @@
|
|
|
1
1
|
import { log } from "../utils/logger.js";
|
|
2
2
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
3
3
|
import { UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
4
|
+
/** Duo's verified-push digits: three to six, per Duo's push-verification policy. */
|
|
5
|
+
const VERIFICATION_CODE_PATTERN = /^\d{3,6}$/;
|
|
6
|
+
/**
|
|
7
|
+
* Duo renders the verified-push digits in an element of its own. Read that
|
|
8
|
+
* element rather than the document at large: an unscoped
|
|
9
|
+
* page.getByText(/^\d{3,6}$/) matches ANY standalone three-to-six digit text on
|
|
10
|
+
* the page — a masked phone number's last four, a countdown, a "remembered for
|
|
11
|
+
* 30 days" line — and .first() then announces whichever one the DOM happened to
|
|
12
|
+
* render first. A headless run has no screen to check that against, so the
|
|
13
|
+
* wrong number is simply typed into Duo Mobile and the push is denied.
|
|
14
|
+
*/
|
|
15
|
+
const VERIFICATION_CODE_SELECTORS = [
|
|
16
|
+
".verification-code",
|
|
17
|
+
"#verification-code",
|
|
18
|
+
"[class*='verification-code']",
|
|
19
|
+
];
|
|
20
|
+
/**
|
|
21
|
+
* Duo's prompt surface, innermost first, bounding the fallback text scan for
|
|
22
|
+
* the day Duo renames the element above. The first one on screen decides:
|
|
23
|
+
* numbers rendered outside Duo's own prompt are not the code.
|
|
24
|
+
*/
|
|
25
|
+
const PROMPT_SCOPE_SELECTORS = [
|
|
26
|
+
"#auth-view",
|
|
27
|
+
".base-wrapper",
|
|
28
|
+
"#root",
|
|
29
|
+
"#app",
|
|
30
|
+
"main",
|
|
31
|
+
"body",
|
|
32
|
+
];
|
|
33
|
+
/** The digits on a visible element, or null when it is absent or not digits. */
|
|
34
|
+
async function readCodeFrom(target) {
|
|
35
|
+
if (!await target.isVisible().catch(() => false))
|
|
36
|
+
return null;
|
|
37
|
+
const code = (await target.textContent().catch(() => null))?.trim();
|
|
38
|
+
return code && VERIFICATION_CODE_PATTERN.test(code) ? code : null;
|
|
39
|
+
}
|
|
4
40
|
/** Duo Universal Prompt redirects to a Duo-hosted page after primary sign-in. */
|
|
5
41
|
export function isDuoPrompt(page) {
|
|
6
42
|
try {
|
|
@@ -40,11 +76,43 @@ export class DuoMfaHandler {
|
|
|
40
76
|
return true;
|
|
41
77
|
}
|
|
42
78
|
async readVerificationCode(page) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
79
|
+
return await this.readCodeElement(page) ?? await this.readScopedCode(page);
|
|
80
|
+
}
|
|
81
|
+
/** Duo's own verification-code element, when the prompt exposes one. */
|
|
82
|
+
async readCodeElement(page) {
|
|
83
|
+
for (const selector of VERIFICATION_CODE_SELECTORS) {
|
|
84
|
+
const code = await readCodeFrom(page.locator(selector).first());
|
|
85
|
+
if (code)
|
|
86
|
+
return code;
|
|
87
|
+
}
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Fallback for a prompt that does not label its digits: the standalone
|
|
92
|
+
* numbers inside Duo's prompt container. Announce one only when they all
|
|
93
|
+
* agree — two different numbers mean this is not a screen this can read, and
|
|
94
|
+
* naming the wrong one costs the user the push.
|
|
95
|
+
*/
|
|
96
|
+
async readScopedCode(page) {
|
|
97
|
+
for (const selector of PROMPT_SCOPE_SELECTORS) {
|
|
98
|
+
const scope = page.locator(selector).first();
|
|
99
|
+
if (!await scope.isVisible().catch(() => false))
|
|
100
|
+
continue;
|
|
101
|
+
const found = [];
|
|
102
|
+
const candidates = await scope.getByText(VERIFICATION_CODE_PATTERN).all().catch(() => []);
|
|
103
|
+
for (const candidate of candidates) {
|
|
104
|
+
// Nested elements repeat the same text; only distinct values compete.
|
|
105
|
+
const code = await readCodeFrom(candidate);
|
|
106
|
+
if (code && !found.includes(code))
|
|
107
|
+
found.push(code);
|
|
108
|
+
}
|
|
109
|
+
if (found.length > 1) {
|
|
110
|
+
log("DEBUG", `Duo prompt showed ${found.length} standalone numbers; announcing none of them.`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return found[0] ?? null;
|
|
114
|
+
}
|
|
115
|
+
return null;
|
|
48
116
|
}
|
|
49
117
|
async submitPasscode(page) {
|
|
50
118
|
if (this.options.headless === false || this.passcodeSubmitted)
|
|
@@ -59,6 +127,13 @@ export class DuoMfaHandler {
|
|
|
59
127
|
const code = await this.options.requestMfaCode();
|
|
60
128
|
if (!/^\d{6,8}$/.test(code))
|
|
61
129
|
throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
|
|
130
|
+
// That prompt blocks on a person for as long as they take to find the
|
|
131
|
+
// code, and Duo expires its prompt and redirects on its own schedule. A
|
|
132
|
+
// passcode is a credential: confirm it is still Duo's page receiving it
|
|
133
|
+
// rather than whatever the browser moved on to.
|
|
134
|
+
if (!this.isChallenge(page)) {
|
|
135
|
+
throw new UnsupportedAuthenticationError(`The Duo prompt closed before the passcode was entered. Run \`${AUTH_COMMAND}\` to retry.`);
|
|
136
|
+
}
|
|
62
137
|
await input.fill(code);
|
|
63
138
|
const verify = page.getByRole("button", { name: /verify/i }).first();
|
|
64
139
|
if (await verify.isVisible().catch(() => false))
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -113,7 +113,16 @@ export class PurdueSSOFlow {
|
|
|
113
113
|
if (!this.config.password)
|
|
114
114
|
throw new BrowserAuthError("Password is required for SSO login", "credentials");
|
|
115
115
|
log("INFO", "Entering credentials");
|
|
116
|
-
|
|
116
|
+
// A submitted account hint only counts once Microsoft has actually left the
|
|
117
|
+
// email step. It keeps that field on screen whenever it rejects the hint,
|
|
118
|
+
// and clickWhenReady swallows a click that never landed on purpose (Entra
|
|
119
|
+
// normally detaches the button after navigating), so identifyAccount can
|
|
120
|
+
// report a success the page never granted. Skipping the email step there
|
|
121
|
+
// spends the whole password timeout on a page still asking for a username
|
|
122
|
+
// and then blames a missing password field.
|
|
123
|
+
const hintAccepted = this.accountHintSubmitted && !await this.anyVisible(page, EMAIL_SELECTORS);
|
|
124
|
+
this.accountHintSubmitted = false;
|
|
125
|
+
if (!hintAccepted) {
|
|
117
126
|
const email = signInName(this.config.username, this.config.baseUrl);
|
|
118
127
|
if (!await this.fillWhenReady(page, EMAIL_SELECTORS, email)) {
|
|
119
128
|
throw new UnsupportedAuthenticationError("The Microsoft email field did not appear. Automatic sign-in cannot continue.");
|
|
@@ -122,7 +131,6 @@ export class PurdueSSOFlow {
|
|
|
122
131
|
throw new UnsupportedAuthenticationError("The Microsoft email submit button did not appear. Automatic sign-in cannot continue.");
|
|
123
132
|
}
|
|
124
133
|
}
|
|
125
|
-
this.accountHintSubmitted = false;
|
|
126
134
|
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
|
|
127
135
|
throw new UnsupportedAuthenticationError("The Microsoft password field did not appear. Automatic sign-in cannot continue.");
|
|
128
136
|
}
|
|
@@ -9,7 +9,7 @@ import * as path from "node:path";
|
|
|
9
9
|
import * as os from "node:os";
|
|
10
10
|
import { SessionStoreError } from "../utils/errors.js";
|
|
11
11
|
import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
|
|
12
|
-
import { NativeCredentialStoreError } from "./credential-store.js";
|
|
12
|
+
import { hasSessionEncryptionKey, NativeCredentialStoreError } from "./credential-store.js";
|
|
13
13
|
import { decrypt, readEncryptedRecord, saveEncryptedRecord, trashFile } from "./encrypted-store.js";
|
|
14
14
|
const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".d2l-session");
|
|
15
15
|
function validTenantOrigin(origin) {
|
|
@@ -107,10 +107,27 @@ export class SessionStore {
|
|
|
107
107
|
throw new Error("Invalid saved session contents.");
|
|
108
108
|
return token;
|
|
109
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* A version 1 record is sealed with scrypt over the local account name and
|
|
112
|
+
* an on-disk salt: material every local process already has, so the record
|
|
113
|
+
* proves nothing about who wrote it. It is worth trusting only during the
|
|
114
|
+
* one-time upgrade, which by definition happens before this directory owns
|
|
115
|
+
* a native key. Once the key exists the upgrade has already run, and a
|
|
116
|
+
* version 1 file can only have been planted by something that could write
|
|
117
|
+
* the session directory but could not reach the credential store. Refuse it
|
|
118
|
+
* rather than let it replace the authenticated session.
|
|
119
|
+
*/
|
|
120
|
+
async assertLegacyUpgradePending() {
|
|
121
|
+
if (!await hasSessionEncryptionKey(this.sessionDir, this.options.backend))
|
|
122
|
+
return;
|
|
123
|
+
throw new SessionStoreError("An old unauthenticated session file appeared after this installation was already using native key storage. It was not trusted and was left in place. Sign in again to replace it.");
|
|
124
|
+
}
|
|
110
125
|
async loadUnlocked() {
|
|
111
126
|
const record = await this.readFile();
|
|
112
127
|
if (!record)
|
|
113
128
|
return null;
|
|
129
|
+
if (record.version === 1)
|
|
130
|
+
await this.assertLegacyUpgradePending();
|
|
114
131
|
const token = await this.decode(record);
|
|
115
132
|
if (record.version === 1)
|
|
116
133
|
await this.saveUnlocked(token);
|
|
@@ -122,6 +139,10 @@ export class SessionStore {
|
|
|
122
139
|
await saveEncryptedRecord(this.sessionDir, this.sessionFilePath, "session", token, this.options);
|
|
123
140
|
}
|
|
124
141
|
storeError(action, error) {
|
|
142
|
+
// A store error already carries the specific reason and the way out of it;
|
|
143
|
+
// wrapping it again would bury both behind the generic sentence below.
|
|
144
|
+
if (error instanceof SessionStoreError)
|
|
145
|
+
throw error;
|
|
125
146
|
if (error instanceof NativeCredentialStoreError || error instanceof AuthenticationInProgressError)
|
|
126
147
|
throw error;
|
|
127
148
|
throw new SessionStoreError(`Failed to ${action} session. Existing session data was preserved.`, error instanceof Error ? error : new Error(String(error)));
|
package/build/auth/suny-sso.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { PurdueSSOFlow } from "./purdue-sso.js";
|
|
7
7
|
import { log } from "../utils/logger.js";
|
|
8
8
|
import { UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
9
|
+
import { BrowserAuthError } from "../utils/errors.js";
|
|
9
10
|
/** SUNY campuses share one Brightspace tenant behind one Shibboleth IdP. */
|
|
10
11
|
const SUNY_BRIGHTSPACE_HOST = "mylearning.suny.edu";
|
|
11
12
|
const SUNY_IDP_ENTITY_ID = "https://idm.suny.edu/shibboleth/idp/";
|
|
@@ -73,6 +74,12 @@ export class SunySSOFlow {
|
|
|
73
74
|
await this.selectCampus(page);
|
|
74
75
|
}
|
|
75
76
|
catch (error) {
|
|
77
|
+
// selectCampus already names the cause and, for a mismatch, lists the
|
|
78
|
+
// campuses SUNY itself is offering. Re-wrapping those threw away the
|
|
79
|
+
// only list a user can act on, so pass an already-typed failure through
|
|
80
|
+
// and describe only the ones that arrive untyped.
|
|
81
|
+
if (error instanceof BrowserAuthError)
|
|
82
|
+
throw error;
|
|
76
83
|
throw new UnsupportedAuthenticationError("SUNY campus selection could not complete automatically. Run brightspace-mcp-server setup --suny and select a campus.", error);
|
|
77
84
|
}
|
|
78
85
|
return this.defaultFlow.login(page);
|
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, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, } from "./tools/index.js";
|
|
19
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
|
|
20
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
21
21
|
const __dirname = dirname(__filename);
|
|
22
22
|
const PKG_VERSION = (() => {
|
|
@@ -113,11 +113,12 @@ else {
|
|
|
113
113
|
registerGetSyllabus(server, apiClient);
|
|
114
114
|
registerGetDiscussions(server, apiClient);
|
|
115
115
|
registerGetVideoTranscript(server, apiClient);
|
|
116
|
-
|
|
116
|
+
registerGetServerInfo(server, config, PKG_VERSION);
|
|
117
|
+
log("DEBUG", "MCP tools registered (14 tools)");
|
|
117
118
|
// Connect stdio transport
|
|
118
119
|
const transport = new StdioServerTransport();
|
|
119
120
|
await server.connect(transport);
|
|
120
|
-
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (
|
|
121
|
+
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (14 tools registered)");
|
|
121
122
|
log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
|
|
122
123
|
}
|
|
123
124
|
catch (error) {
|
package/build/setup.js
CHANGED
|
@@ -14,6 +14,7 @@ import { spawn } from "node:child_process";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils/config-store.js";
|
|
16
16
|
import { saveSecureConfig } from "./utils/secure-config.js";
|
|
17
|
+
import { writeFileAtomicSync } from "./utils/atomic-write.js";
|
|
17
18
|
import { AUTH_COMMAND } from "./utils/commands.js";
|
|
18
19
|
import { cliMcpClients, configureCliMcpClient, isCliAvailable, } from "./utils/mcp-client-cli.js";
|
|
19
20
|
// ANSI helpers
|
|
@@ -22,7 +23,7 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
|
22
23
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
23
24
|
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
24
25
|
const thisDir = path.dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
const SCHOOL_PRESETS = {
|
|
26
|
+
export const SCHOOL_PRESETS = {
|
|
26
27
|
purdue: {
|
|
27
28
|
name: "Purdue University",
|
|
28
29
|
baseUrl: "https://purdue.brightspace.com",
|
|
@@ -45,9 +46,20 @@ const SCHOOL_PRESETS = {
|
|
|
45
46
|
usernameHint: "Use your full sign-in address if your Western account requires it.",
|
|
46
47
|
},
|
|
47
48
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Pick the school preset named by `--purdue`, `--suny`, `--western`, etc.
|
|
51
|
+
*
|
|
52
|
+
* Own properties only: a bare index would make `--constructor` or
|
|
53
|
+
* `--__proto__` resolve to something off `Object.prototype` and hand the
|
|
54
|
+
* wizard an object with no `baseUrl`.
|
|
55
|
+
*/
|
|
56
|
+
export function presetForArgv(argv = process.argv) {
|
|
57
|
+
const flag = argv.find((a) => a.startsWith("--"))?.replace(/^--/, "").toLowerCase();
|
|
58
|
+
if (!flag || !Object.prototype.hasOwnProperty.call(SCHOOL_PRESETS, flag))
|
|
59
|
+
return undefined;
|
|
60
|
+
return SCHOOL_PRESETS[flag];
|
|
61
|
+
}
|
|
62
|
+
const preset = presetForArgv();
|
|
51
63
|
// ── Readline helpers ───────────────────────────────────────────────
|
|
52
64
|
function ask(rl, question) {
|
|
53
65
|
return new Promise((resolve) => {
|
|
@@ -174,27 +186,45 @@ function isChatGPTInstalled() {
|
|
|
174
186
|
function getCursorConfigPath() {
|
|
175
187
|
return path.join(os.homedir(), ".cursor", "mcp.json");
|
|
176
188
|
}
|
|
177
|
-
|
|
178
|
-
|
|
189
|
+
/**
|
|
190
|
+
* A JSON value we can safely merge a server entry into. An array passes
|
|
191
|
+
* `typeof x === "object"` but drops every added key when it is stringified
|
|
192
|
+
* again, so it has to be rejected alongside `null`.
|
|
193
|
+
*/
|
|
194
|
+
function isJsonObject(value) {
|
|
195
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
196
|
+
}
|
|
197
|
+
export function configureMcpClient(configPath) {
|
|
198
|
+
let config = {};
|
|
179
199
|
// Read existing config if present
|
|
180
200
|
if (fs.existsSync(configPath)) {
|
|
201
|
+
let parsed;
|
|
202
|
+
let readable = true;
|
|
181
203
|
try {
|
|
182
|
-
|
|
183
|
-
config = JSON.parse(raw);
|
|
204
|
+
parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
184
205
|
}
|
|
185
206
|
catch {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
207
|
+
readable = false;
|
|
208
|
+
}
|
|
209
|
+
if (isJsonObject(parsed)) {
|
|
210
|
+
config = parsed;
|
|
211
|
+
}
|
|
212
|
+
else {
|
|
213
|
+
// Unparseable, or valid JSON that is not an object (null, an array, a
|
|
214
|
+
// bare string). Merging into it would either throw or silently discard
|
|
215
|
+
// the entry we just reported as written, so start fresh and say so.
|
|
216
|
+
console.log(yellow(` Warning: existing config was ${readable ? "not a JSON object" : "invalid"}, creating new one.`));
|
|
217
|
+
config = {};
|
|
189
218
|
}
|
|
190
219
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
220
|
+
// Same reasoning for the servers map itself, which is hand-edited far more
|
|
221
|
+
// often than the file around it.
|
|
222
|
+
const servers = isJsonObject(config.mcpServers) ? config.mcpServers : {};
|
|
223
|
+
config.mcpServers = servers;
|
|
194
224
|
// Add/update brightspace entry
|
|
195
225
|
// On Windows, npx is a .cmd shim that must be invoked through cmd.exe
|
|
196
226
|
const isWindows = process.platform === "win32";
|
|
197
|
-
|
|
227
|
+
servers["brightspace"] = isWindows
|
|
198
228
|
? {
|
|
199
229
|
command: "cmd",
|
|
200
230
|
args: ["/c", "npx", "-y", "brightspace-mcp-server@latest"],
|
|
@@ -208,9 +238,73 @@ function configureMcpClient(configPath) {
|
|
|
208
238
|
if (!fs.existsSync(dir)) {
|
|
209
239
|
fs.mkdirSync(dir, { recursive: true });
|
|
210
240
|
}
|
|
211
|
-
|
|
241
|
+
// This file holds every MCP server the user has configured, not just ours.
|
|
242
|
+
// A plain write truncates it first, so a write that fails part way through
|
|
243
|
+
// (a full disk, an I/O error) would leave the user with no MCP servers at
|
|
244
|
+
// all. Staging and renaming leaves either the old file or the new one.
|
|
245
|
+
// The existing permissions are carried over, since the rename would
|
|
246
|
+
// otherwise replace them with the umask default.
|
|
247
|
+
let mode;
|
|
248
|
+
try {
|
|
249
|
+
if (fs.existsSync(configPath))
|
|
250
|
+
mode = fs.statSync(configPath).mode & 0o777;
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
// Unreadable metadata is not a reason to skip the write.
|
|
254
|
+
}
|
|
255
|
+
writeFileAtomicSync(configPath, JSON.stringify(config, null, 2) + "\n", mode === undefined ? {} : { mode });
|
|
212
256
|
return true;
|
|
213
257
|
}
|
|
258
|
+
/** The settings already on disk, or null when there are none to read. */
|
|
259
|
+
export function readExistingConfig() {
|
|
260
|
+
try {
|
|
261
|
+
return configStoreExists() ? loadConfigStore() : null;
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
// An unreadable config is replaced by the setup values.
|
|
265
|
+
return null;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function sameSchool(stored, chosen) {
|
|
269
|
+
// A config that never recorded a school (environment-driven installs) is
|
|
270
|
+
// not a *different* school, so its settings are still ours to keep.
|
|
271
|
+
if (!stored)
|
|
272
|
+
return true;
|
|
273
|
+
try {
|
|
274
|
+
return new URL(stored).origin === new URL(chosen).origin;
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return false;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Merge the wizard's answers over the settings already saved.
|
|
282
|
+
*
|
|
283
|
+
* `saveConfigStore` replaces the whole file, and setup is the documented way
|
|
284
|
+
* to update a saved password — so it runs again on configurations that carry
|
|
285
|
+
* settings it never prompts for: the SUNY campus, course filters, a custom
|
|
286
|
+
* session directory or token TTL. Writing only the answers deleted all of
|
|
287
|
+
* them; most visibly, a SUNY user who reran plain `setup` lost the campus
|
|
288
|
+
* that lets sign-in skip the shared campus picker.
|
|
289
|
+
*
|
|
290
|
+
* Settings are carried only within one school, since course ids and the
|
|
291
|
+
* campus belong to a single tenant.
|
|
292
|
+
*/
|
|
293
|
+
export function buildConfigToSave(existing, answers) {
|
|
294
|
+
const carried = existing && sameSchool(existing.baseUrl, answers.baseUrl) ? existing : null;
|
|
295
|
+
const config = {
|
|
296
|
+
...carried,
|
|
297
|
+
baseUrl: answers.baseUrl,
|
|
298
|
+
username: answers.username,
|
|
299
|
+
// Always the freshly typed one: a carried v1 plaintext password would
|
|
300
|
+
// otherwise be the value written to the native store.
|
|
301
|
+
password: answers.password,
|
|
302
|
+
headless: answers.headless,
|
|
303
|
+
};
|
|
304
|
+
if (answers.campus)
|
|
305
|
+
config.campus = answers.campus;
|
|
306
|
+
return config;
|
|
307
|
+
}
|
|
214
308
|
// ── Auth spawn ─────────────────────────────────────────────────────
|
|
215
309
|
function runAuth() {
|
|
216
310
|
const scriptPath = path.resolve(thisDir, "auth-cli.js");
|
|
@@ -349,15 +443,13 @@ async function main() {
|
|
|
349
443
|
: " A browser window will open when authentication is needed."));
|
|
350
444
|
console.log("");
|
|
351
445
|
// ── Step 5: Save config ──────────────────────────────────────────
|
|
352
|
-
const config = {
|
|
446
|
+
const config = buildConfigToSave(readExistingConfig(), {
|
|
353
447
|
baseUrl,
|
|
354
448
|
username,
|
|
355
449
|
password,
|
|
356
450
|
headless,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
config.campus = campus;
|
|
360
|
-
}
|
|
451
|
+
campus: campus || undefined,
|
|
452
|
+
});
|
|
361
453
|
await saveSecureConfig(config);
|
|
362
454
|
console.log(green(" Password saved in your operating system credential store."));
|
|
363
455
|
console.log(green(" Config saved to: " + getConfigStorePath()));
|
|
@@ -467,7 +559,13 @@ async function main() {
|
|
|
467
559
|
}
|
|
468
560
|
console.log("");
|
|
469
561
|
}
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
562
|
+
// Both entry points — the `brightspace-setup` bin and `brightspace-mcp-server
|
|
563
|
+
// setup`, which imports this module — start the wizard here. VITEST is set
|
|
564
|
+
// only by the test runner, which imports the module for the helpers above and
|
|
565
|
+
// must not open prompts on stdin; no user environment sets it.
|
|
566
|
+
if (!process.env.VITEST) {
|
|
567
|
+
main().catch((err) => {
|
|
568
|
+
console.error("Setup failed:", err instanceof Error ? err.message : String(err));
|
|
569
|
+
process.exit(1);
|
|
570
|
+
});
|
|
571
|
+
}
|