brightspace-mcp-server 3.13.21 → 3.14.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,14 +96,15 @@ 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)) |
105
105
  | `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | Limit which courses the AI sees, by course id |
106
106
  | `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
107
+ | `D2L_CURRENT_ONLY=true` | Only show courses in their current enrollment window, like Brightspace's "Current Courses" widget |
107
108
  | `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
108
109
  | `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging session problems ([how](docs/dev-mode.md)) |
109
110
 
@@ -117,13 +118,7 @@ npx -y brightspace-mcp-server@latest auth
117
118
 
118
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.
119
120
 
120
- **If your school asks for a 6-digit code** rather than a phone approval, `setup` can save the
121
- authenticator **setup key** (not a code) you got when you enrolled the app. From then on automatic
122
- sign-ins generate the code on this computer and finish without you, which is what unattended and
123
- scheduled use needs. It is optional and off unless you paste a key. The tradeoff is real: the key
124
- sits beside your password, so on **this** computer your two factors become one. It still stops
125
- anyone who only has your password, and it changes nothing for your other devices. Full enrollment
126
- 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).
127
122
 
128
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.
129
124
 
@@ -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? ────────────────────────────────────
@@ -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,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.21",
3
+ "version": "3.14.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",