brightspace-mcp-server 3.13.22 → 3.15.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 CHANGED
@@ -96,9 +96,9 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
96
96
 
97
97
  | Setting | What it does |
98
98
  |---------|--------------|
99
- | `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this and saves it as `rememberMfa` in `config.json`; the env var overrides it |
99
+ | `D2L_REMEMBER_MFA=true` | Tick Microsoft's "Don't ask again" box so later sign-ins skip the second factor (off by default; not for shared computers). `setup` asks this as option 2 of its one "how often do you want to approve sign-ins" question and saves it as `rememberMfa` in `config.json`; the env var overrides it ([details](docs/sign-in.md#fewer-2fa-prompts)) |
100
100
  | `D2L_PASSWORDLESS=true` | Sign in with Microsoft Entra's passwordless phone approval, so no password is saved on the computer (off by default; Entra schools only). Every sign-in, including automatic ones, then waits for you to approve on your phone, so it suits interactive use, not unattended or scheduled jobs. `setup` asks this and saves it as `passwordless` in `config.json`; the env var overrides it ([details](docs/sign-in.md#passwordless-sign-in)) |
101
- | `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup`, which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#answering-code-challenges-without-your-phone)) |
101
+ | `D2L_TOTP_SECRET` | An authenticator setup key, so a Microsoft Entra "enter a code" challenge is answered on this computer instead of on your phone (off by default). Prefer `setup` (option 3), which keeps the key in your OS keychain; this variable is for CI and containers with no keychain and is weaker, because anything that can read the process environment gets your second factor ([details](docs/sign-in.md#fewer-2fa-prompts)) |
102
102
  | `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
103
103
  | `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
104
104
  | `D2L_SESSION_COOKIE` / `D2L_ACCESS_TOKEN` | Skip the browser entirely with a pasted cookie or token ([how](docs/sign-in.md#no-browser-paste-a-session-cookie-or-token)) |
@@ -118,13 +118,7 @@ npx -y brightspace-mcp-server@latest auth
118
118
 
119
119
  **On a Mac,** sign-in first reads your saved password from Keychain. If you picked a one-time **Allow** at the Keychain prompt, a later sign-in from your AI app or another terminal can fail or hang waiting for an approval nobody sees; pick **Always Allow** instead. That permission only lets the local Brightspace server read its own saved sign-in password, nothing else in your Keychain.
120
120
 
121
- **If your school asks for a 6-digit code** rather than a phone approval, `setup` can save the
122
- authenticator **setup key** (not a code) you got when you enrolled the app. From then on automatic
123
- sign-ins generate the code on this computer and finish without you, which is what unattended and
124
- scheduled use needs. It is optional and off unless you paste a key. The tradeoff is real: the key
125
- sits beside your password, so on **this** computer your two factors become one. It still stops
126
- anyone who only has your password, and it changes nothing for your other devices. Full enrollment
127
- steps: [docs/sign-in.md](docs/sign-in.md#answering-code-challenges-without-your-phone).
121
+ **Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
128
122
 
129
123
  **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. If a sign-in is in progress, it refuses and deletes nothing.
130
124
 
@@ -327,11 +327,20 @@ export class D2LApiClient {
327
327
  * read by a read-only tool is not worth an MFA prompt.
328
328
  */
329
329
  async getPage(path) {
330
+ const response = await this.getPageRaw(path);
331
+ return response ? await response.text() : null;
332
+ }
333
+ /**
334
+ * Like getPage, but returns the response itself so a binary body (a course
335
+ * file reached through a quickLink or /content/enforced path) is not decoded
336
+ * as text. Same cookie, same origin check, same null on a dead session.
337
+ */
338
+ async getPageRaw(path) {
330
339
  // The cookie belongs to this Brightspace alone. A path that would make
331
340
  // `${baseUrl}${path}` name another host (`@evil.example/`, `.evil.example/`,
332
341
  // `//evil.example/`) is refused before the cookie is ever read.
333
342
  if (!this.isSameOriginPath(path)) {
334
- log("WARN", "getPage refused a path outside the Brightspace origin");
343
+ log("WARN", "getPageRaw refused a path outside the Brightspace origin");
335
344
  return null;
336
345
  }
337
346
  const token = await this.tokenManager.getToken();
@@ -339,8 +348,7 @@ export class D2LApiClient {
339
348
  return null;
340
349
  const cookieToken = { ...token, accessToken: `cookie:${token.cookieHeader}` };
341
350
  try {
342
- const response = await this.retrying(() => this.throttled(() => this.makeRawRequest(path, cookieToken)));
343
- return await response.text();
351
+ return await this.retrying(() => this.throttled(() => this.makeRawRequest(path, cookieToken)));
344
352
  }
345
353
  catch (error) {
346
354
  if (error instanceof ApiError && error.status === 401)
@@ -184,6 +184,17 @@ export async function setStoredTotpUri(baseUrl, username, uri, backend = nativeC
184
184
  throw new NativeCredentialStoreError("The authenticator enrollment could not be verified in the native credential store. Existing configuration was preserved.");
185
185
  }
186
186
  }
187
+ /** Remove an account's saved authenticator enrollment. Safe when none was saved. */
188
+ export async function deleteStoredTotpUri(baseUrl, username, backend = nativeCredentialBackend) {
189
+ const account = totpAccount(baseUrl, username);
190
+ // Some native stores raise on deleting an entry that does not exist.
191
+ if (await backend.getPassword(SERVICE, account) === null)
192
+ return;
193
+ await backend.deletePassword(SERVICE, account);
194
+ if (await backend.getPassword(SERVICE, account) !== null) {
195
+ throw new NativeCredentialStoreError("The saved authenticator enrollment could not be removed from the native credential store.");
196
+ }
197
+ }
187
198
  export async function getStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
188
199
  return backend.getPassword(SERVICE, passwordAccount(baseUrl, username));
189
200
  }
package/build/doctor.js CHANGED
@@ -23,8 +23,9 @@ import * as path from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import * as os from "node:os";
25
25
  import { configStoreExists, loadConfigStore, } from "./utils/config-store.js";
26
- import { accountSessionDirectory, expandTilde, readEnvSecret, parseSessionCookieEnv } from "./utils/config.js";
27
- import { getStoredPassword } from "./auth/credential-store.js";
26
+ import { accountSessionDirectory, envBoolean, expandTilde, readEnvSecret, parseSessionCookieEnv } from "./utils/config.js";
27
+ import { getStoredPassword, getStoredTotpUri } from "./auth/credential-store.js";
28
+ import { describeShortcuts } from "./utils/two-factor-shortcuts.js";
28
29
  import { discoverVersions } from "./api/version-discovery.js";
29
30
  import { TokenManager } from "./auth/token-manager.js";
30
31
  import { SessionStore } from "./auth/session-store.js";
@@ -77,6 +78,7 @@ function defaultDoctorDeps() {
77
78
  configStoreExists,
78
79
  loadConfigStore,
79
80
  getStoredPassword,
81
+ getStoredTotpUri: (baseUrl, username) => getStoredTotpUri(baseUrl, username),
80
82
  discoverVersions,
81
83
  getSessionToken: (baseUrl, sessionDir, envAccessToken, envSessionCookie) => readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie).getToken(),
82
84
  countCourses: async (baseUrl, sessionDir, envAccessToken, envSessionCookie) => {
@@ -194,6 +196,39 @@ async function checkCredential(deps, baseUrl, username, envAccessToken, envSessi
194
196
  return problem("credential", `Run setup again: ${SETUP_COMMAND}`);
195
197
  }
196
198
  }
199
+ /**
200
+ * The two opt-in ways to be asked for 2FA less often, as one informational
201
+ * line. Reports only whether each is on: the key itself is never read into
202
+ * the output, and a credential store that cannot be read says "not checked"
203
+ * rather than guessing.
204
+ */
205
+ async function describeTwoFactorShortcuts(deps, baseUrl, username) {
206
+ let store = null;
207
+ try {
208
+ if (deps.configStoreExists())
209
+ store = deps.loadConfigStore();
210
+ }
211
+ catch {
212
+ store = null;
213
+ }
214
+ const rememberMfa = envBoolean(deps.env.D2L_REMEMBER_MFA, "D2L_REMEMBER_MFA") ?? store?.rememberMfa === true;
215
+ let keySaved;
216
+ if (deps.env.D2L_TOTP_SECRET) {
217
+ keySaved = true;
218
+ }
219
+ else if (!username) {
220
+ keySaved = false;
221
+ }
222
+ else {
223
+ try {
224
+ keySaved = Boolean(await deps.getStoredTotpUri(baseUrl, username));
225
+ }
226
+ catch {
227
+ keySaved = "unknown";
228
+ }
229
+ }
230
+ return describeShortcuts({ rememberMfa, keySaved });
231
+ }
197
232
  async function checkNetwork(deps, baseUrl) {
198
233
  try {
199
234
  await deps.discoverVersions(baseUrl);
@@ -282,6 +317,7 @@ function skip(id, reason) {
282
317
  export async function runDoctorChecks(overrides = {}) {
283
318
  const deps = { ...defaultDoctorDeps(), ...overrides };
284
319
  const checks = [];
320
+ const notes = [];
285
321
  checks.push(checkNode(deps));
286
322
  const config = checkConfig(deps);
287
323
  checks.push(config.result);
@@ -293,6 +329,7 @@ export async function runDoctorChecks(overrides = {}) {
293
329
  }
294
330
  else {
295
331
  checks.push(await checkCredential(deps, config.baseUrl, config.username, config.envAccessToken, config.envSessionCookie));
332
+ notes.push(await describeTwoFactorShortcuts(deps, config.baseUrl, config.username));
296
333
  checks.push(await checkNetwork(deps, config.baseUrl));
297
334
  const session = await checkSession(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie);
298
335
  checks.push(session.result);
@@ -304,7 +341,7 @@ export async function runDoctorChecks(overrides = {}) {
304
341
  }
305
342
  }
306
343
  checks.push(await checkVersion(deps));
307
- return { checks, allOk: checks.every((c) => c.ok) };
344
+ return { checks, allOk: checks.every((c) => c.ok), notes };
308
345
  }
309
346
  // ── CLI entry point ──────────────────────────────────────────────────
310
347
  async function main() {
@@ -314,6 +351,8 @@ async function main() {
314
351
  console.log("");
315
352
  for (const check of result.checks)
316
353
  console.log(` ${check.line}`);
354
+ for (const note of result.notes)
355
+ console.log(` ${note}`);
317
356
  console.log("");
318
357
  console.log(result.allOk
319
358
  ? "Everything looks good — open your AI app and ask a question."
package/build/index.js CHANGED
@@ -11,6 +11,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
11
11
  import { enableStdoutGuard, log } from "./utils/logger.js";
12
12
  import { guardServerProcess } from "./utils/process-guard.js";
13
13
  import { loadConfig } from "./utils/config.js";
14
+ import { setActiveShortcuts } from "./utils/two-factor-shortcuts.js";
14
15
  import { TokenManager, AuthRunner } from "./auth/index.js";
15
16
  import { D2LApiClient } from "./api/index.js";
16
17
  import { startUpdateChecks } from "./utils/update-checker.js";
@@ -54,6 +55,9 @@ else {
54
55
  // Load configuration
55
56
  const config = await loadConfig();
56
57
  configureDevActivity(config.sessionDir);
58
+ // Lets the MFA approval message mention the setup options only to users
59
+ // who have neither turned on. Booleans only; the key is never passed.
60
+ setActiveShortcuts({ rememberMfa: config.rememberMfa === true, keySaved: Boolean(config.totpUri) });
57
61
  log("DEBUG", "Configuration loaded", { sessionDir: config.sessionDir });
58
62
  // Create MCP server instance
59
63
  const server = new McpServer({
package/build/setup.js CHANGED
@@ -14,7 +14,8 @@ 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 { normalizeTotpEnrollment } from "./auth/totp.js";
17
+ import { deleteStoredTotpUri, getStoredTotpUri } from "./auth/credential-store.js";
18
+ import { askShortcutQuestion } from "./utils/two-factor-shortcuts.js";
18
19
  import { writeFileAtomicSync } from "./utils/atomic-write.js";
19
20
  import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
20
21
  import { classifyRegistration, cliMcpClients, inspectCliMcpClient, isCliAvailable, registerCliMcpClient, serverCommand, } from "./utils/mcp-client-cli.js";
@@ -533,29 +534,6 @@ async function main() {
533
534
  }
534
535
  console.log("");
535
536
  }
536
- // ── Step 3b: Authenticator enrollment (hidden, optional) ─────────
537
- // Opt-in and off by default. Asked without naming a school: whether a code
538
- // can be typed depends on the identity provider and the challenge it shows,
539
- // which is only known at sign-in time.
540
- console.log(dim(" Optional. If your school asks for a 6-digit code from an authenticator app, this computer can generate it"));
541
- console.log(dim(" for you, so automatic sign-ins need nothing from your phone. Paste the SETUP KEY (or otpauth:// link) you"));
542
- console.log(dim(" got when enrolling the app — a code showing on screen right now will not work."));
543
- console.log(dim(" The tradeoff: the key is stored beside your password, so on THIS computer the two factors become one."));
544
- console.log(dim(" It still stops anyone who only has your password. Press Enter to skip, or to keep a key you saved before."));
545
- let totpUri;
546
- while (true) {
547
- const input = await askPassword(" Authenticator setup key or otpauth:// link (optional): ");
548
- if (!input)
549
- break;
550
- try {
551
- totpUri = normalizeTotpEnrollment(input, username);
552
- break;
553
- }
554
- catch {
555
- console.log(yellow(" That is not a setup key or otpauth:// link. Paste the enrollment key, or press Enter to skip."));
556
- }
557
- }
558
- console.log("");
559
537
  // Re-open readline for remaining prompts
560
538
  let rl2 = readline.createInterface({
561
539
  input: process.stdin,
@@ -600,18 +578,40 @@ async function main() {
600
578
  ? " Authentication will run without a browser window."
601
579
  : " A browser window will open when authentication is needed."));
602
580
  console.log("");
603
- // Opt-in only: a shared computer must never skip MFA unless asked to.
604
- const defaultRemember = savedRememberMfa === true ? "yes" : "no";
605
- let rememberAnswer = "";
606
- while (!/^(y(es)?|no?)$/i.test(rememberAnswer)) {
607
- rememberAnswer = await ask(rl2, ` Remember this device so later sign-ins can skip the second factor? Not for shared computers. (yes/no) [${defaultRemember}]: `) || defaultRemember;
608
- if (!/^(y(es)?|no?)$/i.test(rememberAnswer))
609
- console.log(yellow(" Please enter yes or no."));
581
+ // ── Step 4b: How often to approve (the one 2FA question) ─────────
582
+ // Opt-in only: the default asks every time, and a shared computer must never
583
+ // skip MFA unless asked to. Asked for every school and mode today; the
584
+ // sign-in flow itself decides at run time whether a code can be typed.
585
+ let keySaved = false;
586
+ try {
587
+ keySaved = Boolean(await getStoredTotpUri(baseUrl, username));
610
588
  }
611
- const rememberMfa = /^y/i.test(rememberAnswer);
612
- console.log(dim(rememberMfa
613
- ? " Later sign-ins will ask Microsoft not to repeat the second factor on this device."
614
- : " Every sign-in will ask for the second factor."));
589
+ catch {
590
+ // An unreadable credential store means there is nothing we can offer to remove.
591
+ }
592
+ const toneOf = { plain: (l) => l, dim, warn: yellow };
593
+ const shortcutIO = {
594
+ ask: (prompt) => ask(rl2, prompt),
595
+ // The hidden prompt manages its own readline, so the shared one steps aside.
596
+ async askSecret(prompt) {
597
+ rl2.close();
598
+ try {
599
+ return await askPassword(prompt);
600
+ }
601
+ finally {
602
+ rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
603
+ }
604
+ },
605
+ print: (line, tone = "plain") => console.log(toneOf[tone](line)),
606
+ };
607
+ const shortcuts = await askShortcutQuestion(shortcutIO, {
608
+ username,
609
+ rememberMfa: savedRememberMfa === true,
610
+ keySaved,
611
+ savedConfig: savedRememberMfa !== undefined || keySaved || savedHeadless !== undefined,
612
+ });
613
+ const rememberMfa = shortcuts?.rememberMfa;
614
+ const totpUri = shortcuts?.totpUri;
615
615
  console.log("");
616
616
  // ── Step 5: Save config ──────────────────────────────────────────
617
617
  const config = buildConfigToSave(readExistingConfig(), {
@@ -630,6 +630,15 @@ async function main() {
630
630
  : " Password saved in your operating system credential store."));
631
631
  if (totpUri)
632
632
  console.log(green(" Authenticator enrollment saved in your operating system credential store."));
633
+ if (shortcuts?.removeKey) {
634
+ try {
635
+ await deleteStoredTotpUri(baseUrl, username);
636
+ console.log(green(" Saved authenticator key removed from your operating system credential store."));
637
+ }
638
+ catch {
639
+ console.log(yellow(" Could not remove the saved authenticator key. Delete the brightspace-mcp-server entries from your computer's password store."));
640
+ }
641
+ }
633
642
  console.log(green(" Config saved to: " + getConfigStorePath()));
634
643
  console.log("");
635
644
  // ── Step 6: Authenticate now? ────────────────────────────────────
@@ -15,6 +15,8 @@ import { checkTopicAvailability } from "./topic-availability.js";
15
15
  import { validateContentId, validateFileType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
16
16
  import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
17
17
  import { DownloadError } from "../utils/download-errors.js";
18
+ import { AUTH_COMMAND } from "../utils/commands.js";
19
+ import { parseCoursePath, QUICKLINK_PATH } from "../utils/course-files.js";
18
20
  import { extractPdfText } from "../utils/pdf-extractor.js";
19
21
  import { officeDocumentText } from "../utils/zip-extract.js";
20
22
  import fs from "node:fs/promises";
@@ -247,13 +249,16 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
247
249
  export function registerDownloadFile(server, apiClient) {
248
250
  server.registerTool("download_file", {
249
251
  title: "Download File",
250
- description: "Download a file from course content, instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
252
+ description: "Download a file from course content, files linked by path inside a module description (coursePath, from get_course_content's quickLink URLs with type=coursefile), instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
251
253
  inputSchema: DownloadFileSchema,
252
254
  }, async (args) => {
253
255
  try {
254
256
  log("DEBUG", "download_file tool called", { args });
255
257
  // Parse and validate input
256
- const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
258
+ const { courseId, topicId, coursePath, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
259
+ if (coursePath !== undefined && (topicId !== undefined || folderId !== undefined || newsId !== undefined || fileId !== undefined || source !== undefined)) {
260
+ return errorResponse("coursePath cannot be combined with topicId, folderId, newsId, fileId, or source.");
261
+ }
257
262
  if (source !== undefined && (topicId !== undefined || newsId !== undefined || folderId === undefined || fileId === undefined)) {
258
263
  return errorResponse("source requires folderId and fileId and cannot be combined with topicId or newsId.");
259
264
  }
@@ -282,7 +287,13 @@ export function registerDownloadFile(server, apiClient) {
282
287
  }
283
288
  }
284
289
  // Determine download source
285
- if (topicId !== undefined) {
290
+ if (coursePath !== undefined) {
291
+ const filePath = parseCoursePath(coursePath, courseId);
292
+ if (typeof filePath !== "string")
293
+ return errorResponse(filePath.error);
294
+ return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename);
295
+ }
296
+ else if (topicId !== undefined) {
286
297
  // Content file download
287
298
  validateContentId(topicId);
288
299
  return await downloadContentFile(apiClient, courseId, topicId, downloadPath, customFilename);
@@ -308,6 +319,22 @@ export function registerDownloadFile(server, apiClient) {
308
319
  }
309
320
  });
310
321
  }
322
+ /**
323
+ * Download a file from the course file store. Instructors often link these
324
+ * from a module or page description instead of adding them as content
325
+ * topics, so they have no topicId. Brightspace serves them to the signed-in
326
+ * web session, not to the API token, so this goes through the session cookie.
327
+ */
328
+ async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename) {
329
+ log("INFO", `Downloading course file: courseId=${courseId}, path=${filePath}`);
330
+ const response = await apiClient.getPageRaw(`${QUICKLINK_PATH}?ou=${courseId}&type=coursefile&fileId=${encodeURIComponent(filePath)}`);
331
+ if (!response) {
332
+ return errorResponse(`Course files need a Brightspace web session cookie, and none is available. Sign in with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser.`);
333
+ }
334
+ const disposition = response.headers.get("Content-Disposition") ?? "";
335
+ const filename = parseContentDispositionFilename(disposition) ?? filePath.split("/").pop() ?? "download";
336
+ return finishDownload(response, filename, downloadPath, customFilename, "Course file");
337
+ }
311
338
  /** Download an instructor-provided handout, independently of student submissions. */
312
339
  async function downloadAssignmentAttachment(apiClient, courseId, folderId, fileId, downloadPath, customFilename) {
313
340
  const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"));
@@ -9,6 +9,7 @@ import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
9
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
10
  import { matchesModifiedSince } from "../utils/modified-since.js";
11
11
  import { log } from "../utils/logger.js";
12
+ import { extractCourseFilePaths } from "../utils/course-files.js";
12
13
  import { contentAvailability } from "./content-availability.js";
13
14
  // Topic type mapping
14
15
  const TOPIC_TYPE_MAP = {
@@ -108,6 +109,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
108
109
  else if (currentDepth >= MAX_CONTENT_DEPTH) {
109
110
  log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
110
111
  }
112
+ const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId) : [];
111
113
  // Only include module if it has matching children (or filter is 'all')
112
114
  if (typeFilter === 'all' || processedChildren.length > 0) {
113
115
  tree.push({
@@ -117,6 +119,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
117
119
  description: item.Description?.Html?.trim()
118
120
  ? convertHtmlToMarkdown(item.Description.Html).markdown
119
121
  : item.Description?.Text ?? null,
122
+ ...(courseFiles.length > 0 ? { courseFiles } : {}),
120
123
  ...(item.ModuleDueDate ? { dueDate: item.ModuleDueDate } : {}),
121
124
  ...(item.IsHidden ? { isHidden: item.IsHidden } : {}),
122
125
  ...(item.IsLocked ? { isLocked: item.IsLocked } : {}),
@@ -69,6 +69,8 @@ export const DownloadFileSchema = z.object({
69
69
  .describe("Course ID the file belongs to."),
70
70
  topicId: z.coerce.number().int().positive().optional()
71
71
  .describe("Content topic ID to download (for course content files)."),
72
+ coursePath: z.string().min(1).max(1024).optional()
73
+ .describe("A file in the course's file store, for files an instructor linked inside a module or page description instead of adding as a content topic (get_course_content lists them as courseFiles on the module). Pass one of those paths (e.g. 'Notes/Lecture 1.pdf') or the whole quickLink URL. Not combined with topicId, folderId, or newsId."),
72
74
  folderId: z.coerce.number().int().positive().optional()
73
75
  .describe("Dropbox folder ID. Downloads your submission by default; set source to assignmentAttachment for an instructor-provided handout."),
74
76
  fileId: z.coerce.number().int().positive().optional()
@@ -10,6 +10,7 @@ import { AuthProcessError } from "../auth/auth-runner.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
12
  import { getUpdateNotice } from "../utils/update-checker.js";
13
+ import { mfaShortcutTip } from "../utils/two-factor-shortcuts.js";
13
14
  import { DownloadError, isSafeDetail, } from "../utils/download-errors.js";
14
15
  /**
15
16
  * Append an available-update notice to an already-built content array and
@@ -141,7 +142,18 @@ function authFailureMessage(error) {
141
142
  return MFA_ALREADY_REPORTED;
142
143
  if (error.kind === "automaticPending" && error.duplicate)
143
144
  return AUTOMATIC_ALREADY_REPORTED;
144
- if (error.kind === "mfaPending" && error.numberMatch) {
145
+ if (error.kind === "mfaPending") {
146
+ return withShortcutTip(mfaPendingMessage(error));
147
+ }
148
+ return AUTH_FAILURE_GUIDANCE[error.kind];
149
+ }
150
+ /** Appends the one-line "ask for 2FA less often" tip, when it applies (see two-factor-shortcuts.ts). */
151
+ function withShortcutTip(message) {
152
+ const tip = mfaShortcutTip();
153
+ return tip ? `${message} ${tip}` : message;
154
+ }
155
+ function mfaPendingMessage(error) {
156
+ if (error.numberMatch) {
145
157
  return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
146
158
  "Show the user that number now, as text in your reply, before you make any other tool call: " +
147
159
  "they can't see tool results, and some apps show nothing until you write it. Then call this " +
@@ -189,7 +189,7 @@ function positiveSeconds(value, source) {
189
189
  * value counts as unset, and anything unrecognized is ignored with a warning
190
190
  * so config.json or the default applies, as positiveSeconds does.
191
191
  */
192
- function envBoolean(value, source) {
192
+ export function envBoolean(value, source) {
193
193
  const text = value?.trim().toLowerCase();
194
194
  if (!text)
195
195
  return undefined;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ export const QUICKLINK_PATH = "/d2l/common/dialogs/quickLink/quickLink.d2l";
7
+ /**
8
+ * Reduce a coursePath argument to a plain course-file path ("Notes/a.pdf").
9
+ * Accepts that path or the quickLink URL a module description carries, and
10
+ * refuses anything that could name a file outside the course file store, so
11
+ * the argument can only ever steer the request inside this course's files.
12
+ */
13
+ export function parseCoursePath(input, courseId) {
14
+ let filePath = input.trim();
15
+ if (filePath.includes("quickLink.d2l")) {
16
+ let link;
17
+ try {
18
+ link = new URL(filePath, "https://placeholder.invalid");
19
+ }
20
+ catch {
21
+ return { error: "coursePath is not a valid quickLink URL." };
22
+ }
23
+ if (link.pathname !== QUICKLINK_PATH || link.searchParams.get("type") !== "coursefile") {
24
+ return { error: "coursePath must be a course file path or a quickLink URL with type=coursefile." };
25
+ }
26
+ const ou = link.searchParams.get("ou");
27
+ if (ou !== null && ou !== String(courseId)) {
28
+ return { error: `This quickLink belongs to course ${ou}, not course ${courseId}.` };
29
+ }
30
+ filePath = link.searchParams.get("fileId") ?? "";
31
+ }
32
+ const segments = filePath.split("/");
33
+ if (filePath === "" ||
34
+ filePath.startsWith("/") ||
35
+ filePath.includes("\\") ||
36
+ filePath.includes("://") ||
37
+ segments.some(segment => segment === ".." || segment === "." || segment === "")) {
38
+ return { error: "coursePath must be a relative path inside the course files (e.g. 'Notes/Lecture 1.pdf'), without '..' segments." };
39
+ }
40
+ return filePath;
41
+ }
42
+ /**
43
+ * The course file paths a module description links through
44
+ * `quickLink.d2l?type=coursefile`, in page order, once each. Read from the raw
45
+ * HTML because the Markdown conversion escapes parentheses in file names.
46
+ * Anything parseCoursePath would refuse, and links to other courses, are left out.
47
+ */
48
+ export function extractCourseFilePaths(html, courseId) {
49
+ const found = [];
50
+ for (const [, href] of html.matchAll(/href\s*=\s*["']([^"']*quickLink\.d2l[^"']*)["']/gi)) {
51
+ const parsed = parseCoursePath(href.replace(/&/g, "&"), courseId);
52
+ if (typeof parsed === "string" && !found.includes(parsed))
53
+ found.push(parsed);
54
+ }
55
+ return found;
56
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Brightspace MCP Server. Copyright (c) 2026 Rohan Muppa. MIT licensed.
3
+ *
4
+ * The two opt-in ways to be asked for 2FA less often, described in one place.
5
+ * `setup`, the MFA hint in tool responses, and `doctor` all take their wording
6
+ * from here so they cannot drift apart, and all point at the one docs section
7
+ * that explains both options side by side.
8
+ *
9
+ * A) Remember this device: `rememberMfa` / D2L_REMEMBER_MFA.
10
+ * B) Saved authenticator key: the OS keychain entry (or D2L_TOTP_SECRET).
11
+ *
12
+ * Both are off by default. Nothing here changes what either one does, and
13
+ * nothing here ever sees, prints, or derives anything from the key itself:
14
+ * callers pass only whether one exists.
15
+ */
16
+ import { normalizeTotpEnrollment } from "../auth/totp.js";
17
+ import { SETUP_COMMAND } from "./commands.js";
18
+ /** The single docs section that explains both options. */
19
+ export const SHORTCUTS_DOCS_URL = "https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/docs/sign-in.md#fewer-2fa-prompts";
20
+ /** Shown in `setup` when option 3 is chosen and no key is saved yet. */
21
+ export const KEY_HOWTO_LINES = [
22
+ " How to get the key: open https://mysignins.microsoft.com > Security info > Add sign-in method > Authenticator app,",
23
+ " choose \"I want to use a different authenticator app\", then \"Can't scan image?\" and copy the secret key.",
24
+ ];
25
+ /** The key prompt when no key is saved. Enter means none. */
26
+ export const KEY_PROMPT = " Authenticator setup key or otpauth:// link (Enter = none): ";
27
+ /** The key prompt when a key is already saved. Enter keeps it. */
28
+ export const KEY_PROMPT_KEEP = " Paste a new setup key or otpauth:// link to replace the saved one (Enter to keep it): ";
29
+ const OPTION_TEXT = {
30
+ every: "Every time Microsoft asks",
31
+ remember: "Less often: Microsoft remembers this computer for a while (your school decides how long)",
32
+ key: "Almost never: this computer answers Microsoft's code requests itself (needs a key from Microsoft, about 2 minutes)",
33
+ };
34
+ const CURRENT_NAME = {
35
+ every: "every time",
36
+ remember: "remember this computer",
37
+ key: "this computer answers code requests",
38
+ };
39
+ function currentChoice(input) {
40
+ return input.keySaved ? "key" : input.rememberMfa ? "remember" : "every";
41
+ }
42
+ async function askYesNo(io, prompt, fallback) {
43
+ while (true) {
44
+ const answer = (await io.ask(prompt)) || fallback;
45
+ if (/^(y(es)?|no?)$/i.test(answer))
46
+ return /^y/i.test(answer);
47
+ io.print(" Please enter yes or no.", "warn");
48
+ }
49
+ }
50
+ /**
51
+ * The one question setup asks about being asked for 2FA less often. Replaces
52
+ * the old pair (an optional key prompt, then a yes/no remember question).
53
+ *
54
+ * Returns null when no option applies, so nothing is asked. Everything the
55
+ * user types or sees about the key goes through `io`; the key itself is only
56
+ * handed to the normalizer and returned, never printed.
57
+ */
58
+ export async function askShortcutQuestion(io, input) {
59
+ const applicable = input.applicable ?? { remember: true, key: true };
60
+ const choices = ["every"];
61
+ if (applicable.remember)
62
+ choices.push("remember");
63
+ if (applicable.key)
64
+ choices.push("key");
65
+ if (choices.length === 1)
66
+ return null;
67
+ const numberOf = (c) => choices.indexOf(c) + 1;
68
+ const numbers = choices.map((_, i) => String(i + 1));
69
+ const list = numbers.length === 2 ? "1 or 2" : "1, 2 or 3";
70
+ const current = currentChoice(input);
71
+ const defaultChoice = choices.includes(current) ? current : "every";
72
+ const defaultNumber = String(numberOf(defaultChoice));
73
+ io.print(" How often do you want to approve sign-ins on your phone?");
74
+ for (const c of choices) {
75
+ const suffix = c === "every" ? (defaultChoice === "every" ? " (default, safest)" : " (safest)") : "";
76
+ io.print(` ${numberOf(c)}. ${OPTION_TEXT[c]}${suffix}`);
77
+ }
78
+ if (choices.includes("remember")) {
79
+ io.print(` Option ${numberOf("remember")} ticks Microsoft's "Don't ask again" box, so you still approve now and then.`, "dim");
80
+ }
81
+ if (choices.includes("key")) {
82
+ io.print(` Option ${numberOf("key")} stores a key next to your password, so on this computer the two factors become one. It still stops anyone who only has your password.`, "dim");
83
+ io.print(" It helps only when Microsoft asks for a 6-digit code. If your school only sends a number to approve, sign-in falls back to your phone.", "dim");
84
+ }
85
+ const skipping = choices.slice(1).map(numberOf).join(" and ");
86
+ io.print(` ${choices.length === 2 ? "Option" : "Options"} ${skipping} ${choices.length === 2 ? "is" : "are"} not for shared computers. Details: ${SHORTCUTS_DOCS_URL}`);
87
+ if (input.savedConfig) {
88
+ io.print(` Currently: ${numberOf(defaultChoice)} \u2014 ${CURRENT_NAME[defaultChoice]}`, "dim");
89
+ }
90
+ let reply = "";
91
+ let blank = false;
92
+ while (!numbers.includes(reply)) {
93
+ const raw = await io.ask(` Choose ${list} [${defaultNumber}]: `);
94
+ blank = raw === "";
95
+ reply = raw || defaultNumber;
96
+ if (!numbers.includes(reply))
97
+ io.print(` Please enter ${list}.`, "warn");
98
+ }
99
+ let choice = choices[Number(reply) - 1];
100
+ let removeKey = false;
101
+ let totpUri;
102
+ // Someone who saved a key without remember-this-device keeps exactly that on
103
+ // Enter: remembering a device is opt-in and is never switched on by default.
104
+ let rememberMfa = (choice === "remember" || (choice === "key" && applicable.remember)) &&
105
+ !(blank && input.keySaved && !input.rememberMfa);
106
+ if (choice === "key") {
107
+ if (input.keySaved) {
108
+ // Enter on "Currently: 3" is a plain keep; only an explicit 3 offers a replacement.
109
+ if (!blank)
110
+ totpUri = await askForKey(io, input.username, KEY_PROMPT_KEEP);
111
+ if (totpUri === undefined)
112
+ io.print(" Keeping the saved key.", "dim");
113
+ }
114
+ else {
115
+ for (const line of KEY_HOWTO_LINES)
116
+ io.print(line, "dim");
117
+ totpUri = await askForKey(io, input.username, KEY_PROMPT);
118
+ if (totpUri === undefined) {
119
+ choice = applicable.remember ? "remember" : "every";
120
+ rememberMfa = choice === "remember";
121
+ io.print(` No key entered. Without a key, option ${numberOf("key")} is the same as option ${numberOf(choice)}, so it is saved as option ${numberOf(choice)}.`, "warn");
122
+ }
123
+ }
124
+ }
125
+ else if (input.keySaved) {
126
+ io.print(" A key is saved on this computer. Keeping it means Microsoft's code requests are still answered automatically.", "dim");
127
+ removeKey = await askYesNo(io, " Remove the saved authenticator key from this computer? (yes/no) [yes]: ", "yes");
128
+ }
129
+ io.print(choice === "every"
130
+ ? " Every sign-in will ask for the second factor."
131
+ : choice === "remember"
132
+ ? " Later sign-ins will ask Microsoft not to repeat the second factor on this device."
133
+ : " This computer will answer Microsoft's code requests itself.", "dim");
134
+ return { rememberMfa, removeKey, ...(totpUri ? { totpUri } : {}) };
135
+ }
136
+ /** Prompt (hidden) until a valid key, or Enter. Undefined means none was given. */
137
+ async function askForKey(io, username, prompt) {
138
+ while (true) {
139
+ const input = await io.askSecret(prompt);
140
+ if (!input)
141
+ return undefined;
142
+ try {
143
+ return normalizeTotpEnrollment(input, username);
144
+ }
145
+ catch {
146
+ io.print(" That is not a setup key or otpauth:// link. Paste the enrollment key, or press Enter for none.", "warn");
147
+ }
148
+ }
149
+ }
150
+ let current;
151
+ /**
152
+ * Record, once at startup, which shortcuts are on. Until this is called the
153
+ * state is unknown and no hint is ever shown, so nothing else is affected.
154
+ */
155
+ export function setActiveShortcuts(state) {
156
+ current = { rememberMfa: state.rememberMfa, keySaved: state.keySaved };
157
+ }
158
+ /** Test seam: forget the recorded state. */
159
+ export function resetActiveShortcuts() {
160
+ current = undefined;
161
+ }
162
+ /**
163
+ * One short line appended to the MFA approval message, only when the user has
164
+ * neither shortcut on. Empty string otherwise (including when unknown).
165
+ */
166
+ export function mfaShortcutTip() {
167
+ if (!current || current.rememberMfa || current.keySaved)
168
+ return "";
169
+ return `Tip for the user: to be asked less often, rerun \`${SETUP_COMMAND}\` - see ${SHORTCUTS_DOCS_URL}`;
170
+ }
171
+ /** The one `doctor` line. Mentions the docs link only when both are off. */
172
+ export function describeShortcuts(state) {
173
+ const device = `remember this device ${state.rememberMfa ? "on" : "off"}`;
174
+ const key = state.keySaved === "unknown"
175
+ ? "authenticator key not checked"
176
+ : `authenticator key ${state.keySaved ? "saved" : "not saved"}`;
177
+ const bothOff = !state.rememberMfa && state.keySaved === false;
178
+ return `2FA shortcuts: ${device}, ${key}.${bothOff ? ` Want fewer prompts? See ${SHORTCUTS_DOCS_URL}` : ""}`;
179
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.13.22",
3
+ "version": "3.15.0",
4
4
  "mcpName": "io.github.RohanMuppa/brightspace",
5
5
  "description": "MCP server for Brightspace (D2L). Check grades, due dates, assignments, announcements, syllabus, rosters and more via Claude, ChatGPT, Cursor, Windsurf, or any MCP client.",
6
6
  "type": "module",