brightspace-mcp-server 3.9.14 → 3.9.16
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 -2
- package/build/auth/index.js +1 -0
- package/build/auth/javeriana-sso.js +322 -0
- package/build/auth/sso-flow.js +4 -0
- package/build/setup.js +6 -0
- package/build/tools/get-assignments.js +35 -4
- package/build/utils/config.js +20 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,14 +28,14 @@ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and a
|
|
|
28
28
|
It asks for your school's Brightspace address, your username and password, and how you normally do two-factor sign-in (phone approval, authenticator code, or a browser window). Your password goes into your computer's own password store (Keychain on Mac, Credential Manager on Windows), never into a file. At the end it connects itself to Claude Desktop, Cursor, Codex, or Claude Code if you have them.
|
|
29
29
|
3. **Restart your AI app and ask it something** — the first question signs you in. If your phone asks you to approve a sign-in, approve it and ask again.
|
|
30
30
|
|
|
31
|
-
At one of these schools, add the flag and skip typing the address: `--purdue`, `--suny`, `--western`, `--tudelft`, `--cuny`, `--leiden`.
|
|
31
|
+
At one of these schools, add the flag and skip typing the address: `--purdue`, `--suny`, `--western`, `--tudelft`, `--cuny`, `--leiden`, `--javeriana`.
|
|
32
32
|
|
|
33
33
|
**Rather have the AI install it for you?** Paste this into Claude Code, Cursor, Windsurf, Copilot, or Codex:
|
|
34
34
|
|
|
35
35
|
```
|
|
36
36
|
Install brightspace-mcp-server for me by following
|
|
37
37
|
https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
|
|
38
|
-
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, or --
|
|
38
|
+
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, --leiden at Leiden, or --javeriana at Javeriana Cali).
|
|
39
39
|
```
|
|
40
40
|
|
|
41
41
|
Using a different AI app? Add the command `npx -y brightspace-mcp-server@latest` to its MCP settings (on Windows: `cmd /c npx -y brightspace-mcp-server@latest`). Exact steps for each app: [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
@@ -52,6 +52,7 @@ If your school uses D2L Brightspace, yes. These have automatic sign-in built in:
|
|
|
52
52
|
| TU Delft | NetID (no two-factor) | `--tudelft` |
|
|
53
53
|
| CUNY | CUNY Login, authenticator code each time | `--cuny` |
|
|
54
54
|
| Leiden University | SURFconext → Microsoft | `--leiden` |
|
|
55
|
+
| Javeriana Cali | OneGate (password + authenticator code) | `--javeriana` |
|
|
55
56
|
| Any other D2L school | Paste your Brightspace address; if the login page isn't recognized, a browser window opens so you can sign in by hand | none |
|
|
56
57
|
|
|
57
58
|
School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
|
package/build/auth/index.js
CHANGED
|
@@ -9,6 +9,7 @@ export { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
|
|
|
9
9
|
export { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
|
|
10
10
|
export { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
|
|
11
11
|
export { LeidenSSOFlow, isLeidenBrightspace } from "./leiden-sso.js";
|
|
12
|
+
export { JaverianaSSOFlow, isJaverianaBrightspace } from "./javeriana-sso.js";
|
|
12
13
|
export { createSSOFlow } from "./sso-flow.js";
|
|
13
14
|
export { TokenManager } from "./token-manager.js";
|
|
14
15
|
export { SessionStore } from "./session-store.js";
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/** Pontificia Universidad Javeriana Cali login via MobilityGuard OneGate. Licensed under MIT; see LICENSE. */
|
|
2
|
+
import { BrowserAuthError } from "../utils/errors.js";
|
|
3
|
+
import { log } from "../utils/logger.js";
|
|
4
|
+
import { AUTH_COMMAND, SETUP_COMMAND } from "../utils/commands.js";
|
|
5
|
+
import { MfaApprovalError, UnsupportedAuthenticationError } from "./sso-flow.js";
|
|
6
|
+
/**
|
|
7
|
+
* Ported from Joshua Mendez (@JoshuaMontclair)'s fork, branch
|
|
8
|
+
* feat/javeriana-cali-sso (MIT). The fork's stored-TOTP-secret path and its
|
|
9
|
+
* separate sso-factory.ts are not carried over: here, as with every other
|
|
10
|
+
* code-based MFA in this project, the authenticator code is typed by a
|
|
11
|
+
* person through the terminal prompt (see cuny-sso.ts, which this flow is
|
|
12
|
+
* otherwise modeled on).
|
|
13
|
+
*
|
|
14
|
+
* Brightspace federates by SAML to MobilityGuard OneGate at
|
|
15
|
+
* mg-local.servicios.javerianacali.edu.co:
|
|
16
|
+
*
|
|
17
|
+
* /d2l/login → ... → IdP "Elija su método de autenticación" chooser
|
|
18
|
+
* → GET /mg-local/login?type=webtoken ("Usuario y Contraseña")
|
|
19
|
+
* → (submit #form) → an /auth/totp-pattern page asking for the code
|
|
20
|
+
* → (submit #otp-form) → /d2l/home
|
|
21
|
+
*
|
|
22
|
+
* Both the credentials form (#form) and the code form (#otp-form) copy their
|
|
23
|
+
* typed values into hidden inputs from a submit handler wired up on `load`;
|
|
24
|
+
* clicking before that handler attaches posts the hidden fields empty. Hence
|
|
25
|
+
* `waitForLoadState("load")` before every click. The code field is rendered
|
|
26
|
+
* visible but disabled at first paint, so being editable — not merely
|
|
27
|
+
* visible — is the only honest sign that OneGate is ready for it.
|
|
28
|
+
*/
|
|
29
|
+
const JAVERIANA_BRIGHTSPACE_HOST = "auladigital.javerianacali.edu.co";
|
|
30
|
+
const ONEGATE_HOST = "mg-local.servicios.javerianacali.edu.co";
|
|
31
|
+
/** "Usuario y Contraseña" — OneGate's chooser link for password sign-in. */
|
|
32
|
+
const CREDENTIALS_FORM_PATH = "/mg-local/login?type=webtoken";
|
|
33
|
+
const SELECTORS = {
|
|
34
|
+
username: "input#user-id",
|
|
35
|
+
password: "input#password",
|
|
36
|
+
token: "input#token",
|
|
37
|
+
credentialsSubmit: 'form#form input[type="submit"]',
|
|
38
|
+
tokenSubmit: 'form#otp-form input[type="submit"]',
|
|
39
|
+
/** OneGate's status banner. Rendered hidden and unhidden to show errors. */
|
|
40
|
+
statusMessage: ".box--system-message",
|
|
41
|
+
/**
|
|
42
|
+
* The method chooser's own "Usuario y Contraseña" link — a positive
|
|
43
|
+
* marker that distinguishes the chooser from a transient interstitial
|
|
44
|
+
* (a SAML auto-post, a "processing" page) that also lacks the
|
|
45
|
+
* username/token fields but must not be navigated away from.
|
|
46
|
+
*/
|
|
47
|
+
chooserLink: 'a[href*="type=webtoken"]',
|
|
48
|
+
};
|
|
49
|
+
const POLL_MS = 500;
|
|
50
|
+
/** OneGate parks on the chooser and credentials form briefly before the code challenge. */
|
|
51
|
+
const CHALLENGE_TIMEOUT_MS = 60_000;
|
|
52
|
+
/** A person has to find their authenticator and read a code. */
|
|
53
|
+
const MFA_TIMEOUT_MS = 5 * 60 * 1000;
|
|
54
|
+
const FORM_NAVIGATION_TIMEOUT_MS = 30_000;
|
|
55
|
+
/** How many mistyped codes to re-prompt for before giving up on this challenge. */
|
|
56
|
+
const MAX_CODE_ATTEMPTS = 3;
|
|
57
|
+
/** True for Javeriana Cali's Brightspace tenant. */
|
|
58
|
+
export function isJaverianaBrightspace(baseUrl) {
|
|
59
|
+
try {
|
|
60
|
+
return new URL(baseUrl).hostname.toLowerCase() === JAVERIANA_BRIGHTSPACE_HOST;
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/** Compare hosts, not substrings: a lookalike domain must never pass this check. */
|
|
67
|
+
function isOneGate(url) {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = new URL(url);
|
|
70
|
+
return parsed.protocol === "https:" && parsed.hostname.toLowerCase() === ONEGATE_HOST;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Login flow for Javeriana Cali's Brightspace, behind MobilityGuard OneGate.
|
|
78
|
+
*
|
|
79
|
+
* OneGate's chooser is a plain GET link rather than a visible form, so
|
|
80
|
+
* `selectIdentityProvider` follows it during the shared silent-SSO poll
|
|
81
|
+
* (see browser-auth.ts's clickSilentSurfaces) before a human ever has to;
|
|
82
|
+
* `login` calls the same step again in case nothing resolved it yet. The
|
|
83
|
+
* authenticator code OneGate asks for afterward has no stored secret to
|
|
84
|
+
* generate it from, so it comes from a person, through the same terminal
|
|
85
|
+
* prompt or visible browser every other code-based MFA in this project uses.
|
|
86
|
+
*/
|
|
87
|
+
export class JaverianaSSOFlow {
|
|
88
|
+
config;
|
|
89
|
+
/** Guards the chooser-to-credentials-form redirect to at most once per login. */
|
|
90
|
+
credentialsFormOpened = false;
|
|
91
|
+
constructor(config) {
|
|
92
|
+
this.config = config;
|
|
93
|
+
}
|
|
94
|
+
hasCredentials() {
|
|
95
|
+
return Boolean(this.config.username && this.config.password);
|
|
96
|
+
}
|
|
97
|
+
/** Follow OneGate's "Usuario y Contraseña" chooser link before a human has to. */
|
|
98
|
+
async selectIdentityProvider(page) {
|
|
99
|
+
if (!isOneGate(page.url()))
|
|
100
|
+
return;
|
|
101
|
+
await this.openCredentialsForm(page);
|
|
102
|
+
}
|
|
103
|
+
async login(page) {
|
|
104
|
+
try {
|
|
105
|
+
log("INFO", "Starting Javeriana Cali sign-in flow (MobilityGuard OneGate)");
|
|
106
|
+
await this.completeLogin(page);
|
|
107
|
+
return true;
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
if (error instanceof BrowserAuthError)
|
|
111
|
+
throw error;
|
|
112
|
+
throw new UnsupportedAuthenticationError("OneGate could not complete automatic sign-in. Check saved credentials and your authenticator settings.", error);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Poll the page and answer whichever OneGate step is on screen. A run can
|
|
117
|
+
* hand over mid-flow (resumed re-authentication, or a run that handed off
|
|
118
|
+
* to a visible browser), so no step is assumed to come first.
|
|
119
|
+
*/
|
|
120
|
+
async completeLogin(page) {
|
|
121
|
+
let deadline = Date.now() + (this.config.headless === false ? MFA_TIMEOUT_MS : CHALLENGE_TIMEOUT_MS);
|
|
122
|
+
let credentialsSubmitted = false;
|
|
123
|
+
let challenged = false;
|
|
124
|
+
/** The page the code was submitted from; a reload away from it is a rejection. */
|
|
125
|
+
let codeSubmittedFrom = null;
|
|
126
|
+
let announced = false;
|
|
127
|
+
while (Date.now() < deadline) {
|
|
128
|
+
if (await this.isAuthenticated(page)) {
|
|
129
|
+
log("INFO", "Login successful - verified Brightspace home");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (isOneGate(page.url())) {
|
|
133
|
+
await this.openCredentialsForm(page);
|
|
134
|
+
this.reportStatusMessage(page);
|
|
135
|
+
// The banner is also used for harmless notices before a password is
|
|
136
|
+
// ever sent, so only treat it as a hard rejection once credentials
|
|
137
|
+
// have actually been submitted — otherwise this would abort a login
|
|
138
|
+
// that is still progressing normally.
|
|
139
|
+
if (credentialsSubmitted && !challenged && await this.isVisible(page, SELECTORS.statusMessage)) {
|
|
140
|
+
throw new BrowserAuthError(`OneGate rejected the saved username or password. Run \`${SETUP_COMMAND} --javeriana\` to update your password.`, "credentials");
|
|
141
|
+
}
|
|
142
|
+
if (await this.isEditable(page, SELECTORS.token)) {
|
|
143
|
+
if (!challenged) {
|
|
144
|
+
challenged = true;
|
|
145
|
+
deadline = Date.now() + MFA_TIMEOUT_MS;
|
|
146
|
+
}
|
|
147
|
+
if (this.config.headless === false && !this.config.requestMfaCode) {
|
|
148
|
+
// The person at the window types the code and can retry a typo
|
|
149
|
+
// there, so a rejection is theirs to answer, not a failure here.
|
|
150
|
+
if (!announced) {
|
|
151
|
+
announced = true;
|
|
152
|
+
log("WARN", "Enter the authenticator code in the OneGate browser window. Waiting up to 5 minutes.");
|
|
153
|
+
this.config.onMfaChallenge?.(null);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
const url = page.url();
|
|
158
|
+
if (codeSubmittedFrom !== null && url !== codeSubmittedFrom) {
|
|
159
|
+
throw new MfaApprovalError(new Error("OneGate rejected the authenticator code."));
|
|
160
|
+
}
|
|
161
|
+
if (codeSubmittedFrom === null) {
|
|
162
|
+
if (this.config.headless !== false && !this.config.requestMfaCode)
|
|
163
|
+
throw this.codePromptUnavailable();
|
|
164
|
+
codeSubmittedFrom = url;
|
|
165
|
+
await this.submitCode(page);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
else if (!credentialsSubmitted && await this.isVisible(page, SELECTORS.username)) {
|
|
170
|
+
// OneGate's authenticator code is required on every full sign-in and
|
|
171
|
+
// there is no way to type it without a terminal or a visible window,
|
|
172
|
+
// so a headless run with neither can never finish. Stop before the
|
|
173
|
+
// password is sent: otherwise a stale saved password keeps getting
|
|
174
|
+
// resubmitted on every background re-authentication attempt.
|
|
175
|
+
if (this.config.headless !== false && !this.config.requestMfaCode)
|
|
176
|
+
throw this.codePromptUnavailable();
|
|
177
|
+
credentialsSubmitted = true;
|
|
178
|
+
await this.enterCredentials(page);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
await page.waitForTimeout(POLL_MS);
|
|
182
|
+
}
|
|
183
|
+
if (challenged)
|
|
184
|
+
throw new MfaApprovalError();
|
|
185
|
+
throw new UnsupportedAuthenticationError("OneGate did not reach the authenticator-code challenge or Brightspace. If your sign-in needs a different step, set D2L_HEADLESS=false and finish sign-in in the browser.");
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Open the password form from OneGate's chooser. A no-op once a username
|
|
189
|
+
* or code field is already on screen — a resumed run, or a redirect that
|
|
190
|
+
* landed past the chooser on its own — once this redirect has already
|
|
191
|
+
* run for this login, or when the current page is not actually the
|
|
192
|
+
* chooser: a transient interstitial (a SAML auto-post, a "processing"
|
|
193
|
+
* page between steps) also lacks the username/token fields, and
|
|
194
|
+
* navigating away from it would abort an in-flight POST. The chooser's
|
|
195
|
+
* own "Usuario y Contraseña" link is the positive signal that this really
|
|
196
|
+
* is the chooser, safe to redirect from.
|
|
197
|
+
*/
|
|
198
|
+
async openCredentialsForm(page) {
|
|
199
|
+
const hasUsername = (await page.locator(SELECTORS.username).count()) > 0;
|
|
200
|
+
const hasToken = (await page.locator(SELECTORS.token).count()) > 0;
|
|
201
|
+
if (hasUsername || hasToken)
|
|
202
|
+
return;
|
|
203
|
+
if (this.credentialsFormOpened)
|
|
204
|
+
return;
|
|
205
|
+
const hasChooserLink = (await page.locator(SELECTORS.chooserLink).count()) > 0;
|
|
206
|
+
if (!hasChooserLink)
|
|
207
|
+
return;
|
|
208
|
+
this.credentialsFormOpened = true;
|
|
209
|
+
const { origin } = new URL(page.url());
|
|
210
|
+
log("INFO", 'Opening the "Usuario y Contraseña" form');
|
|
211
|
+
await page.goto(`${origin}${CREDENTIALS_FORM_PATH}`, {
|
|
212
|
+
waitUntil: "load",
|
|
213
|
+
timeout: FORM_NAVIGATION_TIMEOUT_MS,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
async enterCredentials(page) {
|
|
217
|
+
if (!this.config.username)
|
|
218
|
+
throw new BrowserAuthError("Username is required for SSO login", "credentials");
|
|
219
|
+
if (!this.config.password)
|
|
220
|
+
throw new BrowserAuthError("Password is required for SSO login", "credentials");
|
|
221
|
+
// The handler that copies typed values into hidden fields attaches on
|
|
222
|
+
// `load`; clicking before then posts the hidden inputs empty.
|
|
223
|
+
await page.waitForLoadState("load");
|
|
224
|
+
// Re-check immediately after the load wait and before either field is
|
|
225
|
+
// touched: a navigation during that wait must never let a lookalike
|
|
226
|
+
// host receive the username, let alone the password.
|
|
227
|
+
this.assertOnOneGate(page, "entering credentials");
|
|
228
|
+
log("INFO", "Entering credentials");
|
|
229
|
+
await page.locator(SELECTORS.username).first().fill(this.config.username);
|
|
230
|
+
await page.locator(SELECTORS.password).first().fill(this.config.password);
|
|
231
|
+
await page.locator(SELECTORS.credentialsSubmit).first().click().catch(() => { });
|
|
232
|
+
}
|
|
233
|
+
codePromptUnavailable() {
|
|
234
|
+
return new UnsupportedAuthenticationError(`OneGate requires an authenticator code. Run \`${AUTH_COMMAND}\` in a terminal to enter it.`);
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Feed OneGate's code challenge from the terminal prompt. Unlike the
|
|
238
|
+
* other code-based flows, which validate once and bail, this re-prompts
|
|
239
|
+
* on a malformed entry — the caller supplying requestMfaCode may not
|
|
240
|
+
* already loop the way the CLI's own terminal prompt does.
|
|
241
|
+
*/
|
|
242
|
+
async submitCode(page) {
|
|
243
|
+
if (!this.config.requestMfaCode)
|
|
244
|
+
throw this.codePromptUnavailable();
|
|
245
|
+
let code;
|
|
246
|
+
for (let attempt = 1; attempt <= MAX_CODE_ATTEMPTS; attempt++) {
|
|
247
|
+
const typed = (await this.config.requestMfaCode()).replace(/\s/g, "");
|
|
248
|
+
if (/^\d{6,8}$/.test(typed)) {
|
|
249
|
+
code = typed;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
log("WARN", "That does not look like a 6-8 digit authenticator code.");
|
|
253
|
+
}
|
|
254
|
+
if (!code)
|
|
255
|
+
throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
|
|
256
|
+
// The same submit-handler timing applies to the code form.
|
|
257
|
+
await page.waitForLoadState("load");
|
|
258
|
+
// Re-check immediately before the code, for the same reason as the password.
|
|
259
|
+
this.assertOnOneGate(page, "entering the authenticator code");
|
|
260
|
+
await page.locator(SELECTORS.token).first().fill(code);
|
|
261
|
+
await page.locator(SELECTORS.tokenSubmit).first().click().catch(() => { });
|
|
262
|
+
log("INFO", "Authenticator code submitted");
|
|
263
|
+
}
|
|
264
|
+
/** Never type a secret into anything but OneGate itself. */
|
|
265
|
+
assertOnOneGate(page, action) {
|
|
266
|
+
if (!isOneGate(page.url())) {
|
|
267
|
+
throw new UnsupportedAuthenticationError(`Javeriana sign-in reached an unsupported identity-provider page while ${action}. No credentials were entered there.`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async isVisible(page, selector) {
|
|
271
|
+
return page.locator(selector).first().isVisible().catch(() => false);
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Editable rather than visible: the code field is on the page, visible and
|
|
275
|
+
* disabled, before the credentials step is submitted, so waiting for mere
|
|
276
|
+
* visibility would fill a field the page is still ignoring.
|
|
277
|
+
*/
|
|
278
|
+
async isEditable(page, selector) {
|
|
279
|
+
return page.locator(selector).first().isEditable().catch(() => false);
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Surface OneGate's status banner (wrong password, locked account, ...) in
|
|
283
|
+
* the logs if it appears. Deliberately non-blocking and advisory: the
|
|
284
|
+
* banner is also used for harmless notices, so it must not abort a login
|
|
285
|
+
* that is still progressing.
|
|
286
|
+
*/
|
|
287
|
+
reportStatusMessage(page) {
|
|
288
|
+
const banner = page.locator(SELECTORS.statusMessage).first();
|
|
289
|
+
banner
|
|
290
|
+
.isVisible()
|
|
291
|
+
.then(async (visible) => {
|
|
292
|
+
if (!visible)
|
|
293
|
+
return;
|
|
294
|
+
const text = (await banner.textContent().catch(() => null))?.trim();
|
|
295
|
+
if (text)
|
|
296
|
+
log("WARN", `OneGate reported: ${text}`);
|
|
297
|
+
})
|
|
298
|
+
.catch(() => {
|
|
299
|
+
// No banner, or the page navigated away first — both are expected.
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/** The login shell also exposes D2L.LP, so verify origin and home as well. */
|
|
303
|
+
async isAuthenticated(page) {
|
|
304
|
+
try {
|
|
305
|
+
const expected = new URL(this.config.baseUrl ?? `https://${JAVERIANA_BRIGHTSPACE_HOST}`);
|
|
306
|
+
const current = new URL(page.url());
|
|
307
|
+
if (current.origin !== expected.origin || !/^\/d2l\/home(?:\/|$)/.test(current.pathname))
|
|
308
|
+
return false;
|
|
309
|
+
const cookies = await page.context().cookies(expected.origin);
|
|
310
|
+
if (!cookies.some((cookie) => cookie.name === "d2lSessionVal" && Boolean(cookie.value)))
|
|
311
|
+
return false;
|
|
312
|
+
return await page.evaluate(() => {
|
|
313
|
+
const d2l = window.D2L;
|
|
314
|
+
return Boolean(d2l?.LP);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// Redirects can replace the execution context mid-check. Keep polling.
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -9,6 +9,7 @@ import { WesternSSOFlow, isWesternBrightspace } from "./western-sso.js";
|
|
|
9
9
|
import { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
|
|
10
10
|
import { CunySSOFlow, isCunyBrightspace } from "./cuny-sso.js";
|
|
11
11
|
import { LeidenSSOFlow, isLeidenBrightspace } from "./leiden-sso.js";
|
|
12
|
+
import { JaverianaSSOFlow, isJaverianaBrightspace } from "./javeriana-sso.js";
|
|
12
13
|
import { BrowserAuthError } from "../utils/errors.js";
|
|
13
14
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
14
15
|
export class UnsupportedAuthenticationError extends BrowserAuthError {
|
|
@@ -63,5 +64,8 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
|
|
|
63
64
|
if (isCunyBrightspace(config.baseUrl)) {
|
|
64
65
|
return new CunySSOFlow(credentials);
|
|
65
66
|
}
|
|
67
|
+
if (isJaverianaBrightspace(config.baseUrl)) {
|
|
68
|
+
return new JaverianaSSOFlow(credentials);
|
|
69
|
+
}
|
|
66
70
|
return new PurdueSSOFlow(credentials);
|
|
67
71
|
}
|
package/build/setup.js
CHANGED
|
@@ -66,6 +66,12 @@ export const SCHOOL_PRESETS = {
|
|
|
66
66
|
mfaNote: "Type the 6-digit code from your authenticator app when prompted.",
|
|
67
67
|
usernameHint: "Use your full CUNY Login address, e.g. firstname.lastname01@login.cuny.edu",
|
|
68
68
|
},
|
|
69
|
+
javeriana: {
|
|
70
|
+
name: "Pontificia Universidad Javeriana Cali",
|
|
71
|
+
baseUrl: "https://auladigital.javerianacali.edu.co",
|
|
72
|
+
usernameLabel: "Javeriana username",
|
|
73
|
+
mfaNote: "OneGate asks for a code from your authenticator app when you sign in.",
|
|
74
|
+
},
|
|
69
75
|
};
|
|
70
76
|
/**
|
|
71
77
|
* Pick the school preset named by `--purdue`, `--suny`, `--western`, `--cuny`, etc.
|
|
@@ -129,8 +129,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
129
129
|
// Skip hidden folders
|
|
130
130
|
if (folder.IsHidden)
|
|
131
131
|
continue;
|
|
132
|
-
// Fetch submissions for this folder
|
|
132
|
+
// Fetch submissions for this folder. Some tenants deny students access
|
|
133
|
+
// to mysubmissions outright (403), in which case an empty list would be
|
|
134
|
+
// indistinguishable from genuinely unsubmitted work — track whether the
|
|
135
|
+
// answer is actually known so the caller never reports denied-but-real
|
|
136
|
+
// work as missing.
|
|
137
|
+
// Adapted from JoshuaMontclair/brightspace-mcp-server (MIT).
|
|
133
138
|
let submissions = [];
|
|
139
|
+
let submissionsKnown = true;
|
|
134
140
|
try {
|
|
135
141
|
const submissionsRaw = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/submissions/mysubmissions/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
136
142
|
submissions = Array.isArray(submissionsRaw) ? submissionsRaw : submissionsRaw.Objects ?? [];
|
|
@@ -138,9 +144,10 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
138
144
|
catch (error) {
|
|
139
145
|
if (isAuthUnavailable(error))
|
|
140
146
|
throw error;
|
|
141
|
-
// 404
|
|
147
|
+
// 404 is how D2L reports "nothing submitted yet" - that one is known.
|
|
142
148
|
if (error?.status !== 404) {
|
|
143
|
-
|
|
149
|
+
submissionsKnown = false;
|
|
150
|
+
log("DEBUG", `Could not determine submission state for folder ${folder.Id}`, error);
|
|
144
151
|
}
|
|
145
152
|
}
|
|
146
153
|
// Fetch feedback independently of submissions
|
|
@@ -191,6 +198,19 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
191
198
|
})) ?? [],
|
|
192
199
|
})) ?? [],
|
|
193
200
|
})) ?? null,
|
|
201
|
+
// New, additive fields: existing "submission" stays null either way
|
|
202
|
+
// when nothing is known, but submissionStatus tells apart "nothing
|
|
203
|
+
// was submitted" (404, a known answer) from "the answer is denied"
|
|
204
|
+
// (any other error), so a caller never states real work as missing.
|
|
205
|
+
submissionStatus: !submissionsKnown
|
|
206
|
+
? "unknown"
|
|
207
|
+
: submissions.length > 0
|
|
208
|
+
? "submitted"
|
|
209
|
+
: "not_submitted",
|
|
210
|
+
...(submissionsKnown ? {} : {
|
|
211
|
+
submissionStatusNote: "Brightspace did not return this assignment's submission list, so whether it was submitted " +
|
|
212
|
+
"is unknown. Do not report it as missing or unsubmitted - tell the user to check Brightspace directly.",
|
|
213
|
+
}),
|
|
194
214
|
submission: submissions.length > 0
|
|
195
215
|
? {
|
|
196
216
|
submittedDate: submissions[0].SubmissionDate,
|
|
@@ -311,6 +331,16 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
311
331
|
// False when the tenant refused the attempts endpoint, in which case
|
|
312
332
|
// every count below is null rather than a guess of zero.
|
|
313
333
|
attemptsAvailable: completedAttempts !== null,
|
|
334
|
+
// New, additive field mirroring submissionStatus above: "known" is
|
|
335
|
+
// completedAttempts !== null (the same condition attemptsAvailable
|
|
336
|
+
// already tracks), named explicitly so a caller does not have to
|
|
337
|
+
// infer "unknown" from a boolean meant for something else.
|
|
338
|
+
// Adapted from JoshuaMontclair/brightspace-mcp-server (MIT).
|
|
339
|
+
attemptStatus: completedAttempts !== null ? "known" : "unknown",
|
|
340
|
+
...(completedAttempts !== null ? {} : {
|
|
341
|
+
attemptStatusNote: "Brightspace did not provide this quiz's attempt data, so it is unknown whether it was taken. " +
|
|
342
|
+
"Do not report it as not attempted.",
|
|
343
|
+
}),
|
|
314
344
|
attemptsUsed: completedAttempts?.length ?? null,
|
|
315
345
|
attemptsRemaining,
|
|
316
346
|
attemptWarning,
|
|
@@ -375,7 +405,8 @@ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
|
|
|
375
405
|
export function registerGetAssignments(server, apiClient, config) {
|
|
376
406
|
server.registerTool("get_assignments", {
|
|
377
407
|
title: "Get Assignments",
|
|
378
|
-
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics."
|
|
408
|
+
description: "Fetch assignments and quizzes for a specific course or all enrolled courses. Shows dropbox submissions and quizzes with due dates, status, and rubric info. Use this when the user asks about assignments, homework, what to submit, quizzes, or assignment details and rubrics. " +
|
|
409
|
+
"Read submissionStatus/attemptStatus, not just submission/attemptsUsed, to decide whether something was turned in: some tenants deny students access to submission or attempt data, in which case the status is \"unknown\" even though submission is null or attemptsUsed is 0. Never report an \"unknown\" item as missing, unsubmitted, or not attempted - say it could not be verified and point the user to Brightspace.",
|
|
379
410
|
inputSchema: GetAssignmentsSchema,
|
|
380
411
|
}, async (args) => {
|
|
381
412
|
try {
|
package/build/utils/config.js
CHANGED
|
@@ -7,15 +7,33 @@ import * as path from "node:path";
|
|
|
7
7
|
import * as os from "node:os";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import dotenv from "dotenv";
|
|
10
|
-
import { configStoreExists, loadConfigStore } from "./config-store.js";
|
|
10
|
+
import { configStoreExists, getConfigStorePath, loadConfigStore } from "./config-store.js";
|
|
11
11
|
import { resolveStoredPassword } from "./secure-config.js";
|
|
12
12
|
import { migrateLegacyState } from "../auth/legacy-state.js";
|
|
13
13
|
export async function loadConfig() {
|
|
14
14
|
dotenv.config({ quiet: true });
|
|
15
|
-
|
|
15
|
+
// A corrupt or permission-denied config.json must not take the whole server
|
|
16
|
+
// down at startup: env vars alone are a complete, if less convenient,
|
|
17
|
+
// configuration. The failure is still surfaced loudly (never silent) so a
|
|
18
|
+
// broken file doesn't masquerade as "no config.json was ever created".
|
|
19
|
+
// Idea from lmgveerhoek's fork (MIT).
|
|
20
|
+
let store = null;
|
|
21
|
+
let storeLoadError;
|
|
22
|
+
if (configStoreExists()) {
|
|
23
|
+
try {
|
|
24
|
+
store = loadConfigStore();
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
storeLoadError = error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
16
30
|
if (store) {
|
|
17
31
|
console.error("[config] Loaded base config from ~/.brightspace-mcp/config.json");
|
|
18
32
|
}
|
|
33
|
+
else if (storeLoadError) {
|
|
34
|
+
console.error(`[config] WARN: Failed to read ${getConfigStorePath()} (${storeLoadError instanceof Error ? storeLoadError.message : String(storeLoadError)}); ` +
|
|
35
|
+
"continuing with environment variables only.");
|
|
36
|
+
}
|
|
19
37
|
else {
|
|
20
38
|
console.error("[config] No config.json found, using environment variables");
|
|
21
39
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.16",
|
|
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",
|