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 +3 -9
- package/build/api/client.js +11 -3
- package/build/auth/credential-store.js +11 -0
- package/build/doctor.js +42 -3
- package/build/index.js +4 -0
- package/build/setup.js +44 -35
- package/build/tools/download-file.js +30 -3
- package/build/tools/get-course-content.js +3 -0
- package/build/tools/schemas.js +2 -0
- package/build/tools/tool-helpers.js +13 -1
- package/build/utils/config.js +1 -1
- package/build/utils/course-files.js +56 -0
- package/build/utils/two-factor-shortcuts.js +179 -0
- package/package.json +1 -1
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
|
|
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
|
-
**
|
|
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
|
|
package/build/api/client.js
CHANGED
|
@@ -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", "
|
|
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
|
-
|
|
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 {
|
|
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
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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 (
|
|
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 } : {}),
|
package/build/tools/schemas.js
CHANGED
|
@@ -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"
|
|
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 " +
|
package/build/utils/config.js
CHANGED
|
@@ -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.
|
|
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",
|