brightspace-mcp-server 3.11.15 → 3.12.1
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 +1 -0
- package/build/api/paginate.js +6 -0
- package/build/auth/browser-auth.js +13 -2
- package/build/auth/credential-store.js +11 -0
- package/build/auth/purdue-sso.js +50 -13
- package/build/auth/sso-flow.js +1 -0
- package/build/auth/suny-sso.js +1 -0
- package/build/setup.js +40 -11
- package/build/tools/get-assignments.js +6 -7
- package/build/tools/get-my-grades.js +5 -3
- package/build/tools/get-upcoming-due-dates.js +6 -2
- package/build/utils/config.js +8 -1
- package/build/utils/secure-config.js +4 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -96,6 +96,7 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
|
|
|
96
96
|
| Setting | What it does |
|
|
97
97
|
|---------|--------------|
|
|
98
98
|
| `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_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)) |
|
|
99
100
|
| `D2L_HEADLESS=false` | Show the browser window during sign-in, for MFA methods that need a click |
|
|
100
101
|
| `D2L_DUO_PASSCODE=1` | On Duo, type a passcode instead of waiting for a push |
|
|
101
102
|
| `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)) |
|
package/build/api/paginate.js
CHANGED
|
@@ -48,6 +48,7 @@ export async function fetchAllItems(apiClient, firstPath, options) {
|
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
50
50
|
* Every object across every page of a { Objects, Next } endpoint, in order.
|
|
51
|
+
* A bare array response is taken as the complete list.
|
|
51
52
|
*
|
|
52
53
|
* Next arrives either as a full next-page URL or as a bare bookmark, so the
|
|
53
54
|
* absolute form is reduced to its path and query and anything else is treated
|
|
@@ -65,6 +66,11 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
|
|
|
65
66
|
}
|
|
66
67
|
const response = await apiClient.get(path, options);
|
|
67
68
|
pages += 1;
|
|
69
|
+
// Some tenants answer a paged route with a bare array: that is the whole list.
|
|
70
|
+
if (Array.isArray(response)) {
|
|
71
|
+
objects.push(...response);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
68
74
|
objects.push(...(response?.Objects ?? []));
|
|
69
75
|
const next = response?.Next;
|
|
70
76
|
if (!next)
|
|
@@ -38,6 +38,9 @@ const SILENT_SSO = {
|
|
|
38
38
|
// text fallback covers a tenant that renames the id.
|
|
39
39
|
passwordSwitch: "#idA_PWD_SwitchToPassword",
|
|
40
40
|
passwordSwitchText: "Use your password instead",
|
|
41
|
+
// The approval view's own markers (purdue-sso.ts PASSWORDLESS_APPROVAL_SELECTORS),
|
|
42
|
+
// for a tenant that shows it without the password link.
|
|
43
|
+
passwordlessApproval: ["#idRemoteNGC_DisplaySign", "#idDiv_RemoteNGC_PollingDescription"],
|
|
41
44
|
};
|
|
42
45
|
export class BrowserAuthTransportError extends Error {
|
|
43
46
|
code = "AUTH_TRANSPORT";
|
|
@@ -535,9 +538,17 @@ export class BrowserAuth {
|
|
|
535
538
|
const byText = page.getByText(SILENT_SSO.passwordSwitchText).first();
|
|
536
539
|
return await byText.isVisible().catch(() => false) ? byText : null;
|
|
537
540
|
}
|
|
538
|
-
/**
|
|
541
|
+
/**
|
|
542
|
+
* One definition of the MFA pages the shared authentication loop supports.
|
|
543
|
+
* With passwordless sign-in on, Microsoft's approval view is the challenge
|
|
544
|
+
* itself, so the silent poll stays on it instead of taking the password link.
|
|
545
|
+
*/
|
|
539
546
|
async hasMfaChallenge(page) {
|
|
540
|
-
|
|
547
|
+
if (isDuoPrompt(page) || await this.isAnyOnScreen(page, SILENT_SSO.mfaChallenges))
|
|
548
|
+
return true;
|
|
549
|
+
if (!this.config.passwordless)
|
|
550
|
+
return false;
|
|
551
|
+
return await this.isAnyOnScreen(page, SILENT_SSO.passwordlessApproval) || await this.passwordSignInOption(page) !== null;
|
|
541
552
|
}
|
|
542
553
|
/**
|
|
543
554
|
* A POSITIVE session check, never "the URL doesn't look like a login page".
|
|
@@ -172,6 +172,17 @@ export async function setStoredPassword(baseUrl, username, password, backend = n
|
|
|
172
172
|
throw new NativeCredentialStoreError("The saved password could not be verified in the native credential store. Existing configuration was preserved.");
|
|
173
173
|
}
|
|
174
174
|
}
|
|
175
|
+
/** Remove an account's saved password, so passwordless sign-in leaves none behind. */
|
|
176
|
+
export async function deleteStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
|
|
177
|
+
const account = passwordAccount(baseUrl, username);
|
|
178
|
+
// Some native stores raise on deleting an entry that does not exist.
|
|
179
|
+
if (await backend.getPassword(SERVICE, account) === null)
|
|
180
|
+
return;
|
|
181
|
+
await backend.deletePassword(SERVICE, account);
|
|
182
|
+
if (await backend.getPassword(SERVICE, account) !== null) {
|
|
183
|
+
throw new NativeCredentialStoreError("The saved password could not be removed from the native credential store. Existing configuration was preserved.");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
175
186
|
function decodeKey(value) {
|
|
176
187
|
if (!/^[a-f0-9]{64}$/i.test(value)) {
|
|
177
188
|
throw new NativeCredentialStoreError("The saved session encryption key is invalid. Restore its native credential store entry before retrying.");
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -22,6 +22,20 @@ const FIELD_POLL_MS = 250;
|
|
|
22
22
|
* logged. Plain DOM text, no OCR.
|
|
23
23
|
*/
|
|
24
24
|
const NUMBER_MATCH_SELECTOR = "#idRichContext_DisplaySign";
|
|
25
|
+
/**
|
|
26
|
+
* Microsoft's passwordless phone sign-in view ("Approve sign in request"),
|
|
27
|
+
* which the tenant shows instead of a password page once the account has
|
|
28
|
+
* registered passwordless sign-in in Authenticator. The "Use your password
|
|
29
|
+
* instead" link is the marker browser-auth.ts already relies on; the
|
|
30
|
+
* RemoteNGC ids are EXPECTED, not verified against a live tenant from here.
|
|
31
|
+
* The number shown there is typed into Authenticator like number match.
|
|
32
|
+
*/
|
|
33
|
+
const PASSWORDLESS_NUMBER_SELECTOR = "#idRemoteNGC_DisplaySign";
|
|
34
|
+
const PASSWORDLESS_APPROVAL_SELECTORS = [
|
|
35
|
+
PASSWORDLESS_NUMBER_SELECTOR,
|
|
36
|
+
"#idDiv_RemoteNGC_PollingDescription",
|
|
37
|
+
"#idA_PWD_SwitchToPassword",
|
|
38
|
+
];
|
|
25
39
|
const MFA_CODE_SELECTORS = ["#idTxtBx_SAOTCC_OTC", 'input[name="otc"]'];
|
|
26
40
|
const MFA_CODE_SUBMIT_SELECTORS = ["#idSubmit_SAOTCC_Continue", "#idSIButton9"];
|
|
27
41
|
// Entra rejects a wrong code in place: the field stays and this message appears.
|
|
@@ -118,7 +132,7 @@ export class PurdueSSOFlow {
|
|
|
118
132
|
* Returns true if credentials are available for automated SSO login.
|
|
119
133
|
*/
|
|
120
134
|
hasCredentials() {
|
|
121
|
-
return Boolean(this.config.username && this.config.password);
|
|
135
|
+
return Boolean(this.config.username && (this.config.password || this.config.passwordless));
|
|
122
136
|
}
|
|
123
137
|
/** What Entra's "Don't ask again" checkbox did, once its MFA page has appeared. */
|
|
124
138
|
rememberMfaResult() {
|
|
@@ -195,7 +209,7 @@ export class PurdueSSOFlow {
|
|
|
195
209
|
async enterCredentials(page) {
|
|
196
210
|
if (!this.config.username)
|
|
197
211
|
throw new BrowserAuthError("Username is required for SSO login", "credentials");
|
|
198
|
-
if (!this.config.password)
|
|
212
|
+
if (!this.config.password && !this.config.passwordless)
|
|
199
213
|
throw new BrowserAuthError("Password is required for SSO login", "credentials");
|
|
200
214
|
log("INFO", "Entering credentials");
|
|
201
215
|
// A submitted account hint only counts once Microsoft has actually left the
|
|
@@ -225,7 +239,7 @@ export class PurdueSSOFlow {
|
|
|
225
239
|
// browser-auth.ts), so this only takes the single-page branch once the
|
|
226
240
|
// field survives two consecutive checks.
|
|
227
241
|
if (await this.hasCoVisiblePassword(page)) {
|
|
228
|
-
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.
|
|
242
|
+
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.savedPassword())) {
|
|
229
243
|
throw new UnsupportedAuthenticationError("The identity provider's password field did not appear. Automatic sign-in cannot continue.");
|
|
230
244
|
}
|
|
231
245
|
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
|
|
@@ -236,14 +250,29 @@ export class PurdueSSOFlow {
|
|
|
236
250
|
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
|
|
237
251
|
throw new UnsupportedAuthenticationError("The identity provider's username submit button did not appear. Automatic sign-in cannot continue.");
|
|
238
252
|
}
|
|
253
|
+
// Passwordless: Microsoft answers the username with its approval view,
|
|
254
|
+
// which handleMFA waits on like any other phone approval.
|
|
255
|
+
if (!this.config.password)
|
|
256
|
+
return;
|
|
239
257
|
}
|
|
240
|
-
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.
|
|
258
|
+
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.savedPassword())) {
|
|
241
259
|
throw new UnsupportedAuthenticationError("The identity provider's password field did not appear. Automatic sign-in cannot continue.");
|
|
242
260
|
}
|
|
243
261
|
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
|
|
244
262
|
throw new UnsupportedAuthenticationError("The identity provider's password submit button did not appear. Automatic sign-in cannot continue.");
|
|
245
263
|
}
|
|
246
264
|
}
|
|
265
|
+
/**
|
|
266
|
+
* The saved password, or a clear failure when passwordless sign-in left
|
|
267
|
+
* none to type: a tenant can still ask for one (an unregistered method, a
|
|
268
|
+
* policy change), and waiting on a field nothing can fill would only time out.
|
|
269
|
+
*/
|
|
270
|
+
savedPassword() {
|
|
271
|
+
if (this.config.password)
|
|
272
|
+
return this.config.password;
|
|
273
|
+
throw new UnsupportedAuthenticationError("Microsoft asked for a password, but passwordless sign-in is on (D2L_PASSWORDLESS, or passwordless in config.json), so none is saved. " +
|
|
274
|
+
"Register passwordless phone sign-in in Microsoft Authenticator, or turn passwordless off and save a password with setup.");
|
|
275
|
+
}
|
|
247
276
|
/** Ported from Brightspace Bar's proven four-step Entra choreography. */
|
|
248
277
|
async actWhenReady(page, selectors, act) {
|
|
249
278
|
const deadline = Date.now() + FIELD_TIMEOUT_MS;
|
|
@@ -307,6 +336,7 @@ export class PurdueSSOFlow {
|
|
|
307
336
|
"#idDiv_SAOTCC_Title",
|
|
308
337
|
"#KmsiCheckboxField",
|
|
309
338
|
...MFA_CODE_SELECTORS,
|
|
339
|
+
...PASSWORDLESS_APPROVAL_SELECTORS,
|
|
310
340
|
]);
|
|
311
341
|
}
|
|
312
342
|
/** Brightspace Bar's bounded number/auth/KMSI polling loop. */
|
|
@@ -350,7 +380,11 @@ export class PurdueSSOFlow {
|
|
|
350
380
|
sawNumber = true;
|
|
351
381
|
const challengeVisible = number !== null ||
|
|
352
382
|
await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
|
|
353
|
-
await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false)
|
|
383
|
+
await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false) ||
|
|
384
|
+
await this.anyVisible(page, PASSWORDLESS_APPROVAL_SELECTORS);
|
|
385
|
+
// With no password saved, a password page can only end in a timeout.
|
|
386
|
+
if (this.config.passwordless && !this.config.password && !challengeVisible && await this.isPasswordOnlyPage(page))
|
|
387
|
+
this.savedPassword();
|
|
354
388
|
// The checkbox can render after the heading or number. Recheck during
|
|
355
389
|
// the existing poll without delaying the first challenge announcement.
|
|
356
390
|
if (challengeVisible)
|
|
@@ -649,13 +683,16 @@ export class PurdueSSOFlow {
|
|
|
649
683
|
}
|
|
650
684
|
/** The digits on screen, or null when Entra is not showing any. */
|
|
651
685
|
async readNumberMatch(page) {
|
|
652
|
-
const
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
686
|
+
for (const selector of [NUMBER_MATCH_SELECTOR, PASSWORDLESS_NUMBER_SELECTOR]) {
|
|
687
|
+
const sign = page.locator(selector).first();
|
|
688
|
+
// isVisible answers immediately rather than waiting out a timeout, so the
|
|
689
|
+
// runs that never show a number keep the poll on its two-second rhythm.
|
|
690
|
+
if (!(await sign.isVisible().catch(() => false)))
|
|
691
|
+
continue;
|
|
692
|
+
const text = await sign.textContent().catch(() => null);
|
|
693
|
+
const number = text?.trim();
|
|
694
|
+
return number && /^\d{1,3}$/.test(number) ? number : null;
|
|
695
|
+
}
|
|
696
|
+
return null;
|
|
660
697
|
}
|
|
661
698
|
}
|
package/build/auth/sso-flow.js
CHANGED
package/build/auth/suny-sso.js
CHANGED
|
@@ -56,6 +56,7 @@ export class SunySSOFlow {
|
|
|
56
56
|
baseUrl: `https://${SUNY_BRIGHTSPACE_HOST}`,
|
|
57
57
|
headless: config.headless,
|
|
58
58
|
rememberMfa: config.rememberMfa,
|
|
59
|
+
passwordless: config.passwordless,
|
|
59
60
|
requestMfaCode: config.requestMfaCode,
|
|
60
61
|
onMfaChallenge: config.onMfaChallenge,
|
|
61
62
|
});
|
package/build/setup.js
CHANGED
|
@@ -359,11 +359,13 @@ export function buildConfigToSave(existing, answers) {
|
|
|
359
359
|
username: answers.username,
|
|
360
360
|
// Always the freshly typed one: a carried v1 plaintext password would
|
|
361
361
|
// otherwise be the value written to the native store.
|
|
362
|
-
password: answers.password,
|
|
362
|
+
password: answers.passwordless ? undefined : answers.password,
|
|
363
363
|
headless: answers.headless,
|
|
364
364
|
};
|
|
365
365
|
if (answers.rememberMfa !== undefined)
|
|
366
366
|
config.rememberMfa = answers.rememberMfa;
|
|
367
|
+
if (answers.passwordless !== undefined)
|
|
368
|
+
config.passwordless = answers.passwordless;
|
|
367
369
|
if (answers.campus)
|
|
368
370
|
config.campus = answers.campus;
|
|
369
371
|
return config;
|
|
@@ -488,20 +490,44 @@ async function main() {
|
|
|
488
490
|
}
|
|
489
491
|
console.log("");
|
|
490
492
|
// ── Step 3: Password (hidden) ────────────────────────────────────
|
|
493
|
+
// Opt-in only, asked the way remember-MFA is: passwordless trades a saved
|
|
494
|
+
// password for a phone approval on every sign-in, so it is never the default.
|
|
495
|
+
let savedPasswordless;
|
|
496
|
+
try {
|
|
497
|
+
savedPasswordless = configStoreExists() ? loadConfigStore().passwordless : undefined;
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// An invalid old config is replaced by the setup values below.
|
|
501
|
+
}
|
|
502
|
+
console.log(dim(" Microsoft Entra schools can sign you in with only a phone approval, so no password is saved on this computer."));
|
|
503
|
+
console.log(dim(" The cost: every sign-in, including automatic ones after a session expires, waits until you approve on your phone."));
|
|
504
|
+
console.log(dim(" Fine for using an assistant at your desk; not for unattended or scheduled use. It needs passwordless phone sign-in"));
|
|
505
|
+
console.log(dim(" turned on in Microsoft Authenticator first, or Microsoft will still ask for a password and sign-in will stop."));
|
|
506
|
+
const defaultPasswordless = savedPasswordless === true ? "yes" : "no";
|
|
507
|
+
let passwordlessAnswer = "";
|
|
508
|
+
while (!/^(y(es)?|no?)$/i.test(passwordlessAnswer)) {
|
|
509
|
+
passwordlessAnswer = await ask(rl, ` Sign in without a saved password (Microsoft Entra only)? (yes/no) [${defaultPasswordless}]: `) || defaultPasswordless;
|
|
510
|
+
if (!/^(y(es)?|no?)$/i.test(passwordlessAnswer))
|
|
511
|
+
console.log(yellow(" Please enter yes or no."));
|
|
512
|
+
}
|
|
513
|
+
const passwordless = /^y/i.test(passwordlessAnswer);
|
|
514
|
+
console.log("");
|
|
491
515
|
// Close the rl temporarily since askPassword manages its own
|
|
492
516
|
rl.close();
|
|
493
|
-
console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
|
|
494
|
-
const passwordPrompt = preset
|
|
495
|
-
? `What is your ${preset.name} password? `
|
|
496
|
-
: "What is your Brightspace password? ";
|
|
497
517
|
let password = "";
|
|
498
|
-
|
|
499
|
-
password
|
|
500
|
-
|
|
501
|
-
|
|
518
|
+
if (!passwordless) {
|
|
519
|
+
console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
|
|
520
|
+
const passwordPrompt = preset
|
|
521
|
+
? `What is your ${preset.name} password? `
|
|
522
|
+
: "What is your Brightspace password? ";
|
|
523
|
+
while (!password) {
|
|
524
|
+
password = await askPassword(passwordPrompt);
|
|
525
|
+
if (!password) {
|
|
526
|
+
console.log(yellow(" Password is required."));
|
|
527
|
+
}
|
|
502
528
|
}
|
|
529
|
+
console.log("");
|
|
503
530
|
}
|
|
504
|
-
console.log("");
|
|
505
531
|
// Re-open readline for remaining prompts
|
|
506
532
|
let rl2 = readline.createInterface({
|
|
507
533
|
input: process.stdin,
|
|
@@ -566,10 +592,13 @@ async function main() {
|
|
|
566
592
|
password,
|
|
567
593
|
headless,
|
|
568
594
|
rememberMfa,
|
|
595
|
+
passwordless,
|
|
569
596
|
campus: campus || undefined,
|
|
570
597
|
});
|
|
571
598
|
await saveSecureConfig(config);
|
|
572
|
-
console.log(green(
|
|
599
|
+
console.log(green(passwordless
|
|
600
|
+
? " No password saved: each sign-in will wait for your phone approval."
|
|
601
|
+
: " Password saved in your operating system credential store."));
|
|
573
602
|
console.log(green(" Config saved to: " + getConfigStorePath()));
|
|
574
603
|
console.log("");
|
|
575
604
|
// ── Step 6: Authenticate now? ────────────────────────────────────
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
-
import { fetchAllItems } from "../api/paginate.js";
|
|
7
|
+
import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetAssignmentsSchema } from "./schemas.js";
|
|
9
9
|
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
10
10
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
@@ -110,7 +110,10 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
110
110
|
// cannot be made until they have answered.
|
|
111
111
|
const [dropboxResult, quizResult, gradebookResult, contentResult] = await Promise.allSettled([
|
|
112
112
|
apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
|
|
113
|
-
|
|
113
|
+
// Quizzes are paged; a course with many of them spills past page one.
|
|
114
|
+
fetchAllObjects(apiClient, apiClient.le(courseId, "/quizzes/"), {
|
|
115
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
116
|
+
}),
|
|
114
117
|
apiClient.get(apiClient.le(courseId, "/grades/"), {
|
|
115
118
|
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
116
119
|
}),
|
|
@@ -263,11 +266,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
263
266
|
// forbidden or unavailable, so a failed list starts from an empty set.
|
|
264
267
|
let quizzes = [];
|
|
265
268
|
if (quizResult.status === "fulfilled") {
|
|
266
|
-
|
|
267
|
-
// D2L quizzes API returns paged result { Objects: [...] } or a plain array
|
|
268
|
-
quizzes = Array.isArray(quizResponse)
|
|
269
|
-
? quizResponse
|
|
270
|
-
: quizResponse?.Objects ?? [];
|
|
269
|
+
quizzes = quizResult.value;
|
|
271
270
|
}
|
|
272
271
|
else {
|
|
273
272
|
log("DEBUG", `Failed to fetch quizzes for course ${courseId}`, quizResult.reason);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
-
import { fetchAllItems } from "../api/paginate.js";
|
|
7
|
+
import { fetchAllItems, fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetMyGradesSchema, } from "./schemas.js";
|
|
9
9
|
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
@@ -27,8 +27,10 @@ const QUIZ_FEEDBACK_NOTE = "The gradebook has no comment for this item, but it i
|
|
|
27
27
|
*/
|
|
28
28
|
async function fetchQuizzes(apiClient, courseId) {
|
|
29
29
|
try {
|
|
30
|
-
|
|
31
|
-
return
|
|
30
|
+
// Quizzes are paged; a course with many of them spills past page one.
|
|
31
|
+
return await fetchAllObjects(apiClient, apiClient.le(courseId, "/quizzes/"), {
|
|
32
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
33
|
+
});
|
|
32
34
|
}
|
|
33
35
|
catch (error) {
|
|
34
36
|
log("DEBUG", `get_my_grades: quiz list unavailable for course ${courseId}`, error);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { fetchAllObjects } from "../api/paginate.js";
|
|
7
8
|
import { GetUpcomingDueDatesSchema, } from "./schemas.js";
|
|
8
9
|
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
9
10
|
import { log } from "../utils/logger.js";
|
|
@@ -78,7 +79,10 @@ function calendarItems(events, items) {
|
|
|
78
79
|
async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
79
80
|
const [dropboxResult, quizResult, discussionResult, calendarResult] = await Promise.allSettled([
|
|
80
81
|
apiClient.get(apiClient.le(course.id, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
|
|
81
|
-
|
|
82
|
+
// Quizzes are paged; a course with many of them spills past page one.
|
|
83
|
+
fetchAllObjects(apiClient, apiClient.le(course.id, "/quizzes/"), {
|
|
84
|
+
ttl: DEFAULT_CACHE_TTLS.assignments,
|
|
85
|
+
}),
|
|
82
86
|
fetchDiscussionDueTopics(apiClient, course.id),
|
|
83
87
|
fetchCourseCalendarEvents(apiClient, baseUrl, course, from, to),
|
|
84
88
|
]);
|
|
@@ -117,7 +121,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
117
121
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch dropbox folders for course ${course.id}`, dropboxResult.reason);
|
|
118
122
|
}
|
|
119
123
|
if (quizResult.status === "fulfilled") {
|
|
120
|
-
for (const quiz of
|
|
124
|
+
for (const quiz of quizResult.value) {
|
|
121
125
|
if (quiz.IsActive === false)
|
|
122
126
|
continue;
|
|
123
127
|
// Many instructors set only an End Date, which is the effective deadline.
|
package/build/utils/config.js
CHANGED
|
@@ -53,6 +53,12 @@ export async function loadConfig() {
|
|
|
53
53
|
const rememberMfa = envBoolean(process.env.D2L_REMEMBER_MFA, "D2L_REMEMBER_MFA")
|
|
54
54
|
?? store?.rememberMfa
|
|
55
55
|
?? false;
|
|
56
|
+
// Opt-in: sign in with Microsoft's passwordless phone approval, so no
|
|
57
|
+
// password is read or saved. Every sign-in then needs the phone, which is
|
|
58
|
+
// why it is off unless the user chose it in setup or set D2L_PASSWORDLESS.
|
|
59
|
+
const passwordless = envBoolean(process.env.D2L_PASSWORDLESS, "D2L_PASSWORDLESS")
|
|
60
|
+
?? store?.passwordless
|
|
61
|
+
?? false;
|
|
56
62
|
// Resolve tokenTtl: env > store > default (3600)
|
|
57
63
|
const tokenTtl = positiveSeconds(process.env.D2L_TOKEN_TTL, "D2L_TOKEN_TTL")
|
|
58
64
|
?? positiveSeconds(store?.tokenTtl, "tokenTtl in config.json")
|
|
@@ -87,7 +93,7 @@ export async function loadConfig() {
|
|
|
87
93
|
}
|
|
88
94
|
const baseUrl = configuredUrl.origin;
|
|
89
95
|
const username = process.env.D2L_USERNAME || store?.username;
|
|
90
|
-
const password = await resolveStoredPassword(baseUrl, username, store);
|
|
96
|
+
const password = passwordless ? undefined : await resolveStoredPassword(baseUrl, username, store);
|
|
91
97
|
// A new account must never inherit another account's cookies, even at the same school.
|
|
92
98
|
const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
|
|
93
99
|
const legacyMigration = sessionDir !== sessionRoot ? await migrateLegacyState(sessionRoot) : undefined;
|
|
@@ -99,6 +105,7 @@ export async function loadConfig() {
|
|
|
99
105
|
tokenTtl,
|
|
100
106
|
headless,
|
|
101
107
|
rememberMfa,
|
|
108
|
+
passwordless,
|
|
102
109
|
username,
|
|
103
110
|
password,
|
|
104
111
|
campus: process.env.D2L_CAMPUS || store?.campus,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getStoredPassword, setStoredPassword } from "../auth/credential-store.js";
|
|
1
|
+
import { deleteStoredPassword, getStoredPassword, setStoredPassword } from "../auth/credential-store.js";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { acquireProcessLock } from "../auth/auth-lock.js";
|
|
4
4
|
import { configStoreExists, getConfigStorePath, loadConfigStore, saveConfigStore } from "./config-store.js";
|
|
@@ -33,6 +33,9 @@ async function saveSecureConfigUnlocked(config) {
|
|
|
33
33
|
throw new Error("Could not verify the password in the native credential store. Configuration was preserved.");
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
|
+
if (publicConfig.passwordless && config.baseUrl && config.username) {
|
|
37
|
+
await deleteStoredPassword(config.baseUrl, config.username);
|
|
38
|
+
}
|
|
36
39
|
saveConfigStore(publicConfig);
|
|
37
40
|
}
|
|
38
41
|
/** Migrate a v1 password under its own account, before resolving environment overrides. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.12.1",
|
|
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",
|