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 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
- emailFields: ["input[type=email]", "input[name=loginfmt]"],
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"]',
@@ -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 = page.getByRole("textbox", { name: /passcode|verification code/i }).first();
130
- if (!await input.isVisible().catch(() => false))
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.`);
@@ -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
- const EMAIL_SELECTORS = ["input[type=email]", "input[name=loginfmt]"];
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 Microsoft email field did not appear. Automatic sign-in cannot continue.");
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 Microsoft email submit button did not appear. Automatic sign-in cannot continue.");
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 Microsoft password field did not appear. Automatic sign-in cannot continue.");
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 Microsoft password submit button did not appear. Automatic sign-in cannot continue.");
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.0",
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",