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.
@@ -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
- const target = page.getByText(/^\d{3,6}$/).first();
44
- if (!await target.isVisible().catch(() => false))
45
- return null;
46
- const code = (await target.textContent().catch(() => null))?.trim();
47
- return code && /^\d{3,6}$/.test(code) ? code : null;
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))
@@ -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
- if (!this.accountHintSubmitted) {
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)));
@@ -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 });
@@ -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
- await new BrowserAuth(config, codePrompt).authenticate({
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
- // Parse --purdue, --osu, etc. from argv
49
- const schoolFlag = process.argv.find((a) => a.startsWith("--"))?.replace(/^--/, "").toLowerCase();
50
- const preset = schoolFlag ? SCHOOL_PRESETS[schoolFlag] : undefined;
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
- function configureMcpClient(configPath) {
178
- let config = { mcpServers: {} };
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
- const raw = fs.readFileSync(configPath, "utf-8");
183
- config = JSON.parse(raw);
204
+ parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
184
205
  }
185
206
  catch {
186
- // If we can't parse, start fresh but warn
187
- console.log(yellow(" Warning: existing config was invalid, creating new one."));
188
- config = { mcpServers: {} };
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
- if (!config.mcpServers) {
192
- config.mcpServers = {};
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
- config.mcpServers["brightspace"] = isWindows
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
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
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
- if (campus) {
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
- main().catch((err) => {
471
- console.error("Setup failed:", err instanceof Error ? err.message : String(err));
472
- process.exit(1);
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
- import { validateContentId, MAX_FILE_SIZE, } from "../utils/file-validator.js";
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 in the submission
148
- const submission = submissions[0];
149
- const file = submission.Files.find((f) => f.FileId === fileId);
150
- if (!file) {
151
- return errorResponse(`File ID ${fileId} not found in submission. Available files: ${submission.Files.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
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
- const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
107
- const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
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(enrollmentResponse.Items.map(item => ({
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 ${enrollmentResponse.Items.length} courses)`);
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
- const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
362
- const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
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(enrollmentResponse.Items.map(item => ({
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 ${enrollmentResponse.Items.length} enrolled)`);
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) {