brightspace-mcp-server 3.9.17 → 3.9.19

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
@@ -110,6 +110,7 @@ How often you're asked is up to your school, not this tool. Everything else abou
110
110
 
111
111
  ## Something not working?
112
112
 
113
+ - Run `npx -y brightspace-mcp-server@latest doctor` first — it checks your Node version, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix.
113
114
  - **It works in the terminal but not in the app:** the app starts it separately and may not see your password store yet — see [docs/troubleshooting.md](docs/troubleshooting.md).
114
115
  - **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
115
116
  - **Still stuck?** [Open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) and paste what the terminal printed.
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Brightspace MCP Server
4
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
5
+ * Licensed under MIT — see LICENSE file for details.
6
+ *
7
+ * `doctor` — a beginner-facing diagnostic. It runs a fixed, ordered list of
8
+ * checks (Node version, saved setup, credential store, school reachability,
9
+ * saved sign-in, one real API call, installed version) and prints one line
10
+ * per check: a checkmark plus a short description, or a cross plus exactly
11
+ * one plain-English next step.
12
+ *
13
+ * Every check after "setup found" depends on the ones before it, so a
14
+ * missing config or a broken sign-in is reported once, with everything
15
+ * downstream marked "skipped" instead of printing a cascade of confusing
16
+ * errors for the same root cause.
17
+ *
18
+ * Like `setup` and `auth`, this never prints a secret: no password, token,
19
+ * or cookie value appears in any line.
20
+ */
21
+ import { readFileSync } from "node:fs";
22
+ import * as path from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import * as os from "node:os";
25
+ import { configStoreExists, loadConfigStore, } from "./utils/config-store.js";
26
+ import { accountSessionDirectory, expandTilde, readEnvSecret, parseSessionCookieEnv } from "./utils/config.js";
27
+ import { getStoredPassword } from "./auth/credential-store.js";
28
+ import { discoverVersions } from "./api/version-discovery.js";
29
+ import { TokenManager } from "./auth/token-manager.js";
30
+ import { SessionStore } from "./auth/session-store.js";
31
+ import { D2LApiClient } from "./api/client.js";
32
+ import { fetchLatestVersion, isNewerVersion, safeVersionLabel, installKindOf, } from "./utils/update-checker.js";
33
+ import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, } from "./utils/commands.js";
34
+ const __filename = fileURLToPath(import.meta.url);
35
+ const __dirname = path.dirname(__filename);
36
+ function readInstalledVersion() {
37
+ try {
38
+ const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "..", "package.json"), "utf-8"));
39
+ return pkg.version ?? "0.0.0";
40
+ }
41
+ catch {
42
+ return "0.0.0";
43
+ }
44
+ }
45
+ /**
46
+ * doctor only ever reads a saved session — it must never write session.json,
47
+ * trash it, or take the write lock a v1-record migration uses (that lock can
48
+ * also throw AuthenticationInProgressError while a real sign-in holds it).
49
+ * SessionStore.peek() is the read-only sibling of load() that does none of
50
+ * that, so every write-shaped method here is a no-op and load() delegates to
51
+ * peek() instead of the real load(). Exported for tests, which pass a fake
52
+ * in place of a real SessionStore to prove the no-op wiring.
53
+ */
54
+ export function readOnlySessionStore(store) {
55
+ return {
56
+ load: () => store.peek(),
57
+ save: async () => { },
58
+ clear: async () => { },
59
+ saveIfCurrent: async () => true,
60
+ clearIfCurrent: async () => true,
61
+ };
62
+ }
63
+ /** A TokenManager that can mint from a saved cookie but can never write, clear, or migrate session.json. */
64
+ function readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie) {
65
+ return new TokenManager({
66
+ baseUrl,
67
+ envAccessToken,
68
+ envSessionCookie,
69
+ sessionStore: readOnlySessionStore(new SessionStore(sessionDir)),
70
+ });
71
+ }
72
+ function defaultDoctorDeps() {
73
+ return {
74
+ nodeVersion: process.version,
75
+ platform: process.platform,
76
+ env: process.env,
77
+ configStoreExists,
78
+ loadConfigStore,
79
+ getStoredPassword,
80
+ discoverVersions,
81
+ getSessionToken: (baseUrl, sessionDir, envAccessToken, envSessionCookie) => readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie).getToken(),
82
+ countCourses: async (baseUrl, sessionDir, envAccessToken, envSessionCookie) => {
83
+ const apiClient = new D2LApiClient({
84
+ baseUrl,
85
+ tokenManager: readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie),
86
+ });
87
+ const coursesPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3");
88
+ const response = await apiClient.get(coursesPath);
89
+ return { count: response?.Items?.length ?? 0, hasMore: Boolean(response?.PagingInfo?.HasMoreItems) };
90
+ },
91
+ fetchLatestVersion,
92
+ installKind: installKindOf(),
93
+ installedVersion: readInstalledVersion(),
94
+ };
95
+ }
96
+ function pass(id, message) {
97
+ return { id, ok: true, line: `✓ ${message}` };
98
+ }
99
+ function problem(id, message) {
100
+ return { id, ok: false, line: `✗ ${message}` };
101
+ }
102
+ // ── Individual checks ────────────────────────────────────────────────
103
+ function checkNode(deps) {
104
+ const major = Number(deps.nodeVersion.replace(/^v/, "").split(".")[0]);
105
+ if (Number.isFinite(major) && major >= 20) {
106
+ return pass("node", `Node.js ${deps.nodeVersion} (20 or newer)`);
107
+ }
108
+ return problem("node", `Node.js ${deps.nodeVersion} is too old — install Node 20 or newer from https://nodejs.org/, then try again.`);
109
+ }
110
+ /**
111
+ * Checks the resolved configuration — config.json AND the same env overrides
112
+ * loadConfig() honours (D2L_BASE_URL, D2L_USERNAME, D2L_ACCESS_TOKEN,
113
+ * D2L_SESSION_COOKIE) — since a Docker/headless setup legitimately has no
114
+ * config.json at all and is configured entirely through the environment.
115
+ * "Neither config.json nor the environment says anything" is the single most
116
+ * common first-run state for everyone else.
117
+ */
118
+ function checkConfig(deps) {
119
+ let store = null;
120
+ try {
121
+ if (deps.configStoreExists())
122
+ store = deps.loadConfigStore();
123
+ }
124
+ catch {
125
+ store = null;
126
+ }
127
+ let envAccessToken;
128
+ let envSessionCookie;
129
+ try {
130
+ envAccessToken = readEnvSecret(deps.env.D2L_ACCESS_TOKEN, "D2L_ACCESS_TOKEN");
131
+ const rawCookie = readEnvSecret(deps.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
132
+ envSessionCookie = rawCookie ? parseSessionCookieEnv(rawCookie) : undefined;
133
+ }
134
+ catch (error) {
135
+ return {
136
+ ok: false,
137
+ result: problem("config", error instanceof Error ? error.message : "D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE is malformed."),
138
+ };
139
+ }
140
+ const rawBaseUrl = deps.env.D2L_BASE_URL || store?.baseUrl;
141
+ const username = deps.env.D2L_USERNAME || store?.username;
142
+ const hasEnvCredential = Boolean(envAccessToken || envSessionCookie);
143
+ // A username is required only for the normal stored-password flow: an env
144
+ // credential signs in on its own and never needs one.
145
+ if (!rawBaseUrl || (!username && !hasEnvCredential)) {
146
+ return { ok: false, result: problem("config", `No setup found — run: ${SETUP_COMMAND}`) };
147
+ }
148
+ let baseUrl;
149
+ try {
150
+ baseUrl = new URL(rawBaseUrl).origin;
151
+ }
152
+ catch {
153
+ return { ok: false, result: problem("config", `The configured Brightspace URL is invalid — run: ${SETUP_COMMAND}`) };
154
+ }
155
+ const sessionRoot = deps.env.D2L_SESSION_DIR
156
+ ? expandTilde(deps.env.D2L_SESSION_DIR)
157
+ : store?.sessionDir
158
+ ? expandTilde(store.sessionDir)
159
+ : path.join(os.homedir(), ".d2l-session");
160
+ const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
161
+ const label = username ? `signed in as ${username} at ${baseUrl}` : `configured for ${baseUrl} via environment variables`;
162
+ return {
163
+ ok: true,
164
+ result: pass("config", `Setup found — ${label}`),
165
+ baseUrl,
166
+ username,
167
+ sessionDir,
168
+ envAccessToken,
169
+ envSessionCookie,
170
+ };
171
+ }
172
+ async function checkCredential(deps, baseUrl, username, envAccessToken, envSessionCookie) {
173
+ // Never print the value — only which variable is supplying it.
174
+ if (envAccessToken)
175
+ return pass("credential", "Signed in via the D2L_ACCESS_TOKEN environment variable (no password needed)");
176
+ if (envSessionCookie)
177
+ return pass("credential", "Signed in via the D2L_SESSION_COOKIE environment variable (no password needed)");
178
+ if (!username)
179
+ return problem("credential", `No username configured — run: ${SETUP_COMMAND}`);
180
+ try {
181
+ const password = await deps.getStoredPassword(baseUrl, username);
182
+ if (password)
183
+ return pass("credential", "Password saved in your operating system's credential store");
184
+ return problem("credential", `No saved password found for this account — run: ${SETUP_COMMAND}`);
185
+ }
186
+ catch (error) {
187
+ // On Linux the store itself (secret-tool / an unlocked Secret Service)
188
+ // is usually the problem, and the thrown error already explains that in
189
+ // plain English. Elsewhere, re-running setup is the one fix that covers
190
+ // every native-store failure a student could hit.
191
+ if (deps.platform === "linux") {
192
+ return problem("credential", error instanceof Error ? error.message : "The Linux credential store is locked or unavailable. Unlock your keyring and try again.");
193
+ }
194
+ return problem("credential", `Run setup again: ${SETUP_COMMAND}`);
195
+ }
196
+ }
197
+ async function checkNetwork(deps, baseUrl) {
198
+ try {
199
+ await deps.discoverVersions(baseUrl);
200
+ return pass("network", `${baseUrl} is reachable`);
201
+ }
202
+ catch {
203
+ return problem("network", `Could not reach ${baseUrl} — check your internet connection and the school address, then run doctor again.`);
204
+ }
205
+ }
206
+ async function checkSession(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
207
+ const failMessage = `No working saved sign-in — open your AI app and ask a question to sign in, or run: ${AUTH_COMMAND}`;
208
+ try {
209
+ const token = await deps.getSessionToken(baseUrl, sessionDir, envAccessToken, envSessionCookie);
210
+ if (token)
211
+ return { ok: true, result: pass("session", "Saved sign-in works — a token was issued without opening a browser") };
212
+ return { ok: false, result: problem("session", failMessage) };
213
+ }
214
+ catch {
215
+ return { ok: false, result: problem("session", failMessage) };
216
+ }
217
+ }
218
+ /**
219
+ * Trims an upstream error message down to something safe to show a beginner:
220
+ * a 403/404 body can be a whole HTML page, and either way the raw text can
221
+ * carry the endpoint URL. Collapses newlines, drops URLs, and caps length.
222
+ */
223
+ function sanitizeErrorDetail(message) {
224
+ const noUrls = message.replace(/https?:\/\/\S+/gi, "");
225
+ const singleLine = noUrls.replace(/\s+/g, " ").trim();
226
+ const LIMIT = 120;
227
+ return singleLine.length > LIMIT ? `${singleLine.slice(0, LIMIT).trimEnd()}…` : singleLine;
228
+ }
229
+ async function checkCourses(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
230
+ try {
231
+ const { count, hasMore } = await deps.countCourses(baseUrl, sessionDir, envAccessToken, envSessionCookie);
232
+ const label = hasMore ? `at least ${count}` : `${count}`;
233
+ return pass("courses", `Found ${label} course${count === 1 ? "" : "s"} on Brightspace`);
234
+ }
235
+ catch (error) {
236
+ const detail = error instanceof Error ? sanitizeErrorDetail(error.message) : "an unknown error";
237
+ return problem("courses", `Could not load your courses (${detail}) — run doctor again after checking your connection.`);
238
+ }
239
+ }
240
+ async function checkVersion(deps) {
241
+ let latest = null;
242
+ try {
243
+ latest = await deps.fetchLatestVersion();
244
+ }
245
+ catch {
246
+ latest = null;
247
+ }
248
+ const sourceNote = deps.installKind === "source-checkout" ? " (source checkout)" : "";
249
+ // Offline, or the registry hiccuped: never treat "couldn't check" as a
250
+ // failure — there is nothing actionable to tell a student here.
251
+ if (latest === null) {
252
+ return pass("version", `Running v${safeVersionLabel(deps.installedVersion)}${sourceNote} — could not check for a newer version (you may be offline)`);
253
+ }
254
+ if (!isNewerVersion(latest, deps.installedVersion)) {
255
+ return pass("version", `Running the latest version (v${safeVersionLabel(deps.installedVersion)})${sourceNote}`);
256
+ }
257
+ const from = safeVersionLabel(deps.installedVersion);
258
+ const to = safeVersionLabel(latest);
259
+ const nextStep = deps.installKind === "source-checkout"
260
+ ? "pull the latest changes and run npm run build"
261
+ : deps.installKind === "npm-install"
262
+ ? `run: ${GLOBAL_INSTALL_COMMAND}`
263
+ : `it updates automatically next time, or run: ${CLEAR_NPX_CACHE_COMMAND}`;
264
+ return problem("version", `A newer version is available (v${from} to v${to}) — ${nextStep}.`);
265
+ }
266
+ // ── Orchestration ────────────────────────────────────────────────────
267
+ /** Human-readable name for each check, used to prefix its "Skipped" line. */
268
+ const CHECK_NAMES = {
269
+ node: "Node.js version",
270
+ config: "Setup",
271
+ credential: "Credential store",
272
+ network: "Brightspace reachability",
273
+ session: "Saved sign-in",
274
+ courses: "Course list",
275
+ version: "Version",
276
+ };
277
+ const SKIPPED_SETUP = `finish setup first, then run doctor again: ${SETUP_COMMAND}`;
278
+ const SKIPPED_SESSION = "fix the saved sign-in step above first, then run doctor again.";
279
+ function skip(id, reason) {
280
+ return problem(id, `${CHECK_NAMES[id]}: Skipped — ${reason}`);
281
+ }
282
+ export async function runDoctorChecks(overrides = {}) {
283
+ const deps = { ...defaultDoctorDeps(), ...overrides };
284
+ const checks = [];
285
+ checks.push(checkNode(deps));
286
+ const config = checkConfig(deps);
287
+ checks.push(config.result);
288
+ if (!config.ok) {
289
+ checks.push(skip("credential", SKIPPED_SETUP));
290
+ checks.push(skip("network", SKIPPED_SETUP));
291
+ checks.push(skip("session", SKIPPED_SETUP));
292
+ checks.push(skip("courses", SKIPPED_SETUP));
293
+ }
294
+ else {
295
+ checks.push(await checkCredential(deps, config.baseUrl, config.username, config.envAccessToken, config.envSessionCookie));
296
+ checks.push(await checkNetwork(deps, config.baseUrl));
297
+ const session = await checkSession(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie);
298
+ checks.push(session.result);
299
+ if (session.ok) {
300
+ checks.push(await checkCourses(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie));
301
+ }
302
+ else {
303
+ checks.push(skip("courses", SKIPPED_SESSION));
304
+ }
305
+ }
306
+ checks.push(await checkVersion(deps));
307
+ return { checks, allOk: checks.every((c) => c.ok) };
308
+ }
309
+ // ── CLI entry point ──────────────────────────────────────────────────
310
+ async function main() {
311
+ const result = await runDoctorChecks();
312
+ console.log("");
313
+ console.log("Brightspace MCP Server — doctor");
314
+ console.log("");
315
+ for (const check of result.checks)
316
+ console.log(` ${check.line}`);
317
+ console.log("");
318
+ console.log(result.allOk
319
+ ? "Everything looks good — open your AI app and ask a question."
320
+ : "Fix the ✗ items above, then run doctor again.");
321
+ console.log("");
322
+ process.exitCode = result.allOk ? 0 : 1;
323
+ }
324
+ // VITEST is set only by the test runner, which imports this module for the
325
+ // functions above and must never run the real checks against this machine.
326
+ if (!process.env.VITEST) {
327
+ main().catch((err) => {
328
+ console.error("doctor failed:", err instanceof Error ? err.message : String(err));
329
+ process.exitCode = 1;
330
+ });
331
+ }
package/build/index.js CHANGED
@@ -37,6 +37,9 @@ if (subcommand === 'setup') {
37
37
  else if (subcommand === 'auth') {
38
38
  await import('./auth-cli.js');
39
39
  }
40
+ else if (subcommand === 'doctor') {
41
+ await import('./doctor.js');
42
+ }
40
43
  else {
41
44
  // ── MCP Server (default) ────────────────────────────────────────────
42
45
  // CRITICAL: Enable stdout guard IMMEDIATELY to prevent corruption of stdio transport
package/build/setup.js CHANGED
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
15
15
  import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils/config-store.js";
16
16
  import { saveSecureConfig } from "./utils/secure-config.js";
17
17
  import { writeFileAtomicSync } from "./utils/atomic-write.js";
18
- import { AUTH_COMMAND } from "./utils/commands.js";
18
+ import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
19
19
  import { cliMcpClients, configureCliMcpClient, isCliAvailable, } from "./utils/mcp-client-cli.js";
20
20
  // ANSI helpers
21
21
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
@@ -387,11 +387,12 @@ async function main() {
387
387
  console.log("");
388
388
  }
389
389
  else {
390
+ console.log(dim(" Your school's Brightspace address — the web address you open to see your courses."));
390
391
  while (!baseUrl) {
391
- const raw = await ask(rl, "What is your Brightspace URL? (e.g., purdue.brightspace.com): ");
392
+ const raw = await ask(rl, "What is your Brightspace address? (e.g., purdue.brightspace.com): ");
392
393
  const normalized = normalizeUrl(raw);
393
394
  if (!raw || !isValidUrl(normalized)) {
394
- console.log(yellow(" Please enter a valid URL (e.g., purdue.brightspace.com)"));
395
+ console.log(yellow(" Please enter a valid address, like purdue.brightspace.com"));
395
396
  continue;
396
397
  }
397
398
  baseUrl = normalized;
@@ -416,10 +417,13 @@ async function main() {
416
417
  // ── Step 2: Username ─────────────────────────────────────────────
417
418
  const usernamePrompt = preset
418
419
  ? `What is your ${preset.usernameLabel}? `
419
- : "What is your Brightspace username? ";
420
+ : "What username do you use to sign in to Brightspace? ";
420
421
  if (preset?.usernameHint) {
421
422
  console.log(dim(` ${preset.usernameHint}`));
422
423
  }
424
+ else {
425
+ console.log(dim(" The same username you type on your school's Brightspace sign-in page."));
426
+ }
423
427
  let username = "";
424
428
  while (!username) {
425
429
  username = await ask(rl, usernamePrompt);
@@ -431,6 +435,7 @@ async function main() {
431
435
  // ── Step 3: Password (hidden) ────────────────────────────────────
432
436
  // Close the rl temporarily since askPassword manages its own
433
437
  rl.close();
438
+ console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
434
439
  const passwordPrompt = preset
435
440
  ? `What is your ${preset.name} password? `
436
441
  : "What is your Brightspace password? ";
@@ -449,20 +454,21 @@ async function main() {
449
454
  });
450
455
  const configuredClients = [];
451
456
  // ── Step 4: MFA info ─────────────────────────────────────────────
457
+ console.log(dim(" Your school may ask you to approve the sign-in a second way — on your phone, with a code, or something else."));
452
458
  if (preset) {
453
- console.log(dim(` MFA: ${preset.mfaNote}`));
459
+ console.log(dim(` ${preset.mfaNote}`));
454
460
  }
455
461
  else {
456
- console.log(dim(" MFA: You will be prompted to approve the sign-in on your phone during auth."));
462
+ console.log(dim(" You'll be prompted to approve the sign-in on your phone during auth."));
457
463
  }
458
464
  console.log("");
459
465
  // Only two outcomes exist: a hidden browser or a visible one. Which kind of
460
466
  // MFA you have is detected at sign-in time, so offering "approve a prompt"
461
467
  // and "type a code" as separate choices would be a distinction the code does
462
468
  // not make, and picking between them would change nothing on disk.
463
- console.log(" How do you complete MFA?");
464
- console.log(" 1. On your phone, or by typing a code here (recommended)");
465
- console.log(" 2. In a visible browser window");
469
+ console.log(" When your school asks you to approve the sign-in, how will you do it?");
470
+ console.log(" 1. I approve on my phone, or I type a code from an authenticator app (recommended)");
471
+ console.log(" 2. Something else — open a browser window I can use");
466
472
  let savedHeadless;
467
473
  try {
468
474
  savedHeadless = configStoreExists() ? loadConfigStore().headless : undefined;
@@ -495,23 +501,23 @@ async function main() {
495
501
  console.log(green(" Config saved to: " + getConfigStorePath()));
496
502
  console.log("");
497
503
  // ── Step 6: Authenticate now? ────────────────────────────────────
498
- const authNow = await ask(rl2, "Would you like to authenticate now? (yes/no): ");
504
+ const authNow = await ask(rl2, "Sign in now, so everything is ready to go? (yes/no): ");
499
505
  if (/^y(es)?$/i.test(authNow)) {
500
506
  rl2.close();
501
507
  console.log("");
502
- console.log(dim(" Starting authentication..."));
508
+ console.log(dim(" Signing you in..."));
503
509
  console.log("");
504
510
  const ok = await runAuth();
505
511
  rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
506
512
  if (ok) {
507
- console.log(green("\n Authentication successful!"));
513
+ console.log(green("\n Signed in!"));
508
514
  }
509
515
  else {
510
- console.log(yellow(`\n Authentication failed. You can retry later with: ${AUTH_COMMAND}`));
516
+ console.log(yellow(`\n Sign-in didn't finish. You can try again later by running: ${AUTH_COMMAND}`));
511
517
  }
512
518
  }
513
519
  else {
514
- console.log(dim(` You can authenticate later by running: ${AUTH_COMMAND}`));
520
+ console.log(dim(` You can sign in later by running: ${AUTH_COMMAND}`));
515
521
  }
516
522
  console.log("");
517
523
  // ── Step 7: Claude Desktop auto-config ───────────────────────────
@@ -590,14 +596,16 @@ async function main() {
590
596
  console.log(" Next steps:");
591
597
  if (configuredClients.length > 0) {
592
598
  console.log(` 1. Restart ${configuredClients.join(", ")}`);
593
- console.log(" 2. Ask your AI client about your Brightspace courses");
599
+ console.log(` 2. Ask it: "What's due this week?"`);
594
600
  console.log(" Sign-in runs automatically if your saved session has expired.");
595
601
  }
596
602
  else {
597
603
  console.log(" 1. Register the MCP server in your AI client using the command in README.md");
598
- console.log(" 2. Restart your AI client and ask about your Brightspace courses");
604
+ console.log(` 2. Restart your AI client, then ask it: "What's due this week?"`);
599
605
  }
600
606
  console.log("");
607
+ console.log(dim(` If something doesn't work, run: ${DOCTOR_COMMAND}`));
608
+ console.log("");
601
609
  }
602
610
  // Both entry points — the `brightspace-setup` bin and `brightspace-mcp-server
603
611
  // setup`, which imports this module — start the wizard here. VITEST is set
@@ -130,6 +130,8 @@ export function registerGetAnnouncements(server, apiClient, config) {
130
130
  code: item.OrgUnit.Code,
131
131
  isActive: item.Access.IsActive,
132
132
  canAccess: item.Access.CanAccess,
133
+ startDate: item.Access.StartDate,
134
+ endDate: item.Access.EndDate,
133
135
  ...item,
134
136
  })), config.courseFilter);
135
137
  // Fetch announcements for each course (handle 403s gracefully)
@@ -457,6 +457,8 @@ export function registerGetAssignments(server, apiClient, config) {
457
457
  code: item.OrgUnit.Code,
458
458
  isActive: item.Access.IsActive,
459
459
  canAccess: item.Access.CanAccess,
460
+ startDate: item.Access.StartDate,
461
+ endDate: item.Access.EndDate,
460
462
  ...item,
461
463
  })), config.courseFilter);
462
464
  // Fetch assignments for each course (handle 403s gracefully)
@@ -42,6 +42,8 @@ export function registerGetMyCourses(server, apiClient, config) {
42
42
  role: item.Access.ClasslistRoleName,
43
43
  isActive: item.Access.IsActive,
44
44
  canAccess: item.Access.CanAccess,
45
+ startDate: item.Access.StartDate,
46
+ endDate: item.Access.EndDate,
45
47
  lastAccessed: item.Access.LastAccessed,
46
48
  })), { ...config.courseFilter, activeOnly });
47
49
  log("INFO", `get_my_courses: Retrieved ${courses.length} courses`);
@@ -77,6 +77,8 @@ export function registerGetMyGrades(server, apiClient, config) {
77
77
  code: item.OrgUnit.Code,
78
78
  isActive: item.Access.IsActive,
79
79
  canAccess: item.Access.CanAccess,
80
+ startDate: item.Access.StartDate,
81
+ endDate: item.Access.EndDate,
80
82
  ...item,
81
83
  })), config.courseFilter);
82
84
  // Fetch grades for each course (handle 403s gracefully)
@@ -41,6 +41,8 @@ export async function resolveCourses(apiClient, config, courseId) {
41
41
  code: item.OrgUnit.Code,
42
42
  isActive: item.Access.IsActive,
43
43
  canAccess: item.Access.CanAccess,
44
+ startDate: item.Access.StartDate,
45
+ endDate: item.Access.EndDate,
44
46
  })), config.courseFilter);
45
47
  return filtered.map((course) => ({ id: course.id, name: course.name }));
46
48
  }
@@ -19,6 +19,8 @@ export const PACKAGE_NAME = "brightspace-mcp-server";
19
19
  export const AUTH_COMMAND = `npx -y ${PACKAGE_NAME}@latest auth`;
20
20
  /** Re-run the setup wizard. Always runs the current published release. */
21
21
  export const SETUP_COMMAND = `npx -y ${PACKAGE_NAME}@latest setup`;
22
+ /** Diagnose a broken setup. Always runs the current published release. */
23
+ export const DOCTOR_COMMAND = `npx -y ${PACKAGE_NAME}@latest doctor`;
22
24
  /** Bring a global install up to date. The only fix for a stale `npm i -g` copy. */
23
25
  export const GLOBAL_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
24
26
  /** Drop cached npx copies so the next `@latest` genuinely refetches. */
@@ -67,6 +67,12 @@ export async function loadConfig() {
67
67
  const activeOnly = envBoolean(process.env.D2L_ACTIVE_ONLY, "D2L_ACTIVE_ONLY")
68
68
  ?? store?.activeOnly
69
69
  ?? true;
70
+ // Resolve currentOnly: env > store > default (false). When true, a course
71
+ // only passes the filter if Access.StartDate <= now <= Access.EndDate,
72
+ // matching Brightspace's "Current Courses" widget.
73
+ const currentOnly = envBoolean(process.env.D2L_CURRENT_ONLY, "D2L_CURRENT_ONLY")
74
+ ?? store?.currentOnly
75
+ ?? false;
70
76
  // D2L_ACCESS_TOKEN beats D2L_SESSION_COOKIE beats the normal stored-credential
71
77
  // browser flow; both are validated whenever present regardless of which one
72
78
  // wins, so a typo in the losing variable still fails loudly at startup.
@@ -100,6 +106,7 @@ export async function loadConfig() {
100
106
  includeCourseIds,
101
107
  excludeCourseIds,
102
108
  activeOnly,
109
+ currentOnly,
103
110
  },
104
111
  };
105
112
  }
@@ -211,7 +218,8 @@ export function parseSessionCookieEnv(raw) {
211
218
  }
212
219
  throw new Error(SESSION_COOKIE_FORMAT_ERROR);
213
220
  }
214
- function expandTilde(filePath) {
221
+ /** Exported for the `doctor` CLI, which resolves the same session directory without loading the full app config. */
222
+ export function expandTilde(filePath) {
215
223
  if (filePath.startsWith("~")) {
216
224
  return path.join(os.homedir(), filePath.slice(1));
217
225
  }
@@ -4,18 +4,49 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { log } from "./logger.js";
7
+ /**
8
+ * True if `now` falls within [startDate, endDate].
9
+ *
10
+ * When both dates are null the course has no enrollment window at all — it's
11
+ * almost certainly an informational org unit (e.g. "Waterloo Campus",
12
+ * "Academic Integrity") rather than a term-bound course, so we return false.
13
+ * A single null side is treated as open-ended (start-only means "not yet
14
+ * ended", end-only means "already started"). A malformed date string is
15
+ * likewise treated as open-ended on that side rather than excluding the
16
+ * course outright.
17
+ *
18
+ * Adapted from LunaParker/brightspace-mcp-server (MIT), src/utils/course-filter.ts.
19
+ */
20
+ function isCurrentlyInWindow(startDate, endDate, now) {
21
+ // Both null → undated org unit, not a current semester course.
22
+ if (!startDate && !endDate)
23
+ return false;
24
+ if (startDate) {
25
+ const start = Date.parse(startDate);
26
+ if (!Number.isNaN(start) && start > now)
27
+ return false;
28
+ }
29
+ if (endDate) {
30
+ const end = Date.parse(endDate);
31
+ if (!Number.isNaN(end) && end < now)
32
+ return false;
33
+ }
34
+ return true;
35
+ }
7
36
  /**
8
37
  * Apply course filtering based on environment variable configuration.
9
38
  *
10
39
  * Filter priority:
11
40
  * 1. activeOnly — exclude inactive courses (default: true)
12
- * 2. includeCourseIds — whitelist (only these courses)
13
- * 3. excludeCourseIds — blacklist (remove these courses)
41
+ * 2. currentOnly — exclude courses outside their Access.StartDate/EndDate
42
+ * window, matching Brightspace's "Current Courses" widget (default: false)
43
+ * 3. includeCourseIds — whitelist (only these courses)
44
+ * 4. excludeCourseIds — blacklist (remove these courses)
14
45
  *
15
46
  * Tool-level courseId params bypass this filter entirely —
16
47
  * if user explicitly requests courseId=X, honor it regardless of config.
17
48
  */
18
- export function applyCourseFilter(courses, config) {
49
+ export function applyCourseFilter(courses, config, now = Date.now()) {
19
50
  let filtered = courses;
20
51
  const originalCount = courses.length;
21
52
  if (config.activeOnly) {
@@ -26,6 +57,9 @@ export function applyCourseFilter(courses, config) {
26
57
  // removes about 70 percent of the requests an all-courses call would make.
27
58
  filtered = filtered.filter(c => c.canAccess !== false);
28
59
  }
60
+ if (config.currentOnly) {
61
+ filtered = filtered.filter(c => isCurrentlyInWindow(c.startDate, c.endDate, now));
62
+ }
29
63
  if (config.includeCourseIds && config.includeCourseIds.length > 0) {
30
64
  filtered = filtered.filter(c => config.includeCourseIds.includes(c.id));
31
65
  }
@@ -35,6 +69,7 @@ export function applyCourseFilter(courses, config) {
35
69
  if (filtered.length !== originalCount) {
36
70
  log("DEBUG", `Course filter: ${originalCount} -> ${filtered.length} courses`, {
37
71
  activeOnly: config.activeOnly,
72
+ currentOnly: config.currentOnly,
38
73
  include: config.includeCourseIds,
39
74
  exclude: config.excludeCourseIds,
40
75
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.17",
3
+ "version": "3.9.19",
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",