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 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 --leiden at Leiden).
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).
@@ -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
+ }
@@ -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 means no submissions yet - that's fine
147
+ // 404 is how D2L reports "nothing submitted yet" - that one is known.
142
148
  if (error?.status !== 404) {
143
- log("DEBUG", `Failed to fetch submissions for folder ${folder.Id}`, error);
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 {
@@ -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
- const store = configStoreExists() ? loadConfigStore() : null;
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.14",
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",