brightspace-mcp-server 3.3.0 → 3.4.1

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
@@ -117,6 +117,7 @@ npx -y brightspace-mcp-server@latest auth
117
117
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
118
118
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
119
119
  | Video transcripts | "What did the professor say about pinch-off in Tuesday's lecture recording?" · "Summarize last week's BoilerCast video" — works for Kaltura and YouTube embeds; other platforms report that they aren't supported yet |
120
+ | Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, and whether a credential is stored, without contacting Brightspace or revealing secrets |
120
121
  | Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, and graded discussion topics (any topic with a due date) |
121
122
 
122
123
 
@@ -22,12 +22,25 @@ export class TTLCache {
22
22
  timerId.unref();
23
23
  }
24
24
  // Store entry
25
- this.cache.set(key, { data: value, timerId });
25
+ this.cache.set(key, { data: value, timerId, storedAt: Date.now() });
26
26
  }
27
27
  get(key) {
28
28
  const entry = this.cache.get(key);
29
29
  return entry?.data;
30
30
  }
31
+ /**
32
+ * How long ago this key was written, in milliseconds, or undefined when it
33
+ * is not cached.
34
+ *
35
+ * One key can be written under several TTLs: two tools ask for the same
36
+ * path with different freshness requirements. An entry another caller kept
37
+ * alive for an hour is still too old for a caller that asked for ten
38
+ * minutes, and only its age can say so.
39
+ */
40
+ ageOf(key) {
41
+ const entry = this.cache.get(key);
42
+ return entry ? Date.now() - entry.storedAt : undefined;
43
+ }
31
44
  has(key) {
32
45
  return this.cache.has(key);
33
46
  }
@@ -183,9 +183,18 @@ export class D2LApiClient {
183
183
  async get(path, options) {
184
184
  // Checked before the path is resolved, and keyed by the path as the caller
185
185
  // wrote it, so a cached read needs neither version discovery nor a token.
186
- if (options?.ttl && this.cache.has(path)) {
187
- log("DEBUG", `Cache hit: ${path}`);
188
- return this.cache.get(path);
186
+ //
187
+ // The entry has to be younger than this caller's own TTL, not merely
188
+ // still alive. One path can be written under two TTLs: the discussion
189
+ // forum list is thirty minutes of course content to get_discussions and
190
+ // ten minutes of due dates to get_upcoming_due_dates. Trusting the
191
+ // surviving entry served the longer caller's staleness to the shorter one.
192
+ if (options?.ttl) {
193
+ const age = this.cache.ageOf(path);
194
+ if (age !== undefined && age <= options.ttl) {
195
+ log("DEBUG", `Cache hit: ${path}`);
196
+ return this.cache.get(path);
197
+ }
189
198
  }
190
199
  const resolved = await this.resolvePath(path);
191
200
  const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
@@ -25,21 +25,30 @@ export class TokenBucket {
25
25
  this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
26
26
  this.lastRefill = now;
27
27
  }
28
+ /**
29
+ * Take `count` tokens, waiting for them if the bucket is short.
30
+ *
31
+ * The tokens are deducted before the wait rather than after it, so
32
+ * concurrent callers queue behind one another. Deducting afterwards let
33
+ * every caller read the same empty bucket, wait for the same single token,
34
+ * and then wake together and take it: a fan-out across courses drained the
35
+ * burst and then admitted the whole remainder one refill later, which is the
36
+ * 429 storm this limiter exists to prevent. It also left the balance deeply
37
+ * negative, so the next unrelated request paid the whole debt in one wait.
38
+ *
39
+ * A negative balance is that debt, owed by the waiters already queued, and
40
+ * refill() pays it down at the refill rate.
41
+ */
28
42
  async consume(count = 1) {
29
43
  this.refill();
30
- if (this.tokens >= count) {
31
- // Enough tokens available - consume immediately
32
- this.tokens -= count;
44
+ const shortfall = count - this.tokens;
45
+ this.tokens -= count;
46
+ if (shortfall <= 0) {
47
+ // Enough tokens were already banked - proceed immediately.
33
48
  return;
34
49
  }
35
- // Not enough tokens - calculate wait time
36
- const tokensNeeded = count - this.tokens;
37
- const waitTimeMs = (tokensNeeded / this.refillRate) * 1000;
38
- // Wait for tokens to refill
50
+ const waitTimeMs = (shortfall / this.refillRate) * 1000;
39
51
  await new Promise((resolve) => setTimeout(resolve, waitTimeMs));
40
- // Refill and consume
41
- this.refill();
42
- this.tokens -= count;
43
52
  }
44
53
  tryConsume(count = 1) {
45
54
  this.refill();
@@ -49,8 +58,10 @@ export class TokenBucket {
49
58
  }
50
59
  return false;
51
60
  }
61
+ /** Tokens free to take right now. Never negative: an outstanding
62
+ * reservation is a debt, not a negative supply. */
52
63
  get availableTokens() {
53
64
  this.refill();
54
- return this.tokens;
65
+ return Math.max(0, this.tokens);
55
66
  }
56
67
  }
@@ -24,6 +24,20 @@ export class AuthenticationCooldownError extends Error {
24
24
  this.name = "AuthenticationCooldownError";
25
25
  }
26
26
  }
27
+ /** The recorded retry time, or undefined when the file says nothing usable. */
28
+ function readRetryAt(content) {
29
+ let status;
30
+ try {
31
+ status = JSON.parse(content);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ if (typeof status !== "object" || status === null || Array.isArray(status))
37
+ return undefined;
38
+ const retryAt = status.retryAt;
39
+ return typeof retryAt === "number" && Number.isFinite(retryAt) ? retryAt : undefined;
40
+ }
27
41
  /** Non-secret retry metadata. Call only while holding the authentication lock. */
28
42
  export class AuthCooldown {
29
43
  file;
@@ -40,9 +54,19 @@ export class AuthCooldown {
40
54
  return;
41
55
  throw error;
42
56
  }
43
- const status = JSON.parse(content);
44
- if (typeof status.retryAt === "number" && status.retryAt > Date.now())
45
- throw new AuthenticationCooldownError(status.retryAt);
57
+ const retryAt = readRetryAt(content);
58
+ if (retryAt === undefined) {
59
+ // This file is non-secret retry metadata, and only a readable retryAt is
60
+ // evidence of anything. A truncated or non-object file used to throw out
61
+ // of here, which runs before the sign-in attempt — and the automatic path
62
+ // only clears the file after a sign-in that then never started. One
63
+ // damaged file disabled background sign-in for good, with nothing on
64
+ // screen naming the cause. Discard it and let this attempt proceed.
65
+ await fs.unlink(this.file).catch(() => { });
66
+ return;
67
+ }
68
+ if (retryAt > Date.now())
69
+ throw new AuthenticationCooldownError(retryAt);
46
70
  }
47
71
  async recordMfaFailure() {
48
72
  await this.write({ retryAt: Date.now() + MFA_COOLDOWN_MS });
@@ -178,11 +178,24 @@ function decodeKey(value) {
178
178
  }
179
179
  return Buffer.from(value, "hex");
180
180
  }
181
+ function sessionKeyAccount(canonicalDir) {
182
+ return `session-key:${createHash("sha256").update(canonicalDir).digest("hex")}`;
183
+ }
184
+ /**
185
+ * Whether this session directory already holds a native encryption key, which
186
+ * is what separates an upgrade that has not run yet from one that finished.
187
+ * A locked or unavailable store raises instead of answering false, so a
188
+ * caller never mistakes "cannot tell" for "not migrated".
189
+ */
190
+ export async function hasSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend) {
191
+ const canonicalDir = await fs.realpath(sessionDir);
192
+ return (await backend.getPassword(SERVICE, sessionKeyAccount(canonicalDir))) !== null;
193
+ }
181
194
  /** The canonical session directory stays stable when DHCP changes the hostname. */
182
195
  export async function getSessionEncryptionKey(sessionDir, backend = nativeCredentialBackend, create = true) {
183
196
  await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
184
197
  const canonicalDir = await fs.realpath(sessionDir);
185
- const account = `session-key:${createHash("sha256").update(canonicalDir).digest("hex")}`;
198
+ const account = sessionKeyAccount(canonicalDir);
186
199
  const existing = await backend.getPassword(SERVICE, account);
187
200
  if (existing !== null) {
188
201
  try {
@@ -1,6 +1,42 @@
1
1
  import { log } from "../utils/logger.js";
2
2
  import { AUTH_COMMAND } from "../utils/commands.js";
3
3
  import { UnsupportedAuthenticationError } from "./sso-flow.js";
4
+ /** Duo's verified-push digits: three to six, per Duo's push-verification policy. */
5
+ const VERIFICATION_CODE_PATTERN = /^\d{3,6}$/;
6
+ /**
7
+ * Duo renders the verified-push digits in an element of its own. Read that
8
+ * element rather than the document at large: an unscoped
9
+ * page.getByText(/^\d{3,6}$/) matches ANY standalone three-to-six digit text on
10
+ * the page — a masked phone number's last four, a countdown, a "remembered for
11
+ * 30 days" line — and .first() then announces whichever one the DOM happened to
12
+ * render first. A headless run has no screen to check that against, so the
13
+ * wrong number is simply typed into Duo Mobile and the push is denied.
14
+ */
15
+ const VERIFICATION_CODE_SELECTORS = [
16
+ ".verification-code",
17
+ "#verification-code",
18
+ "[class*='verification-code']",
19
+ ];
20
+ /**
21
+ * Duo's prompt surface, innermost first, bounding the fallback text scan for
22
+ * the day Duo renames the element above. The first one on screen decides:
23
+ * numbers rendered outside Duo's own prompt are not the code.
24
+ */
25
+ const PROMPT_SCOPE_SELECTORS = [
26
+ "#auth-view",
27
+ ".base-wrapper",
28
+ "#root",
29
+ "#app",
30
+ "main",
31
+ "body",
32
+ ];
33
+ /** The digits on a visible element, or null when it is absent or not digits. */
34
+ async function readCodeFrom(target) {
35
+ if (!await target.isVisible().catch(() => false))
36
+ return null;
37
+ const code = (await target.textContent().catch(() => null))?.trim();
38
+ return code && VERIFICATION_CODE_PATTERN.test(code) ? code : null;
39
+ }
4
40
  /** Duo Universal Prompt redirects to a Duo-hosted page after primary sign-in. */
5
41
  export function isDuoPrompt(page) {
6
42
  try {
@@ -40,11 +76,43 @@ export class DuoMfaHandler {
40
76
  return true;
41
77
  }
42
78
  async readVerificationCode(page) {
43
- const target = page.getByText(/^\d{3,6}$/).first();
44
- if (!await target.isVisible().catch(() => false))
45
- return null;
46
- const code = (await target.textContent().catch(() => null))?.trim();
47
- return code && /^\d{3,6}$/.test(code) ? code : null;
79
+ return await this.readCodeElement(page) ?? await this.readScopedCode(page);
80
+ }
81
+ /** Duo's own verification-code element, when the prompt exposes one. */
82
+ async readCodeElement(page) {
83
+ for (const selector of VERIFICATION_CODE_SELECTORS) {
84
+ const code = await readCodeFrom(page.locator(selector).first());
85
+ if (code)
86
+ return code;
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Fallback for a prompt that does not label its digits: the standalone
92
+ * numbers inside Duo's prompt container. Announce one only when they all
93
+ * agree — two different numbers mean this is not a screen this can read, and
94
+ * naming the wrong one costs the user the push.
95
+ */
96
+ async readScopedCode(page) {
97
+ for (const selector of PROMPT_SCOPE_SELECTORS) {
98
+ const scope = page.locator(selector).first();
99
+ if (!await scope.isVisible().catch(() => false))
100
+ continue;
101
+ const found = [];
102
+ const candidates = await scope.getByText(VERIFICATION_CODE_PATTERN).all().catch(() => []);
103
+ for (const candidate of candidates) {
104
+ // Nested elements repeat the same text; only distinct values compete.
105
+ const code = await readCodeFrom(candidate);
106
+ if (code && !found.includes(code))
107
+ found.push(code);
108
+ }
109
+ if (found.length > 1) {
110
+ log("DEBUG", `Duo prompt showed ${found.length} standalone numbers; announcing none of them.`);
111
+ return null;
112
+ }
113
+ return found[0] ?? null;
114
+ }
115
+ return null;
48
116
  }
49
117
  async submitPasscode(page) {
50
118
  if (this.options.headless === false || this.passcodeSubmitted)
@@ -59,6 +127,13 @@ export class DuoMfaHandler {
59
127
  const code = await this.options.requestMfaCode();
60
128
  if (!/^\d{6,8}$/.test(code))
61
129
  throw new UnsupportedAuthenticationError("The MFA code must contain 6-8 digits.");
130
+ // That prompt blocks on a person for as long as they take to find the
131
+ // code, and Duo expires its prompt and redirects on its own schedule. A
132
+ // passcode is a credential: confirm it is still Duo's page receiving it
133
+ // rather than whatever the browser moved on to.
134
+ if (!this.isChallenge(page)) {
135
+ throw new UnsupportedAuthenticationError(`The Duo prompt closed before the passcode was entered. Run \`${AUTH_COMMAND}\` to retry.`);
136
+ }
62
137
  await input.fill(code);
63
138
  const verify = page.getByRole("button", { name: /verify/i }).first();
64
139
  if (await verify.isVisible().catch(() => false))
@@ -113,7 +113,16 @@ export class PurdueSSOFlow {
113
113
  if (!this.config.password)
114
114
  throw new BrowserAuthError("Password is required for SSO login", "credentials");
115
115
  log("INFO", "Entering credentials");
116
- if (!this.accountHintSubmitted) {
116
+ // A submitted account hint only counts once Microsoft has actually left the
117
+ // email step. It keeps that field on screen whenever it rejects the hint,
118
+ // and clickWhenReady swallows a click that never landed on purpose (Entra
119
+ // normally detaches the button after navigating), so identifyAccount can
120
+ // report a success the page never granted. Skipping the email step there
121
+ // spends the whole password timeout on a page still asking for a username
122
+ // and then blames a missing password field.
123
+ const hintAccepted = this.accountHintSubmitted && !await this.anyVisible(page, EMAIL_SELECTORS);
124
+ this.accountHintSubmitted = false;
125
+ if (!hintAccepted) {
117
126
  const email = signInName(this.config.username, this.config.baseUrl);
118
127
  if (!await this.fillWhenReady(page, EMAIL_SELECTORS, email)) {
119
128
  throw new UnsupportedAuthenticationError("The Microsoft email field did not appear. Automatic sign-in cannot continue.");
@@ -122,7 +131,6 @@ export class PurdueSSOFlow {
122
131
  throw new UnsupportedAuthenticationError("The Microsoft email submit button did not appear. Automatic sign-in cannot continue.");
123
132
  }
124
133
  }
125
- this.accountHintSubmitted = false;
126
134
  if (!await this.fillWhenReady(page, PASSWORD_SELECTORS, this.config.password)) {
127
135
  throw new UnsupportedAuthenticationError("The Microsoft password field did not appear. Automatic sign-in cannot continue.");
128
136
  }
@@ -9,7 +9,7 @@ import * as path from "node:path";
9
9
  import * as os from "node:os";
10
10
  import { SessionStoreError } from "../utils/errors.js";
11
11
  import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
12
- import { NativeCredentialStoreError } from "./credential-store.js";
12
+ import { hasSessionEncryptionKey, NativeCredentialStoreError } from "./credential-store.js";
13
13
  import { decrypt, readEncryptedRecord, saveEncryptedRecord, trashFile } from "./encrypted-store.js";
14
14
  const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".d2l-session");
15
15
  function validTenantOrigin(origin) {
@@ -107,10 +107,27 @@ export class SessionStore {
107
107
  throw new Error("Invalid saved session contents.");
108
108
  return token;
109
109
  }
110
+ /**
111
+ * A version 1 record is sealed with scrypt over the local account name and
112
+ * an on-disk salt: material every local process already has, so the record
113
+ * proves nothing about who wrote it. It is worth trusting only during the
114
+ * one-time upgrade, which by definition happens before this directory owns
115
+ * a native key. Once the key exists the upgrade has already run, and a
116
+ * version 1 file can only have been planted by something that could write
117
+ * the session directory but could not reach the credential store. Refuse it
118
+ * rather than let it replace the authenticated session.
119
+ */
120
+ async assertLegacyUpgradePending() {
121
+ if (!await hasSessionEncryptionKey(this.sessionDir, this.options.backend))
122
+ return;
123
+ throw new SessionStoreError("An old unauthenticated session file appeared after this installation was already using native key storage. It was not trusted and was left in place. Sign in again to replace it.");
124
+ }
110
125
  async loadUnlocked() {
111
126
  const record = await this.readFile();
112
127
  if (!record)
113
128
  return null;
129
+ if (record.version === 1)
130
+ await this.assertLegacyUpgradePending();
114
131
  const token = await this.decode(record);
115
132
  if (record.version === 1)
116
133
  await this.saveUnlocked(token);
@@ -122,6 +139,10 @@ export class SessionStore {
122
139
  await saveEncryptedRecord(this.sessionDir, this.sessionFilePath, "session", token, this.options);
123
140
  }
124
141
  storeError(action, error) {
142
+ // A store error already carries the specific reason and the way out of it;
143
+ // wrapping it again would bury both behind the generic sentence below.
144
+ if (error instanceof SessionStoreError)
145
+ throw error;
125
146
  if (error instanceof NativeCredentialStoreError || error instanceof AuthenticationInProgressError)
126
147
  throw error;
127
148
  throw new SessionStoreError(`Failed to ${action} session. Existing session data was preserved.`, error instanceof Error ? error : new Error(String(error)));
@@ -6,6 +6,7 @@
6
6
  import { PurdueSSOFlow } from "./purdue-sso.js";
7
7
  import { log } from "../utils/logger.js";
8
8
  import { UnsupportedAuthenticationError } from "./sso-flow.js";
9
+ import { BrowserAuthError } from "../utils/errors.js";
9
10
  /** SUNY campuses share one Brightspace tenant behind one Shibboleth IdP. */
10
11
  const SUNY_BRIGHTSPACE_HOST = "mylearning.suny.edu";
11
12
  const SUNY_IDP_ENTITY_ID = "https://idm.suny.edu/shibboleth/idp/";
@@ -73,6 +74,12 @@ export class SunySSOFlow {
73
74
  await this.selectCampus(page);
74
75
  }
75
76
  catch (error) {
77
+ // selectCampus already names the cause and, for a mismatch, lists the
78
+ // campuses SUNY itself is offering. Re-wrapping those threw away the
79
+ // only list a user can act on, so pass an already-typed failure through
80
+ // and describe only the ones that arrive untyped.
81
+ if (error instanceof BrowserAuthError)
82
+ throw error;
76
83
  throw new UnsupportedAuthenticationError("SUNY campus selection could not complete automatically. Run brightspace-mcp-server setup --suny and select a campus.", error);
77
84
  }
78
85
  return this.defaultFlow.login(page);
package/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
16
16
  import { readFileSync } from "node:fs";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { dirname, resolve } from "node:path";
19
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, } from "./tools/index.js";
19
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
  const PKG_VERSION = (() => {
@@ -113,11 +113,12 @@ else {
113
113
  registerGetSyllabus(server, apiClient);
114
114
  registerGetDiscussions(server, apiClient);
115
115
  registerGetVideoTranscript(server, apiClient);
116
- log("DEBUG", "MCP tools registered (13 tools)");
116
+ registerGetServerInfo(server, config, PKG_VERSION);
117
+ log("DEBUG", "MCP tools registered (14 tools)");
117
118
  // Connect stdio transport
118
119
  const transport = new StdioServerTransport();
119
120
  await server.connect(transport);
120
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (13 tools registered)");
121
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (14 tools registered)");
121
122
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
122
123
  }
123
124
  catch (error) {
package/build/setup.js CHANGED
@@ -14,6 +14,7 @@ import { spawn } from "node:child_process";
14
14
  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
+ import { writeFileAtomicSync } from "./utils/atomic-write.js";
17
18
  import { AUTH_COMMAND } from "./utils/commands.js";
18
19
  import { cliMcpClients, configureCliMcpClient, isCliAvailable, } from "./utils/mcp-client-cli.js";
19
20
  // ANSI helpers
@@ -22,7 +23,7 @@ const green = (s) => `\x1b[32m${s}\x1b[0m`;
22
23
  const dim = (s) => `\x1b[2m${s}\x1b[0m`;
23
24
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
24
25
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
25
- const SCHOOL_PRESETS = {
26
+ export const SCHOOL_PRESETS = {
26
27
  purdue: {
27
28
  name: "Purdue University",
28
29
  baseUrl: "https://purdue.brightspace.com",
@@ -45,9 +46,20 @@ const SCHOOL_PRESETS = {
45
46
  usernameHint: "Use your full sign-in address if your Western account requires it.",
46
47
  },
47
48
  };
48
- // Parse --purdue, --osu, etc. from argv
49
- const schoolFlag = process.argv.find((a) => a.startsWith("--"))?.replace(/^--/, "").toLowerCase();
50
- const preset = schoolFlag ? SCHOOL_PRESETS[schoolFlag] : undefined;
49
+ /**
50
+ * Pick the school preset named by `--purdue`, `--suny`, `--western`, etc.
51
+ *
52
+ * Own properties only: a bare index would make `--constructor` or
53
+ * `--__proto__` resolve to something off `Object.prototype` and hand the
54
+ * wizard an object with no `baseUrl`.
55
+ */
56
+ export function presetForArgv(argv = process.argv) {
57
+ const flag = argv.find((a) => a.startsWith("--"))?.replace(/^--/, "").toLowerCase();
58
+ if (!flag || !Object.prototype.hasOwnProperty.call(SCHOOL_PRESETS, flag))
59
+ return undefined;
60
+ return SCHOOL_PRESETS[flag];
61
+ }
62
+ const preset = presetForArgv();
51
63
  // ── Readline helpers ───────────────────────────────────────────────
52
64
  function ask(rl, question) {
53
65
  return new Promise((resolve) => {
@@ -174,27 +186,45 @@ function isChatGPTInstalled() {
174
186
  function getCursorConfigPath() {
175
187
  return path.join(os.homedir(), ".cursor", "mcp.json");
176
188
  }
177
- function configureMcpClient(configPath) {
178
- let config = { mcpServers: {} };
189
+ /**
190
+ * A JSON value we can safely merge a server entry into. An array passes
191
+ * `typeof x === "object"` but drops every added key when it is stringified
192
+ * again, so it has to be rejected alongside `null`.
193
+ */
194
+ function isJsonObject(value) {
195
+ return typeof value === "object" && value !== null && !Array.isArray(value);
196
+ }
197
+ export function configureMcpClient(configPath) {
198
+ let config = {};
179
199
  // Read existing config if present
180
200
  if (fs.existsSync(configPath)) {
201
+ let parsed;
202
+ let readable = true;
181
203
  try {
182
- const raw = fs.readFileSync(configPath, "utf-8");
183
- config = JSON.parse(raw);
204
+ parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
184
205
  }
185
206
  catch {
186
- // If we can't parse, start fresh but warn
187
- console.log(yellow(" Warning: existing config was invalid, creating new one."));
188
- config = { mcpServers: {} };
207
+ readable = false;
208
+ }
209
+ if (isJsonObject(parsed)) {
210
+ config = parsed;
211
+ }
212
+ else {
213
+ // Unparseable, or valid JSON that is not an object (null, an array, a
214
+ // bare string). Merging into it would either throw or silently discard
215
+ // the entry we just reported as written, so start fresh and say so.
216
+ console.log(yellow(` Warning: existing config was ${readable ? "not a JSON object" : "invalid"}, creating new one.`));
217
+ config = {};
189
218
  }
190
219
  }
191
- if (!config.mcpServers) {
192
- config.mcpServers = {};
193
- }
220
+ // Same reasoning for the servers map itself, which is hand-edited far more
221
+ // often than the file around it.
222
+ const servers = isJsonObject(config.mcpServers) ? config.mcpServers : {};
223
+ config.mcpServers = servers;
194
224
  // Add/update brightspace entry
195
225
  // On Windows, npx is a .cmd shim that must be invoked through cmd.exe
196
226
  const isWindows = process.platform === "win32";
197
- config.mcpServers["brightspace"] = isWindows
227
+ servers["brightspace"] = isWindows
198
228
  ? {
199
229
  command: "cmd",
200
230
  args: ["/c", "npx", "-y", "brightspace-mcp-server@latest"],
@@ -208,9 +238,73 @@ function configureMcpClient(configPath) {
208
238
  if (!fs.existsSync(dir)) {
209
239
  fs.mkdirSync(dir, { recursive: true });
210
240
  }
211
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
241
+ // This file holds every MCP server the user has configured, not just ours.
242
+ // A plain write truncates it first, so a write that fails part way through
243
+ // (a full disk, an I/O error) would leave the user with no MCP servers at
244
+ // all. Staging and renaming leaves either the old file or the new one.
245
+ // The existing permissions are carried over, since the rename would
246
+ // otherwise replace them with the umask default.
247
+ let mode;
248
+ try {
249
+ if (fs.existsSync(configPath))
250
+ mode = fs.statSync(configPath).mode & 0o777;
251
+ }
252
+ catch {
253
+ // Unreadable metadata is not a reason to skip the write.
254
+ }
255
+ writeFileAtomicSync(configPath, JSON.stringify(config, null, 2) + "\n", mode === undefined ? {} : { mode });
212
256
  return true;
213
257
  }
258
+ /** The settings already on disk, or null when there are none to read. */
259
+ export function readExistingConfig() {
260
+ try {
261
+ return configStoreExists() ? loadConfigStore() : null;
262
+ }
263
+ catch {
264
+ // An unreadable config is replaced by the setup values.
265
+ return null;
266
+ }
267
+ }
268
+ function sameSchool(stored, chosen) {
269
+ // A config that never recorded a school (environment-driven installs) is
270
+ // not a *different* school, so its settings are still ours to keep.
271
+ if (!stored)
272
+ return true;
273
+ try {
274
+ return new URL(stored).origin === new URL(chosen).origin;
275
+ }
276
+ catch {
277
+ return false;
278
+ }
279
+ }
280
+ /**
281
+ * Merge the wizard's answers over the settings already saved.
282
+ *
283
+ * `saveConfigStore` replaces the whole file, and setup is the documented way
284
+ * to update a saved password — so it runs again on configurations that carry
285
+ * settings it never prompts for: the SUNY campus, course filters, a custom
286
+ * session directory or token TTL. Writing only the answers deleted all of
287
+ * them; most visibly, a SUNY user who reran plain `setup` lost the campus
288
+ * that lets sign-in skip the shared campus picker.
289
+ *
290
+ * Settings are carried only within one school, since course ids and the
291
+ * campus belong to a single tenant.
292
+ */
293
+ export function buildConfigToSave(existing, answers) {
294
+ const carried = existing && sameSchool(existing.baseUrl, answers.baseUrl) ? existing : null;
295
+ const config = {
296
+ ...carried,
297
+ baseUrl: answers.baseUrl,
298
+ username: answers.username,
299
+ // Always the freshly typed one: a carried v1 plaintext password would
300
+ // otherwise be the value written to the native store.
301
+ password: answers.password,
302
+ headless: answers.headless,
303
+ };
304
+ if (answers.campus)
305
+ config.campus = answers.campus;
306
+ return config;
307
+ }
214
308
  // ── Auth spawn ─────────────────────────────────────────────────────
215
309
  function runAuth() {
216
310
  const scriptPath = path.resolve(thisDir, "auth-cli.js");
@@ -349,15 +443,13 @@ async function main() {
349
443
  : " A browser window will open when authentication is needed."));
350
444
  console.log("");
351
445
  // ── Step 5: Save config ──────────────────────────────────────────
352
- const config = {
446
+ const config = buildConfigToSave(readExistingConfig(), {
353
447
  baseUrl,
354
448
  username,
355
449
  password,
356
450
  headless,
357
- };
358
- if (campus) {
359
- config.campus = campus;
360
- }
451
+ campus: campus || undefined,
452
+ });
361
453
  await saveSecureConfig(config);
362
454
  console.log(green(" Password saved in your operating system credential store."));
363
455
  console.log(green(" Config saved to: " + getConfigStorePath()));
@@ -467,7 +559,13 @@ async function main() {
467
559
  }
468
560
  console.log("");
469
561
  }
470
- main().catch((err) => {
471
- console.error("Setup failed:", err instanceof Error ? err.message : String(err));
472
- process.exit(1);
473
- });
562
+ // Both entry points — the `brightspace-setup` bin and `brightspace-mcp-server
563
+ // setup`, which imports this module — start the wizard here. VITEST is set
564
+ // only by the test runner, which imports the module for the helpers above and
565
+ // must not open prompts on stdin; no user environment sets it.
566
+ if (!process.env.VITEST) {
567
+ main().catch((err) => {
568
+ console.error("Setup failed:", err instanceof Error ? err.message : String(err));
569
+ process.exit(1);
570
+ });
571
+ }