brightspace-mcp-server 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/build/api/cache.js +14 -1
- package/build/api/client.js +17 -5
- package/build/api/rate-limiter.js +24 -13
- package/build/auth/auth-cooldown.js +28 -4
- package/build/auth/auth-runner.js +194 -27
- package/build/auth/browser-auth.js +2 -2
- package/build/auth/credential-store.js +14 -1
- package/build/auth/duo-mfa.js +90 -6
- package/build/auth/purdue-sso.js +19 -2
- package/build/auth/session-store.js +22 -1
- package/build/auth/sso-flow.js +2 -1
- package/build/auth/suny-sso.js +8 -0
- package/build/auth-cli.js +8 -1
- 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-upcoming-due-dates.js +15 -5
- package/build/tools/tool-helpers.js +8 -6
- 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/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 {
|
|
@@ -17,6 +53,8 @@ export class DuoMfaHandler {
|
|
|
17
53
|
approvalAnnounced = false;
|
|
18
54
|
verificationCodeAnnounced = null;
|
|
19
55
|
passcodeSubmitted = false;
|
|
56
|
+
/** True once onMfaChallenge has been told about this login, code or not. */
|
|
57
|
+
announcedToCaller = false;
|
|
20
58
|
constructor(options) {
|
|
21
59
|
this.options = options;
|
|
22
60
|
}
|
|
@@ -27,24 +65,63 @@ export class DuoMfaHandler {
|
|
|
27
65
|
async handle(page) {
|
|
28
66
|
if (!this.isChallenge(page))
|
|
29
67
|
return false;
|
|
68
|
+
const verificationCode = await this.readVerificationCode(page);
|
|
30
69
|
if (!this.approvalAnnounced) {
|
|
31
70
|
this.approvalAnnounced = true;
|
|
32
71
|
log("WARN", "Waiting up to 5 minutes for Duo MFA approval on your device.");
|
|
72
|
+
this.options.onMfaChallenge?.(verificationCode);
|
|
73
|
+
if (verificationCode)
|
|
74
|
+
this.announcedToCaller = true;
|
|
33
75
|
}
|
|
34
|
-
const verificationCode = await this.readVerificationCode(page);
|
|
35
76
|
if (verificationCode && verificationCode !== this.verificationCodeAnnounced) {
|
|
36
77
|
this.verificationCodeAnnounced = verificationCode;
|
|
37
78
|
log("WARN", `Duo verification code: ${verificationCode}. Enter it in Duo Mobile.`);
|
|
79
|
+
if (!this.announcedToCaller) {
|
|
80
|
+
this.announcedToCaller = true;
|
|
81
|
+
this.options.onMfaChallenge?.(verificationCode);
|
|
82
|
+
}
|
|
38
83
|
}
|
|
39
84
|
await this.submitPasscode(page);
|
|
40
85
|
return true;
|
|
41
86
|
}
|
|
42
87
|
async readVerificationCode(page) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
88
|
+
return await this.readCodeElement(page) ?? await this.readScopedCode(page);
|
|
89
|
+
}
|
|
90
|
+
/** Duo's own verification-code element, when the prompt exposes one. */
|
|
91
|
+
async readCodeElement(page) {
|
|
92
|
+
for (const selector of VERIFICATION_CODE_SELECTORS) {
|
|
93
|
+
const code = await readCodeFrom(page.locator(selector).first());
|
|
94
|
+
if (code)
|
|
95
|
+
return code;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Fallback for a prompt that does not label its digits: the standalone
|
|
101
|
+
* numbers inside Duo's prompt container. Announce one only when they all
|
|
102
|
+
* agree — two different numbers mean this is not a screen this can read, and
|
|
103
|
+
* naming the wrong one costs the user the push.
|
|
104
|
+
*/
|
|
105
|
+
async readScopedCode(page) {
|
|
106
|
+
for (const selector of PROMPT_SCOPE_SELECTORS) {
|
|
107
|
+
const scope = page.locator(selector).first();
|
|
108
|
+
if (!await scope.isVisible().catch(() => false))
|
|
109
|
+
continue;
|
|
110
|
+
const found = [];
|
|
111
|
+
const candidates = await scope.getByText(VERIFICATION_CODE_PATTERN).all().catch(() => []);
|
|
112
|
+
for (const candidate of candidates) {
|
|
113
|
+
// Nested elements repeat the same text; only distinct values compete.
|
|
114
|
+
const code = await readCodeFrom(candidate);
|
|
115
|
+
if (code && !found.includes(code))
|
|
116
|
+
found.push(code);
|
|
117
|
+
}
|
|
118
|
+
if (found.length > 1) {
|
|
119
|
+
log("DEBUG", `Duo prompt showed ${found.length} standalone numbers; announcing none of them.`);
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
return found[0] ?? null;
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
48
125
|
}
|
|
49
126
|
async submitPasscode(page) {
|
|
50
127
|
if (this.options.headless === false || this.passcodeSubmitted)
|
|
@@ -59,6 +136,13 @@ export class DuoMfaHandler {
|
|
|
59
136
|
const code = await this.options.requestMfaCode();
|
|
60
137
|
if (!/^\d{6,8}$/.test(code))
|
|
61
138
|
throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
|
|
139
|
+
// That prompt blocks on a person for as long as they take to find the
|
|
140
|
+
// code, and Duo expires its prompt and redirects on its own schedule. A
|
|
141
|
+
// passcode is a credential: confirm it is still Duo's page receiving it
|
|
142
|
+
// rather than whatever the browser moved on to.
|
|
143
|
+
if (!this.isChallenge(page)) {
|
|
144
|
+
throw new UnsupportedAuthenticationError(`The Duo prompt closed before the passcode was entered. Run \`${AUTH_COMMAND}\` to retry.`);
|
|
145
|
+
}
|
|
62
146
|
await input.fill(code);
|
|
63
147
|
const verify = page.getByRole("button", { name: /verify/i }).first();
|
|
64
148
|
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
|
}
|
|
@@ -176,6 +184,8 @@ export class PurdueSSOFlow {
|
|
|
176
184
|
const deadline = Date.now() + MFA_TIMEOUT_MS;
|
|
177
185
|
let challenged = false;
|
|
178
186
|
let announced = null;
|
|
187
|
+
/** True once onMfaChallenge has been told about this login, number or not. */
|
|
188
|
+
let announcedToCaller = false;
|
|
179
189
|
try {
|
|
180
190
|
while (Date.now() < deadline) {
|
|
181
191
|
if (await this.duoMfa.handle(page))
|
|
@@ -189,10 +199,17 @@ export class PurdueSSOFlow {
|
|
|
189
199
|
if (challengeVisible && !challenged) {
|
|
190
200
|
challenged = true;
|
|
191
201
|
log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
|
|
202
|
+
this.config.onMfaChallenge?.(number);
|
|
203
|
+
if (number)
|
|
204
|
+
announcedToCaller = true;
|
|
192
205
|
}
|
|
193
206
|
if (number && number !== announced) {
|
|
194
207
|
announced = number;
|
|
195
208
|
log("WARN", `Number match: ${number}. Enter it in Microsoft Authenticator.`);
|
|
209
|
+
if (!announcedToCaller) {
|
|
210
|
+
announcedToCaller = true;
|
|
211
|
+
this.config.onMfaChallenge?.(number);
|
|
212
|
+
}
|
|
196
213
|
}
|
|
197
214
|
if (await this.isAuthenticated(page)) {
|
|
198
215
|
log("INFO", "Login successful - verified Brightspace home");
|
|
@@ -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/sso-flow.js
CHANGED
|
@@ -35,13 +35,14 @@ export class MfaApprovalError extends BrowserAuthError {
|
|
|
35
35
|
* else uses the default flow, which already covers the common Shibboleth,
|
|
36
36
|
* CAS, and Microsoft Entra forms.
|
|
37
37
|
*/
|
|
38
|
-
export function createSSOFlow(config, requestMfaCode) {
|
|
38
|
+
export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
|
|
39
39
|
const credentials = {
|
|
40
40
|
username: config.username,
|
|
41
41
|
password: config.password,
|
|
42
42
|
baseUrl: config.baseUrl,
|
|
43
43
|
headless: config.headless,
|
|
44
44
|
requestMfaCode,
|
|
45
|
+
onMfaChallenge,
|
|
45
46
|
};
|
|
46
47
|
if (isSunyBrightspace(config.baseUrl)) {
|
|
47
48
|
return new SunySSOFlow({ ...credentials, campus: config.campus });
|
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/";
|
|
@@ -55,6 +56,7 @@ export class SunySSOFlow {
|
|
|
55
56
|
baseUrl: `https://${SUNY_BRIGHTSPACE_HOST}`,
|
|
56
57
|
headless: config.headless,
|
|
57
58
|
requestMfaCode: config.requestMfaCode,
|
|
59
|
+
onMfaChallenge: config.onMfaChallenge,
|
|
58
60
|
});
|
|
59
61
|
}
|
|
60
62
|
hasCredentials() {
|
|
@@ -73,6 +75,12 @@ export class SunySSOFlow {
|
|
|
73
75
|
await this.selectCampus(page);
|
|
74
76
|
}
|
|
75
77
|
catch (error) {
|
|
78
|
+
// selectCampus already names the cause and, for a mismatch, lists the
|
|
79
|
+
// campuses SUNY itself is offering. Re-wrapping those threw away the
|
|
80
|
+
// only list a user can act on, so pass an already-typed failure through
|
|
81
|
+
// and describe only the ones that arrive untyped.
|
|
82
|
+
if (error instanceof BrowserAuthError)
|
|
83
|
+
throw error;
|
|
76
84
|
throw new UnsupportedAuthenticationError("SUNY campus selection could not complete automatically. Run brightspace-mcp-server setup --suny and select a campus.", error);
|
|
77
85
|
}
|
|
78
86
|
return this.defaultFlow.login(page);
|
package/build/auth-cli.js
CHANGED
|
@@ -56,7 +56,14 @@ async function main() {
|
|
|
56
56
|
tokenTtl: config.tokenTtl,
|
|
57
57
|
});
|
|
58
58
|
const codePrompt = config.headless && !automatic && process.stdin.isTTY ? requestMfaCode : undefined;
|
|
59
|
-
|
|
59
|
+
// In automatic mode, tell the parent (AuthRunner) about an MFA challenge
|
|
60
|
+
// the moment it appears, so a blocked tool call can answer within
|
|
61
|
+
// seconds instead of waiting out the whole approval window. Stdout only
|
|
62
|
+
// — the parent parses stdout for structured markers, never stderr.
|
|
63
|
+
const onMfaChallenge = automatic
|
|
64
|
+
? (number) => console.log(number ? `MFA_NUMBER:${number}` : "MFA_PENDING")
|
|
65
|
+
: undefined;
|
|
66
|
+
await new BrowserAuth(config, { requestMfaCode: codePrompt, onMfaChallenge }).authenticate({
|
|
60
67
|
automatic,
|
|
61
68
|
onAuthenticated: async (token) => {
|
|
62
69
|
await tokenManager.setToken(token);
|
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
|
+
}
|
|
@@ -6,7 +6,10 @@
|
|
|
6
6
|
import { DownloadFileSchema } from "./schemas.js";
|
|
7
7
|
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
8
8
|
import { log } from "../utils/logger.js";
|
|
9
|
-
|
|
9
|
+
// Path containment and magic-byte checks belong to secureDownload, which both
|
|
10
|
+
// download paths below go through; importing them here only made it look as
|
|
11
|
+
// though this file validated anything itself.
|
|
12
|
+
import { validateContentId, MAX_FILE_SIZE } from "../utils/file-validator.js";
|
|
10
13
|
import { secureDownload } from "../utils/download-helpers.js";
|
|
11
14
|
import fs from "node:fs/promises";
|
|
12
15
|
import path from "node:path";
|
|
@@ -144,11 +147,26 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
|
|
|
144
147
|
if (!submissions || submissions.length === 0) {
|
|
145
148
|
return errorResponse("No submissions found for this assignment. Upload a submission first.");
|
|
146
149
|
}
|
|
147
|
-
// Find the file
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
150
|
+
// Find the file across every submission.
|
|
151
|
+
//
|
|
152
|
+
// A resubmitted assignment answers with one entry per submission, each with
|
|
153
|
+
// its own Files. Reading submissions[0] alone reported "not found" for a file
|
|
154
|
+
// the same response had just returned, and the download URL below needs the
|
|
155
|
+
// id of the submission the file actually belongs to, not the first one's.
|
|
156
|
+
// Files is absent on a submission with no attachments, so it is not assumed.
|
|
157
|
+
let submission;
|
|
158
|
+
let file;
|
|
159
|
+
for (const candidate of submissions) {
|
|
160
|
+
const match = (candidate.Files ?? []).find((f) => f.FileId === fileId);
|
|
161
|
+
if (match) {
|
|
162
|
+
submission = candidate;
|
|
163
|
+
file = match;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (!submission || !file) {
|
|
168
|
+
const available = submissions.flatMap((s) => s.Files ?? []);
|
|
169
|
+
return errorResponse(`File ID ${fileId} not found in submission. Available files: ${available.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
|
|
152
170
|
}
|
|
153
171
|
// Check file size before downloading
|
|
154
172
|
if (file.Size > MAX_FILE_SIZE) {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { fetchAllItems } from "../api/paginate.js";
|
|
7
8
|
import { GetAnnouncementsSchema, } from "./schemas.js";
|
|
8
9
|
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
@@ -102,11 +103,16 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
102
103
|
: announcements);
|
|
103
104
|
}
|
|
104
105
|
// All courses case
|
|
105
|
-
// First, fetch enrolled courses
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
// First, fetch enrolled courses. isActive=true is the configured
|
|
107
|
+
// policy, not a constant: with activeOnly off the user asked to see
|
|
108
|
+
// past courses, and the server would otherwise drop them before
|
|
109
|
+
// applyCourseFilter ever saw them.
|
|
110
|
+
const enrollmentPath = apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`);
|
|
111
|
+
// myenrollments is bookmark-paged; reading only the first page hides
|
|
112
|
+
// every course past it, and with it every announcement they carry.
|
|
113
|
+
const enrollmentItems = await fetchAllItems(apiClient, enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
|
|
108
114
|
// Apply course filter
|
|
109
|
-
const filteredEnrollments = applyCourseFilter(
|
|
115
|
+
const filteredEnrollments = applyCourseFilter(enrollmentItems.map(item => ({
|
|
110
116
|
id: item.OrgUnit.Id,
|
|
111
117
|
name: item.OrgUnit.Name,
|
|
112
118
|
code: item.OrgUnit.Code,
|
|
@@ -149,7 +155,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
149
155
|
const announcements = allMatched
|
|
150
156
|
.sort(newestFirst)
|
|
151
157
|
.slice(0, count);
|
|
152
|
-
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total across ${
|
|
158
|
+
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total across ${enrollmentItems.length} courses)`);
|
|
153
159
|
return toolResponse(modifiedSince
|
|
154
160
|
? {
|
|
155
161
|
announcements,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { fetchAllItems } from "../api/paginate.js";
|
|
7
8
|
import { GetAssignmentsSchema } from "./schemas.js";
|
|
8
9
|
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
10
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
@@ -357,11 +358,16 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
357
358
|
return toolResponse({ courseId, assignments });
|
|
358
359
|
}
|
|
359
360
|
// All courses case
|
|
360
|
-
// First, fetch enrolled courses
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
// First, fetch enrolled courses. isActive=true is the configured
|
|
362
|
+
// policy, not a constant: with activeOnly off the user asked to see
|
|
363
|
+
// past courses, and the server would otherwise drop them before
|
|
364
|
+
// applyCourseFilter ever saw them.
|
|
365
|
+
const enrollmentPath = apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`);
|
|
366
|
+
// myenrollments is bookmark-paged; reading only the first page hides
|
|
367
|
+
// every course past it, and with it every assignment they carry.
|
|
368
|
+
const enrollmentItems = await fetchAllItems(apiClient, enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
|
|
363
369
|
// Apply course filter
|
|
364
|
-
const filteredEnrollments = applyCourseFilter(
|
|
370
|
+
const filteredEnrollments = applyCourseFilter(enrollmentItems.map(item => ({
|
|
365
371
|
id: item.OrgUnit.Id,
|
|
366
372
|
name: item.OrgUnit.Name,
|
|
367
373
|
code: item.OrgUnit.Code,
|
|
@@ -392,7 +398,7 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
392
398
|
const courses = results
|
|
393
399
|
.filter((r) => r.status === "fulfilled" && r.value !== null)
|
|
394
400
|
.map((r) => r.value);
|
|
395
|
-
log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses (out of ${
|
|
401
|
+
log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses (out of ${enrollmentItems.length} enrolled)`);
|
|
396
402
|
return toolResponse({ courses });
|
|
397
403
|
}
|
|
398
404
|
catch (error) {
|