brightspace-mcp-server 3.7.0 → 3.7.2
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 +2 -0
- package/build/auth/browser-auth.js +5 -1
- package/build/auth/duo-mfa.js +79 -2
- package/build/auth/purdue-sso.js +105 -5
- package/build/tools/get-calendar-events.js +7 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -105,6 +105,8 @@ Run it from your home folder. On macOS, a terminal that lacks Files and Folders
|
|
|
105
105
|
|
|
106
106
|
**MFA at Purdue** commonly uses Microsoft Authenticator number matching (some schools use Duo instead). When a sign-in needs it, the tool call itself returns quickly with the number to enter, rather than sitting silent for up to five minutes — approve it on your phone, then call the tool again; sign-in finishes in the background in the meantime. Google Authenticator and other one-time-code apps work too, with no setting to change: run the auth command above in a terminal and it prompts for the code when your provider asks for one. Pick the visible-browser option during setup only if your identity provider needs interaction the server cannot drive. The MCP also sends authentication progress as logging notifications to clients that display them, useful if you don't see the number in the tool response for some reason.
|
|
107
107
|
|
|
108
|
+
**On a Duo tenant**, if Duo asks "Is this your device?" before it will send a push, automatic sign-in answers **yes** so the push can go out at all — a headless run has nobody to click it otherwise. That also makes Duo remember the device, which skips its own device check on later logins from this machine. Don't run automatic sign-in on a shared or public computer if you'd rather Duo keep asking. Setting `D2L_DUO_PASSCODE` to any value switches from waiting for a push to typing a code from Duo Mobile's passcode option instead.
|
|
109
|
+
|
|
108
110
|
## What You Can Ask About
|
|
109
111
|
|
|
110
112
|
| Topic | Examples |
|
|
@@ -19,7 +19,11 @@ const SILENT_SSO_POLL_MS = 1000;
|
|
|
19
19
|
const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
|
|
20
20
|
const VISIBLE_LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
|
|
21
21
|
const SILENT_SSO = {
|
|
22
|
-
|
|
22
|
+
// Kept in sync with purdue-sso.ts's EMAIL_SELECTORS: awaitSilentSSO gates on
|
|
23
|
+
// this list before ever calling into that file, so a two-step Shibboleth/CAS
|
|
24
|
+
// IdP whose username field only matches j_username or #signinid would never
|
|
25
|
+
// get past this gate otherwise.
|
|
26
|
+
emailFields: ["input[type=email]", "input[name=loginfmt]", "input[name=j_username]", "input#signinid"],
|
|
23
27
|
credentialFields: ['input#username', 'input#userName', 'input[type="password"]'],
|
|
24
28
|
mfaChallenges: ["#idRichContext_DisplaySign", "#idDiv_SAOTCAS_Title", "#idDiv_SAOTCC_Title"],
|
|
25
29
|
campusSaml: 'a[href*="/d2l/lp/auth/saml/initiate-login"]',
|
package/build/auth/duo-mfa.js
CHANGED
|
@@ -30,6 +30,8 @@ const PROMPT_SCOPE_SELECTORS = [
|
|
|
30
30
|
"main",
|
|
31
31
|
"body",
|
|
32
32
|
];
|
|
33
|
+
/** Duo's own passcode field, which its Universal Prompt labels inconsistently. */
|
|
34
|
+
const PASSCODE_INPUT_SELECTORS = ["#passcode-input", 'input[name="passcode"]'];
|
|
33
35
|
/** The digits on a visible element, or null when it is absent or not digits. */
|
|
34
36
|
async function readCodeFrom(target) {
|
|
35
37
|
if (!await target.isVisible().catch(() => false))
|
|
@@ -53,6 +55,11 @@ export class DuoMfaHandler {
|
|
|
53
55
|
approvalAnnounced = false;
|
|
54
56
|
verificationCodeAnnounced = null;
|
|
55
57
|
passcodeSubmitted = false;
|
|
58
|
+
/** True once Duo's remembered-device question has been answered this login. */
|
|
59
|
+
deviceQuestionAnswered = false;
|
|
60
|
+
/** Step guards for walking Duo's "Other options" menu to the passcode field. */
|
|
61
|
+
otherOptionsClicked = false;
|
|
62
|
+
passcodeChoiceClicked = false;
|
|
56
63
|
/** True once onMfaChallenge has been told about this login, code or not. */
|
|
57
64
|
announcedToCaller = false;
|
|
58
65
|
constructor(options) {
|
|
@@ -61,10 +68,80 @@ export class DuoMfaHandler {
|
|
|
61
68
|
isChallenge(page) {
|
|
62
69
|
return isDuoPrompt(page);
|
|
63
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Some tenants gate the push behind a remembered-device question ("Is this
|
|
73
|
+
* your device?") that nothing proceeds past until it is answered. A headless
|
|
74
|
+
* run has nobody to click it, so the whole login stalls until the five-minute
|
|
75
|
+
* MFA deadline expires. Answering yes also makes Duo remember this device,
|
|
76
|
+
* which is what an unattended client wants.
|
|
77
|
+
*/
|
|
78
|
+
async answerDeviceQuestion(page) {
|
|
79
|
+
if (this.deviceQuestionAnswered)
|
|
80
|
+
return false;
|
|
81
|
+
const candidates = [
|
|
82
|
+
page.getByRole("button", { name: /yes.*this is my device/i }).first(),
|
|
83
|
+
page.locator("button", { hasText: /yes.*this is my device/i }).first(),
|
|
84
|
+
];
|
|
85
|
+
for (const candidate of candidates) {
|
|
86
|
+
if (!await candidate.isVisible().catch(() => false))
|
|
87
|
+
continue;
|
|
88
|
+
this.deviceQuestionAnswered = true;
|
|
89
|
+
log("INFO", "Answered Duo's remembered-device question with yes.");
|
|
90
|
+
await candidate.click().catch(() => { });
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
/** Duo's passcode field, whichever of its shapes is on screen. */
|
|
96
|
+
async passcodeInput(page) {
|
|
97
|
+
for (const selector of PASSCODE_INPUT_SELECTORS) {
|
|
98
|
+
const target = page.locator(selector).first();
|
|
99
|
+
if (await target.isVisible().catch(() => false))
|
|
100
|
+
return target;
|
|
101
|
+
}
|
|
102
|
+
const byRole = page.getByRole("textbox", { name: /passcode|verification code/i }).first();
|
|
103
|
+
return await byRole.isVisible().catch(() => false) ? byRole : null;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Duo opens on "Check for a Duo Push" and keeps the passcode field behind its
|
|
107
|
+
* "Other options" menu ("Select an option to log in" -> "Duo Mobile
|
|
108
|
+
* passcode"). Walking that path makes sign-in independent of push delivery
|
|
109
|
+
* and of the device Duo happens to pick. Each step is clicked at most once,
|
|
110
|
+
* so a re-render cannot bounce the page back and forth.
|
|
111
|
+
*/
|
|
112
|
+
async openPasscodeEntry(page) {
|
|
113
|
+
if (await this.passcodeInput(page))
|
|
114
|
+
return;
|
|
115
|
+
if (!this.otherOptionsClicked) {
|
|
116
|
+
const other = page.getByRole("button", { name: /other options/i }).first();
|
|
117
|
+
if (await other.isVisible().catch(() => false)) {
|
|
118
|
+
this.otherOptionsClicked = true;
|
|
119
|
+
log("INFO", "Opening Duo's Other options menu for a passcode.");
|
|
120
|
+
await other.click().catch(() => { });
|
|
121
|
+
await page.waitForTimeout(1500);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (this.passcodeChoiceClicked)
|
|
125
|
+
return;
|
|
126
|
+
const choice = page.getByRole("button", { name: /duo mobile passcode/i }).first();
|
|
127
|
+
if (await choice.isVisible().catch(() => false)) {
|
|
128
|
+
this.passcodeChoiceClicked = true;
|
|
129
|
+
log("INFO", "Choosing Duo Mobile passcode entry.");
|
|
130
|
+
await choice.click().catch(() => { });
|
|
131
|
+
await page.waitForTimeout(1500);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
64
134
|
/** Returns true while the page is on a Duo challenge. */
|
|
65
135
|
async handle(page) {
|
|
66
136
|
if (!this.isChallenge(page))
|
|
67
137
|
return false;
|
|
138
|
+
// Answer before announcing the push: on a gated tenant no push is sent
|
|
139
|
+
// until this is answered, so announcing first would be a lie.
|
|
140
|
+
await this.answerDeviceQuestion(page);
|
|
141
|
+
// Opt-in, because it replaces a push with a typed code: without it a tenant
|
|
142
|
+
// whose pushes work keeps the zero-touch path.
|
|
143
|
+
if (process.env.D2L_DUO_PASSCODE)
|
|
144
|
+
await this.openPasscodeEntry(page);
|
|
68
145
|
const verificationCode = await this.readVerificationCode(page);
|
|
69
146
|
if (!this.approvalAnnounced) {
|
|
70
147
|
this.approvalAnnounced = true;
|
|
@@ -126,8 +203,8 @@ export class DuoMfaHandler {
|
|
|
126
203
|
async submitPasscode(page) {
|
|
127
204
|
if (this.options.headless === false || this.passcodeSubmitted)
|
|
128
205
|
return;
|
|
129
|
-
const input =
|
|
130
|
-
if (!
|
|
206
|
+
const input = await this.passcodeInput(page);
|
|
207
|
+
if (!input)
|
|
131
208
|
return;
|
|
132
209
|
if (!this.options.requestMfaCode) {
|
|
133
210
|
throw new UnsupportedAuthenticationError(`Duo requires a passcode. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
|
package/build/auth/purdue-sso.js
CHANGED
|
@@ -8,7 +8,9 @@ import { log } from "../utils/logger.js";
|
|
|
8
8
|
import { MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
9
9
|
import { DuoMfaHandler } from "./duo-mfa.js";
|
|
10
10
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
11
|
-
|
|
11
|
+
// Entra names its username field type=email/loginfmt; Shibboleth portals (USC's
|
|
12
|
+
// login.usc.edu among them) use the protocol's j_username.
|
|
13
|
+
const EMAIL_SELECTORS = ["input[type=email]", "input[name=loginfmt]", "input[name=j_username]", "input#signinid"];
|
|
12
14
|
const PASSWORD_SELECTORS = ["input[type=password]", "input[name=passwd]"];
|
|
13
15
|
const SUBMIT_SELECTORS = ["#idSIButton9", "input[type=submit]", "button[type=submit]"];
|
|
14
16
|
const FIELD_TIMEOUT_MS = 30_000;
|
|
@@ -57,6 +59,14 @@ export class PurdueSSOFlow {
|
|
|
57
59
|
const email = signInName(this.config.username, this.config.baseUrl);
|
|
58
60
|
if (!await this.fillWhenReady(page, EMAIL_SELECTORS, email))
|
|
59
61
|
return false;
|
|
62
|
+
// Reached via awaitSilentSSO, a single-page IdP (see enterCredentials)
|
|
63
|
+
// renders its password field next to the username too. Clicking submit
|
|
64
|
+
// without filling it first would post an empty password and burn the
|
|
65
|
+
// attempt, so detect and handle it here the same way.
|
|
66
|
+
if (this.config.password && await this.hasCoVisiblePassword(page)) {
|
|
67
|
+
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password))
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
60
70
|
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS))
|
|
61
71
|
return false;
|
|
62
72
|
this.accountHintSubmitted = true;
|
|
@@ -125,17 +135,34 @@ export class PurdueSSOFlow {
|
|
|
125
135
|
if (!hintAccepted) {
|
|
126
136
|
const email = signInName(this.config.username, this.config.baseUrl);
|
|
127
137
|
if (!await this.fillWhenReady(page, EMAIL_SELECTORS, email)) {
|
|
128
|
-
throw new UnsupportedAuthenticationError("The
|
|
138
|
+
throw new UnsupportedAuthenticationError("The identity provider's username field did not appear. Automatic sign-in cannot continue.");
|
|
139
|
+
}
|
|
140
|
+
// A single-page identity provider (Shibboleth portals such as USC's
|
|
141
|
+
// login.usc.edu) renders the password field next to the username. Clicking
|
|
142
|
+
// submit between the two would post an empty password and spend the
|
|
143
|
+
// attempt, so fill both and click once. Entra can also flash a
|
|
144
|
+
// password-shaped decoy for a single instant while its email view is
|
|
145
|
+
// still initializing (see awaitSilentSSO's passwordPromptPolls in
|
|
146
|
+
// browser-auth.ts), so this only takes the single-page branch once the
|
|
147
|
+
// field survives two consecutive checks.
|
|
148
|
+
if (await this.hasCoVisiblePassword(page)) {
|
|
149
|
+
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
|
|
150
|
+
throw new UnsupportedAuthenticationError("The identity provider's password field did not appear. Automatic sign-in cannot continue.");
|
|
151
|
+
}
|
|
152
|
+
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
|
|
153
|
+
throw new UnsupportedAuthenticationError("The identity provider's submit button did not appear. Automatic sign-in cannot continue.");
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
129
156
|
}
|
|
130
157
|
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
|
|
131
|
-
throw new UnsupportedAuthenticationError("The
|
|
158
|
+
throw new UnsupportedAuthenticationError("The identity provider's username submit button did not appear. Automatic sign-in cannot continue.");
|
|
132
159
|
}
|
|
133
160
|
}
|
|
134
161
|
if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
|
|
135
|
-
throw new UnsupportedAuthenticationError("The
|
|
162
|
+
throw new UnsupportedAuthenticationError("The identity provider's password field did not appear. Automatic sign-in cannot continue.");
|
|
136
163
|
}
|
|
137
164
|
if (!await this.clickWhenReady(page, SUBMIT_SELECTORS)) {
|
|
138
|
-
throw new UnsupportedAuthenticationError("The
|
|
165
|
+
throw new UnsupportedAuthenticationError("The identity provider's password submit button did not appear. Automatic sign-in cannot continue.");
|
|
139
166
|
}
|
|
140
167
|
}
|
|
141
168
|
/** Ported from Brightspace Bar's proven four-step Entra choreography. */
|
|
@@ -168,6 +195,22 @@ export class PurdueSSOFlow {
|
|
|
168
195
|
}
|
|
169
196
|
return false;
|
|
170
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* True only when the password field is visible on two consecutive checks.
|
|
200
|
+
* Entra can transiently show a password-shaped control for a single
|
|
201
|
+
* instant while its email view is still initializing (documented next to
|
|
202
|
+
* awaitSilentSSO's `passwordPromptPolls` in browser-auth.ts); trusting one
|
|
203
|
+
* instantaneous observation can fill that decoy and click Next, leaving
|
|
204
|
+
* Entra's real password page never filled. A genuinely single-page IdP
|
|
205
|
+
* (Shibboleth portals such as USC's login.usc.edu) keeps the field on
|
|
206
|
+
* screen, so it survives the second check.
|
|
207
|
+
*/
|
|
208
|
+
async hasCoVisiblePassword(page) {
|
|
209
|
+
if (!await this.anyVisible(page, PASSWORD_SELECTORS))
|
|
210
|
+
return false;
|
|
211
|
+
await page.waitForTimeout(FIELD_POLL_MS);
|
|
212
|
+
return await this.anyVisible(page, PASSWORD_SELECTORS);
|
|
213
|
+
}
|
|
171
214
|
async hasPostCredentialChallenge(page) {
|
|
172
215
|
return this.duoMfa.isChallenge(page) || await this.anyVisible(page, [
|
|
173
216
|
NUMBER_MATCH_SELECTOR,
|
|
@@ -216,6 +259,9 @@ export class PurdueSSOFlow {
|
|
|
216
259
|
return;
|
|
217
260
|
}
|
|
218
261
|
await this.clickProvenKmsi(page);
|
|
262
|
+
// The federated-domain trust prompt arrives after the IdP succeeds, so
|
|
263
|
+
// it has to be caught by this loop rather than by enterCredentials.
|
|
264
|
+
await this.clickTrustPrompt(page);
|
|
219
265
|
await page.waitForTimeout(NUMBER_MATCH_POLL_MS);
|
|
220
266
|
}
|
|
221
267
|
}
|
|
@@ -301,6 +347,60 @@ export class PurdueSSOFlow {
|
|
|
301
347
|
log("DEBUG", 'Clicked Yes on "Stay signed in?"');
|
|
302
348
|
}
|
|
303
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Microsoft asks users of a federated domain to confirm they trust it ("Do
|
|
352
|
+
* you trust usc.edu?") before issuing the SAML assertion to Brightspace.
|
|
353
|
+
* Nothing proceeds until Continue is clicked, and a headless run has nobody
|
|
354
|
+
* to click it, so the flow parks on this page until the MFA deadline and
|
|
355
|
+
* the session is never established. This dialog is an anti-login-CSRF
|
|
356
|
+
* control, not a nuisance interstitial, so it is not enough to notice the
|
|
357
|
+
* text is present somewhere on the page (`page.getByText()` is a whole-page
|
|
358
|
+
* substring search, not a heading-scoped match) — the domain named in the
|
|
359
|
+
* prompt is parsed out and compared against the domain this login is
|
|
360
|
+
* actually signing into. A mismatch (e.g. the browser was steered to a
|
|
361
|
+
* different tenant's confirmation) is left unclicked; the existing 5-minute
|
|
362
|
+
* MFA timeout is the safe failure mode for that, same as any other
|
|
363
|
+
* unhandled prompt.
|
|
364
|
+
*/
|
|
365
|
+
async clickTrustPrompt(page) {
|
|
366
|
+
if (new URL(page.url()).hostname !== "login.microsoftonline.com")
|
|
367
|
+
return;
|
|
368
|
+
const prompt = page.getByText(/Do you trust/i).first();
|
|
369
|
+
if (!await prompt.isVisible().catch(() => false))
|
|
370
|
+
return;
|
|
371
|
+
const text = await prompt.textContent().catch(() => null);
|
|
372
|
+
const promptDomain = text ? this.extractTrustDomain(text) : null;
|
|
373
|
+
const expectedDomain = this.expectedTrustDomain();
|
|
374
|
+
if (!promptDomain || !expectedDomain || promptDomain !== expectedDomain) {
|
|
375
|
+
log("WARN", `Domain-trust prompt named "${promptDomain ?? "an unknown domain"}", which does not match the configured sign-in domain; not confirming trust automatically.`);
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
const cont = page.getByRole("button", { name: /continue/i }).first();
|
|
379
|
+
if (!await cont.isVisible().catch(() => false))
|
|
380
|
+
return;
|
|
381
|
+
await cont.click().catch(() => { });
|
|
382
|
+
log("INFO", `Clicked Continue on Microsoft's domain-trust prompt for ${promptDomain}.`);
|
|
383
|
+
}
|
|
384
|
+
/** Pulls the domain Microsoft named out of "Do you trust <domain>?" text. */
|
|
385
|
+
extractTrustDomain(text) {
|
|
386
|
+
const match = text.match(/do you trust\s+([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+)/i);
|
|
387
|
+
return match ? match[1].toLowerCase() : null;
|
|
388
|
+
}
|
|
389
|
+
/** The domain this login is actually signing into, to check the trust prompt against. */
|
|
390
|
+
expectedTrustDomain() {
|
|
391
|
+
if (this.config.username) {
|
|
392
|
+
const email = signInName(this.config.username, this.config.baseUrl);
|
|
393
|
+
const at = email.lastIndexOf("@");
|
|
394
|
+
if (at !== -1)
|
|
395
|
+
return email.slice(at + 1).toLowerCase();
|
|
396
|
+
}
|
|
397
|
+
// Purdue's own tenant is federated to purdue.edu regardless of what the
|
|
398
|
+
// configured username looks like.
|
|
399
|
+
if (this.config.baseUrl && new URL(this.config.baseUrl).hostname.toLowerCase() === "purdue.brightspace.com") {
|
|
400
|
+
return "purdue.edu";
|
|
401
|
+
}
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
304
404
|
/** The digits on screen, or null when Entra is not showing any. */
|
|
305
405
|
async readNumberMatch(page) {
|
|
306
406
|
const sign = page.locator(NUMBER_MATCH_SELECTOR).first();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { GetCalendarEventsSchema } from "./schemas.js";
|
|
7
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
7
|
+
import { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
|
|
8
8
|
import { log } from "../utils/logger.js";
|
|
9
9
|
import { resolveCourses } from "./resolve-courses.js";
|
|
10
10
|
import { fetchCourseCalendarEvents } from "./calendar-events.js";
|
|
@@ -23,6 +23,12 @@ export function registerGetCalendarEvents(server, apiClient, config) {
|
|
|
23
23
|
const { courseId, from, to, includeGenerated } = GetCalendarEventsSchema.parse(args);
|
|
24
24
|
const windowStart = from ? new Date(from).getTime() : Date.now();
|
|
25
25
|
const windowEnd = to ? new Date(to).getTime() : windowStart + DEFAULT_WINDOW_MS;
|
|
26
|
+
// An inverted window matches nothing, and an empty list reads as "no events".
|
|
27
|
+
if (windowEnd < windowStart) {
|
|
28
|
+
return errorResponse(from
|
|
29
|
+
? `to (${to}) is before from (${from}). Pass a to that is on or after from.`
|
|
30
|
+
: `to (${to}) is in the past, and from defaults to now. Pass from as well to look at past events.`);
|
|
31
|
+
}
|
|
26
32
|
const courses = await resolveCourses(apiClient, config, courseId);
|
|
27
33
|
const results = await Promise.allSettled(courses.map((course) => fetchCourseCalendarEvents(apiClient, config.baseUrl, course, windowStart, windowEnd)));
|
|
28
34
|
const events = results
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.2",
|
|
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",
|