brightspace-mcp-server 3.11.12 → 3.11.14

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
@@ -25,7 +25,7 @@ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and a
25
25
  ```bash
26
26
  npx -y brightspace-mcp-server@latest setup
27
27
  ```
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.
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, Antigravity, Codex, or Claude Code if you have them. If one of them already has a `brightspace` entry pointing somewhere else (an old local build, a fork), setup shows you that entry and asks before replacing it.
29
29
 
30
30
  **On a Mac:** when Keychain asks whether `node` may use the saved Brightspace password, choose **Always Allow**, not just Allow. That lets later sign-ins (the `auth` command and questions from your AI app) read it without asking again.
31
31
  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.
@@ -38,8 +38,15 @@ async function renameThroughContention(from, to) {
38
38
  }
39
39
  return false;
40
40
  }
41
+ /**
42
+ * The MFA challenge the lock owner's sign-in is showing, published inside the
43
+ * lock directory so it lives and dies with the lock (see mfa-challenge.ts).
44
+ */
45
+ export const CHALLENGE_FILE = "challenge.json";
41
46
  export class AuthenticationInProgressError extends Error {
42
47
  code = "AUTH_IN_PROGRESS";
48
+ /** The owner's MFA challenge, when the caller looked it up (see BrowserAuth.authenticate). */
49
+ challenge;
43
50
  constructor() {
44
51
  super("Authentication already in progress. Retry after the current authentication finishes.");
45
52
  this.name = "AuthenticationInProgressError";
@@ -89,6 +96,7 @@ export const lockOps = {
89
96
  /** Remove only our fixed metadata names, after the directory was retired. */
90
97
  async function cleanRetired(lockPath) {
91
98
  await fs.unlink(path.join(lockPath, "owner.json")).catch(() => { });
99
+ await fs.unlink(path.join(lockPath, CHALLENGE_FILE)).catch(() => { });
92
100
  await cleanChild(path.join(lockPath, "reclaim.lock"));
93
101
  await fs.rmdir(lockPath).catch(() => { });
94
102
  }
@@ -11,6 +11,7 @@ import { AuthError } from "../utils/errors.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
12
  import { devActivity } from "../utils/dev-activity.js";
13
13
  import { parsePhaseMarker } from "./auth-phases.js";
14
+ import { authLockPath, challengeRelayedAt } from "./mfa-challenge.js";
14
15
  /**
15
16
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
16
17
  * which is five minutes: a person has to find their phone, unlock it, and read
@@ -41,6 +42,25 @@ const MFA_POLL_MS = 45000;
41
42
  * the number in the answer.
42
43
  */
43
44
  const CALL_BUDGET_MS = 55000;
45
+ /**
46
+ * How long a background sign-in past its MFA challenge keeps running once
47
+ * nobody is waiting on it (issue #199). The answer carrying the number can be
48
+ * cancelled, or held in a parallel batch the user never sees; the child then
49
+ * kept its browser and the cross-process lock for the whole 5-minute MFA
50
+ * window, and every server sharing the session was stuck behind it. A user
51
+ * who did see the number calls again, which polls for another MFA_POLL_MS, so
52
+ * the same span without any caller (in this process, or relaying the number
53
+ * from another one) means nobody is approving.
54
+ */
55
+ const ABANDON_MS = MFA_POLL_MS;
56
+ /**
57
+ * Milliseconds until a background sign-in counts as abandoned; zero or less
58
+ * means it already is. A caller still waiting keeps it alive outright;
59
+ * otherwise the clock runs from the latest moment anyone attended to it.
60
+ */
61
+ function abandonDelayMs(waiting, attendedAt, now) {
62
+ return waiting > 0 ? ABANDON_MS : attendedAt + ABANDON_MS - now;
63
+ }
44
64
  /** How long to poll after a challenge seen elapsedMs into the call. */
45
65
  function pollWindowMs(elapsedMs) {
46
66
  return Math.min(MFA_POLL_MS, CALL_BUDGET_MS - elapsedMs);
@@ -179,15 +199,21 @@ export class AuthRunner {
179
199
  pendingChallenge = null;
180
200
  /** Resolves on the next marker from the current child; null between children. */
181
201
  challengeSignal = null;
202
+ /** Callers inside run() right now. */
203
+ waiting = 0;
204
+ /** When the last caller left run(). */
205
+ lastAttendedAt = 0;
182
206
  scriptPath;
183
207
  timeoutMs;
184
208
  onProgress;
209
+ sessionDir;
185
210
  constructor(options = {}) {
186
211
  // Resolve paths relative to this file's compiled location (build/auth/auth-runner.js)
187
212
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
188
213
  this.scriptPath = path.resolve(thisDir, "..", "auth-cli.js");
189
214
  this.timeoutMs = options.timeoutMs ?? AUTH_TIMEOUT_MS;
190
215
  this.onProgress = options.onProgress;
216
+ this.sessionDir = options.sessionDir;
191
217
  }
192
218
  /**
193
219
  * Authenticate, joining the login this process already started if there is
@@ -206,6 +232,16 @@ export class AuthRunner {
206
232
  * the next call to join.
207
233
  */
208
234
  async run(onChallenge) {
235
+ this.waiting += 1;
236
+ try {
237
+ return await this.attend(onChallenge);
238
+ }
239
+ finally {
240
+ this.waiting -= 1;
241
+ this.lastAttendedAt = Date.now();
242
+ }
243
+ }
244
+ async attend(onChallenge) {
209
245
  const startedAt = Date.now();
210
246
  try {
211
247
  return await withinCallBudget(this.runOnce(startedAt), startedAt);
@@ -333,6 +369,8 @@ export class AuthRunner {
333
369
  // against double cleanup.
334
370
  let childFinished = false;
335
371
  let numberMatch;
372
+ let challengeSeen = false;
373
+ let abandonTimer;
336
374
  let killTimer;
337
375
  const kill = (signal) => {
338
376
  try {
@@ -400,8 +438,30 @@ export class AuthRunner {
400
438
  // joinBackgroundChild. Idempotent on the "already known" question, but
401
439
  // a later number still overwrites a numberless pendingChallenge so a
402
440
  // fresh joiner sees it — see MFA_PENDING_MARKER's own comment.
441
+ // Once the child is past its challenge, stop it when nobody is waiting
442
+ // on it any more (see ABANDON_MS). SIGKILL rather than SIGTERM: a
443
+ // graceful stop runs the MFA loop's failure path, which records the
444
+ // cooldown, and the user never had a chance to answer. The dead owner
445
+ // leaves a stale lock the next sign-in reclaims.
446
+ const watchAttention = () => {
447
+ if (childFinished)
448
+ return;
449
+ const relayedAt = this.sessionDir ? challengeRelayedAt(authLockPath(this.sessionDir)) ?? 0 : 0;
450
+ const delayMs = abandonDelayMs(this.waiting, Math.max(this.lastAttendedAt, relayedAt), Date.now());
451
+ if (delayMs > 0) {
452
+ abandonTimer = setTimeout(watchAttention, delayMs);
453
+ abandonTimer.unref?.();
454
+ return;
455
+ }
456
+ log("WARN", "Stopping a background sign-in nobody is waiting on; its MFA prompt went unanswered");
457
+ kill("SIGKILL");
458
+ finishChild(new AuthProcessError("timeout", `The sign-in was stopped because nobody approved its MFA prompt. Try again, or run ${AUTH_COMMAND}.`));
459
+ };
403
460
  const publishChallenge = (matched) => {
461
+ challengeSeen = true;
404
462
  const firstChallenge = this.pendingChallenge === null;
463
+ if (firstChallenge)
464
+ watchAttention();
405
465
  if (firstChallenge)
406
466
  devActivity("mfa_observed", { elapsedMs: Date.now() - started });
407
467
  if (firstChallenge || matched) {
@@ -424,6 +484,8 @@ export class AuthRunner {
424
484
  clearTimeout(timer);
425
485
  if (killTimer)
426
486
  clearTimeout(killTimer);
487
+ if (abandonTimer)
488
+ clearTimeout(abandonTimer);
427
489
  process.off("exit", onExit);
428
490
  const answeredEarly = callerSettled;
429
491
  settleCaller(error);
@@ -500,6 +562,10 @@ export class AuthRunner {
500
562
  6: ["transport", "Brightspace authentication is temporarily unavailable because of a network or server failure. Your saved session was preserved. Try again later."],
501
563
  7: mfaPendingFailure(numberMatch),
502
564
  };
565
+ // Busy, but the owning sign-in's challenge came through: the caller
566
+ // was told what to approve, so answer with that, not "busy".
567
+ if (code === 2 && challengeSeen)
568
+ failures[2] = mfaPendingFailure(numberMatch);
503
569
  const [kind, message] = failures[code ?? -1] ?? ["failed", `Authentication failed. Run ${AUTH_COMMAND} to try again.`];
504
570
  kill("SIGKILL");
505
571
  finishChild(new AuthProcessError(kind, message, kind === "mfaPending" ? numberMatch : undefined));
@@ -3,7 +3,6 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT: see LICENSE file for details.
5
5
  */
6
- import * as path from "node:path";
7
6
  import { readFileSync, accessSync } from "node:fs";
8
7
  import { BrowserAuthError } from "../utils/errors.js";
9
8
  import { log } from "../utils/logger.js";
@@ -11,7 +10,8 @@ import { createSSOFlow, UnsupportedAuthenticationError, MfaApprovalError } from
11
10
  import { isDuoPrompt } from "./duo-mfa.js";
12
11
  import { BrowserStateStore } from "./browser-state-store.js";
13
12
  import { hasNewerEntraState, recordMicrosoftSession } from "./microsoft-session.js";
14
- import { acquireProcessLock } from "./auth-lock.js";
13
+ import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
14
+ import { authLockPath, publishChallenge, relayChallenge } from "./mfa-challenge.js";
15
15
  import { AuthCooldown } from "./auth-cooldown.js";
16
16
  import { mintAccessToken } from "./token-mint.js";
17
17
  import { isMissingBrowserError, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
@@ -54,12 +54,19 @@ export class BrowserAuth {
54
54
  onPhase;
55
55
  /** When the current login first reported an MFA challenge; splits credentials from approvalWait. */
56
56
  challengeSeenAt;
57
+ lockPath;
58
+ /** The latest challenge write, awaited before the lock is released so none lands after it. */
59
+ publishing = Promise.resolve();
57
60
  constructor(config, options = {}) {
58
61
  this.config = config;
59
62
  this.onPhase = options.onPhase;
60
63
  const { onMfaChallenge } = options;
64
+ this.lockPath = authLockPath(config.sessionDir);
61
65
  this.ssoFlow = createSSOFlow(config, options.requestMfaCode, onMfaChallenge && ((number) => {
62
66
  this.challengeSeenAt ??= Date.now();
67
+ // Other server processes sharing this session cannot see our caller's
68
+ // tool response; the lock directory is where they look instead.
69
+ this.publishing = this.publishing.then(() => publishChallenge(this.lockPath, number)).catch(() => { });
63
70
  onMfaChallenge(number);
64
71
  }));
65
72
  this.stateStore = new BrowserStateStore(config.sessionDir);
@@ -101,7 +108,13 @@ export class BrowserAuth {
101
108
  }
102
109
  }
103
110
  async authenticate(options = {}) {
104
- const release = await acquireProcessLock(path.join(this.config.sessionDir, ".auth.lock"), { mode: options.automatic ? "automatic" : "explicit" });
111
+ const release = await acquireProcessLock(this.lockPath, { mode: options.automatic ? "automatic" : "explicit" }).catch(async (error) => {
112
+ // Hand back the number the owner is showing, so this caller's user can
113
+ // approve that sign-in instead of being told only to wait for it.
114
+ if (error instanceof AuthenticationInProgressError)
115
+ error.challenge = await relayChallenge(this.lockPath);
116
+ throw error;
117
+ });
105
118
  try {
106
119
  // Even a cookie-only SAML redirect can issue an MFA push. Suppress all
107
120
  // automatic browser attempts during cooldown; HTTP token refresh runs
@@ -114,6 +127,7 @@ export class BrowserAuth {
114
127
  return token;
115
128
  }
116
129
  finally {
130
+ await this.publishing;
117
131
  await release();
118
132
  }
119
133
  }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Brightspace MCP Server. Copyright (c) 2026 Rohan Muppa. MIT licensed.
3
+ *
4
+ * The MFA challenge a sign-in is showing, shared with every process that
5
+ * shares its session store (issue #199).
6
+ *
7
+ * Several server processes, one per open MCP client, can share one session
8
+ * directory, and only the one holding the sign-in lock can sign in. The number
9
+ * to enter used to reach the user only through the one tool call that started
10
+ * that sign-in. The owner now writes its challenge into the lock directory, so
11
+ * it disappears with the lock, and a contender reads it back and relays it.
12
+ * Relaying touches the file, which tells the owner's server that someone is
13
+ * still waiting on the sign-in (see AuthRunner's abandonment check).
14
+ */
15
+ import * as fs from "node:fs/promises";
16
+ import { statSync } from "node:fs";
17
+ import * as path from "node:path";
18
+ import { CHALLENGE_FILE } from "./auth-lock.js";
19
+ /** Same bound the auth child's stdout marker enforces: 1-3 digits and nothing else. */
20
+ const NUMBER_MATCH = /^\d{1,3}$/;
21
+ export function authLockPath(sessionDir) {
22
+ return path.join(sessionDir, ".auth.lock");
23
+ }
24
+ /** Record the challenge this process's sign-in is showing. Call only while holding the lock. */
25
+ export async function publishChallenge(lockPath, numberMatch) {
26
+ const challenge = numberMatch && NUMBER_MATCH.test(numberMatch) ? { numberMatch } : {};
27
+ await fs.writeFile(path.join(lockPath, CHALLENGE_FILE), JSON.stringify(challenge), { mode: 0o600 });
28
+ }
29
+ /**
30
+ * The challenge the lock owner is showing, or undefined when it has published
31
+ * none. Marks the challenge as just relayed, so the owner keeps waiting.
32
+ */
33
+ export async function relayChallenge(lockPath) {
34
+ const file = path.join(lockPath, CHALLENGE_FILE);
35
+ let challenge;
36
+ try {
37
+ challenge = JSON.parse(await fs.readFile(file, "utf8"));
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ if (typeof challenge !== "object" || challenge === null)
43
+ return undefined;
44
+ const now = new Date();
45
+ await fs.utimes(file, now, now).catch(() => { });
46
+ const numberMatch = challenge.numberMatch;
47
+ return typeof numberMatch === "string" && NUMBER_MATCH.test(numberMatch) ? { numberMatch } : {};
48
+ }
49
+ /** When the lock's challenge was last published or relayed, or undefined when there is none. */
50
+ export function challengeRelayedAt(lockPath) {
51
+ try {
52
+ return statSync(path.join(lockPath, CHALLENGE_FILE)).mtimeMs;
53
+ }
54
+ catch {
55
+ return undefined;
56
+ }
57
+ }
58
+ /** The stdout marker AuthRunner parses for a challenge. */
59
+ export function challengeMarker(numberMatch) {
60
+ return numberMatch ? `MFA_NUMBER:${numberMatch}` : "MFA_PENDING";
61
+ }
package/build/auth-cli.js CHANGED
@@ -11,6 +11,8 @@ import { MfaApprovalError } from "./auth/sso-flow.js";
11
11
  import { NativeCredentialStoreError } from "./auth/credential-store.js";
12
12
  import { retireLegacyProfile } from "./auth/legacy-profile.js";
13
13
  import { formatPhaseMarker } from "./auth/auth-phases.js";
14
+ import { AuthenticationInProgressError } from "./auth/auth-lock.js";
15
+ import { challengeMarker } from "./auth/mfa-challenge.js";
14
16
  import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
15
17
  import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
16
18
  import { reexecLatestIfStale } from "./utils/self-update.js";
@@ -62,7 +64,7 @@ async function main() {
62
64
  // seconds instead of waiting out the whole approval window. Stdout only
63
65
  // — the parent parses stdout for structured markers, never stderr.
64
66
  const onMfaChallenge = automatic
65
- ? (number) => console.log(number ? `MFA_NUMBER:${number}` : "MFA_PENDING")
67
+ ? (number) => console.log(challengeMarker(number))
66
68
  : undefined;
67
69
  // Stage timings for the server's dev activity log, same stdout channel.
68
70
  const onPhase = automatic
@@ -88,7 +90,12 @@ async function main() {
88
90
  // where they were scraped — can cross the process boundary as data
89
91
  // rather than free-form text.
90
92
  if (error instanceof MfaApprovalError && error.numberMatch) {
91
- console.log(`MFA_NUMBER:${error.numberMatch}`);
93
+ console.log(challengeMarker(error.numberMatch));
94
+ }
95
+ // Another process's sign-in holds the lock and is showing a challenge:
96
+ // pass it on, so this caller can tell its user what to approve.
97
+ if (automatic && error instanceof AuthenticationInProgressError && error.challenge) {
98
+ console.log(challengeMarker(error.challenge.numberMatch));
92
99
  }
93
100
  process.exitCode = error instanceof NativeCredentialStoreError ? 5
94
101
  : code === "AUTH_IN_PROGRESS" ? 2
package/build/index.js CHANGED
@@ -86,6 +86,7 @@ else {
86
86
  const authRunner = envAuthActive
87
87
  ? undefined
88
88
  : new AuthRunner({
89
+ sessionDir: config.sessionDir,
89
90
  onProgress: (message) => {
90
91
  void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
91
92
  },
package/build/setup.js CHANGED
@@ -16,7 +16,7 @@ import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils
16
16
  import { saveSecureConfig } from "./utils/secure-config.js";
17
17
  import { writeFileAtomicSync } from "./utils/atomic-write.js";
18
18
  import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
19
- import { cliMcpClients, configureCliMcpClient, isCliAvailable, } from "./utils/mcp-client-cli.js";
19
+ import { classifyRegistration, cliMcpClients, inspectCliMcpClient, isCliAvailable, registerCliMcpClient, serverCommand, } from "./utils/mcp-client-cli.js";
20
20
  // ANSI helpers
21
21
  const bold = (s) => `\x1b[1m${s}\x1b[0m`;
22
22
  const green = (s) => `\x1b[32m${s}\x1b[0m`;
@@ -240,6 +240,9 @@ function isChatGPTInstalled() {
240
240
  function getCursorConfigPath() {
241
241
  return path.join(os.homedir(), ".cursor", "mcp.json");
242
242
  }
243
+ function getAntigravityConfigPath() {
244
+ return path.join(os.homedir(), ".gemini", "antigravity", "mcp_config.json");
245
+ }
243
246
  /**
244
247
  * A JSON value we can safely merge a server entry into. An array passes
245
248
  * `typeof x === "object"` but drops every added key when it is stringified
@@ -248,6 +251,19 @@ function getCursorConfigPath() {
248
251
  function isJsonObject(value) {
249
252
  return typeof value === "object" && value !== null && !Array.isArray(value);
250
253
  }
254
+ /** How the client's existing "brightspace" entry compares with ours. */
255
+ export function inspectMcpClient(configPath) {
256
+ let config;
257
+ try {
258
+ config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
259
+ }
260
+ catch {
261
+ // No file, or one configureMcpClient would replace anyway.
262
+ return { state: "missing" };
263
+ }
264
+ const servers = isJsonObject(config) ? config.mcpServers : undefined;
265
+ return classifyRegistration(isJsonObject(servers) ? servers.brightspace : undefined);
266
+ }
251
267
  export function configureMcpClient(configPath) {
252
268
  let config = {};
253
269
  // Read existing config if present
@@ -276,17 +292,8 @@ export function configureMcpClient(configPath) {
276
292
  const servers = isJsonObject(config.mcpServers) ? config.mcpServers : {};
277
293
  config.mcpServers = servers;
278
294
  // Add/update brightspace entry
279
- // On Windows, npx is a .cmd shim that must be invoked through cmd.exe
280
- const isWindows = process.platform === "win32";
281
- servers["brightspace"] = isWindows
282
- ? {
283
- command: "cmd",
284
- args: ["/c", "npx", "-y", "brightspace-mcp-server@latest"],
285
- }
286
- : {
287
- command: "npx",
288
- args: ["-y", "brightspace-mcp-server@latest"],
289
- };
295
+ const [command, ...args] = serverCommand();
296
+ servers["brightspace"] = { command, args };
290
297
  // Ensure parent directory exists
291
298
  const dir = path.dirname(configPath);
292
299
  if (!fs.existsSync(dir)) {
@@ -362,6 +369,38 @@ export function buildConfigToSave(existing, answers) {
362
369
  return config;
363
370
  }
364
371
  // ── Auth spawn ─────────────────────────────────────────────────────
372
+ /**
373
+ * Registers Brightspace in one client, never silently replacing an entry
374
+ * that points somewhere else: a matching entry is left alone, and a different
375
+ * one is printed (so it can be restored) and replaced only on a yes.
376
+ * Resolves true when the client ends up running the expected entry.
377
+ */
378
+ async function registerWithClient(rl, displayName, existing, write) {
379
+ if (existing.state === "current") {
380
+ console.log(green(` ${displayName} already has Brightspace configured.`));
381
+ return true;
382
+ }
383
+ if (existing.state === "different") {
384
+ console.log(yellow(` ${displayName} has "brightspace" pointing at:`));
385
+ console.log(` ${existing.current}`);
386
+ const replace = await ask(rl, ` Replace it with ${serverCommand().join(" ")}? (yes/no): `);
387
+ if (!/^y(es)?$/i.test(replace)) {
388
+ console.log(dim(` Left ${displayName} unchanged.`));
389
+ return false;
390
+ }
391
+ }
392
+ try {
393
+ if (write(existing.state === "different")) {
394
+ console.log(green(` ${displayName} configured!`));
395
+ return true;
396
+ }
397
+ console.log(yellow(` Could not configure ${displayName}. See README.md for the manual command.`));
398
+ }
399
+ catch (err) {
400
+ console.log(yellow(` Could not configure ${displayName}: ${err instanceof Error ? err.message : String(err)}`));
401
+ }
402
+ return false;
403
+ }
365
404
  function runAuth() {
366
405
  const scriptPath = path.resolve(thisDir, "auth-cli.js");
367
406
  return new Promise((resolve) => {
@@ -557,15 +596,9 @@ async function main() {
557
596
  const claudePath = getClaudeDesktopConfigPath();
558
597
  if (claudePath) {
559
598
  const configClaude = await ask(rl2, "Would you like to automatically configure Claude Desktop? (yes/no): ");
560
- if (/^y(es)?$/i.test(configClaude)) {
561
- try {
562
- configureMcpClient(claudePath);
563
- configuredClients.push("Claude Desktop");
564
- console.log(green(" Claude Desktop configured! Restart Claude Desktop to connect."));
565
- }
566
- catch (err) {
567
- console.log(yellow(` Could not configure Claude Desktop: ${err instanceof Error ? err.message : String(err)}`));
568
- }
599
+ if (/^y(es)?$/i.test(configClaude) &&
600
+ await registerWithClient(rl2, "Claude Desktop", inspectMcpClient(claudePath), () => configureMcpClient(claudePath))) {
601
+ configuredClients.push("Claude Desktop");
569
602
  }
570
603
  console.log("");
571
604
  }
@@ -574,15 +607,19 @@ async function main() {
574
607
  const cursorExists = fs.existsSync(path.dirname(cursorPath));
575
608
  if (cursorExists) {
576
609
  const configCursor = await ask(rl2, "Cursor detected. Would you like to configure it too? (yes/no): ");
577
- if (/^y(es)?$/i.test(configCursor)) {
578
- try {
579
- configureMcpClient(cursorPath);
580
- configuredClients.push("Cursor");
581
- console.log(green(" Cursor configured! Restart Cursor to connect."));
582
- }
583
- catch (err) {
584
- console.log(yellow(` Could not configure Cursor: ${err instanceof Error ? err.message : String(err)}`));
585
- }
610
+ if (/^y(es)?$/i.test(configCursor) &&
611
+ await registerWithClient(rl2, "Cursor", inspectMcpClient(cursorPath), () => configureMcpClient(cursorPath))) {
612
+ configuredClients.push("Cursor");
613
+ }
614
+ console.log("");
615
+ }
616
+ // ── Antigravity auto-config ──────────────────────────────────────
617
+ const antigravityPath = getAntigravityConfigPath();
618
+ if (fs.existsSync(path.dirname(antigravityPath))) {
619
+ const configAntigravity = await ask(rl2, "Antigravity detected. Would you like to configure it too? (yes/no): ");
620
+ if (/^y(es)?$/i.test(configAntigravity) &&
621
+ await registerWithClient(rl2, "Antigravity", inspectMcpClient(antigravityPath), () => configureMcpClient(antigravityPath))) {
622
+ configuredClients.push("Antigravity");
586
623
  }
587
624
  console.log("");
588
625
  }
@@ -591,18 +628,9 @@ async function main() {
591
628
  if (!isCliAvailable(client))
592
629
  continue;
593
630
  const configureClient = await ask(rl2, `${client.displayName} detected. Configure it automatically? (yes/no): `);
594
- if (/^y(es)?$/i.test(configureClient)) {
595
- const result = configureCliMcpClient(client);
596
- if (result === "failed") {
597
- console.log(yellow(` Could not configure ${client.displayName}. See README.md for the manual command.`));
598
- }
599
- else {
600
- configuredClients.push(client.displayName);
601
- const message = result === "already-configured"
602
- ? ` ${client.displayName} already has Brightspace configured.`
603
- : ` ${client.displayName} configured!`;
604
- console.log(green(message));
605
- }
631
+ if (/^y(es)?$/i.test(configureClient) &&
632
+ await registerWithClient(rl2, client.displayName, inspectCliMcpClient(client), (replace) => registerCliMcpClient(client, { replace }))) {
633
+ configuredClients.push(client.displayName);
606
634
  }
607
635
  console.log("");
608
636
  }
@@ -82,6 +82,13 @@ export function errorResponse(message) {
82
82
  * nothing is derived from the caught message, so a child process cannot put
83
83
  * words in the response. The full error still goes to the log.
84
84
  */
85
+ /**
86
+ * Issue #199: a client running calls in a parallel batch can hold the answer
87
+ * carrying the number until the whole batch finishes, and the user never sees
88
+ * it in time. Ask the model to relay it on its own and call one tool at a time.
89
+ */
90
+ const MFA_SOLO_CALL = "Don't run other Brightspace calls in parallel until the sign-in completes: a batched " +
91
+ "answer can reach the user too late to approve. Make one call at a time.";
85
92
  const AUTH_FAILURE_GUIDANCE = {
86
93
  busy: "A sign-in is already running in another process. Let it finish, then try again, " +
87
94
  `or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
@@ -101,7 +108,8 @@ const AUTH_FAILURE_GUIDANCE = {
101
108
  mfaPending: "Approve the sign-in request on your phone (Microsoft Authenticator or Duo). " +
102
109
  "Tell the user that, then call this tool again right away without waiting for them to " +
103
110
  "confirm: each call waits up to 45 seconds for the approval and returns the result as soon " +
104
- "as the sign-in completes. Keep calling until it succeeds or reports a different error.",
111
+ "as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
112
+ MFA_SOLO_CALL,
105
113
  inProgress: "Brightspace sign-in is still starting in the background (opening the browser and the " +
106
114
  "school's login pages). Call this tool again right away: the next call joins the same " +
107
115
  "sign-in, so it sends no second MFA prompt, and returns the number to approve as soon as " +
@@ -119,7 +127,8 @@ function authFailureMessage(error) {
119
127
  return `Open Microsoft Authenticator and enter ${error.numberMatch} within 5 minutes. ` +
120
128
  "Tell the user that number, then call this tool again right away without waiting for them " +
121
129
  "to confirm: each call waits up to 45 seconds for the approval and returns the result as " +
122
- "soon as the sign-in completes. Keep calling until it succeeds or reports a different error.";
130
+ "soon as the sign-in completes. Keep calling until it succeeds or reports a different error. " +
131
+ MFA_SOLO_CALL;
123
132
  }
124
133
  return AUTH_FAILURE_GUIDANCE[error.kind];
125
134
  }
@@ -1,41 +1,111 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
2
5
  import { PACKAGE_NAME } from "./commands.js";
3
6
  function defaultRunCommand(command, args, stdio) {
4
7
  const result = spawnSync(command, args, {
5
- stdio,
8
+ stdio: stdio === "pipe" ? ["ignore", "pipe", "ignore"] : stdio,
9
+ encoding: "utf-8",
6
10
  shell: process.platform === "win32",
7
11
  });
8
- return result.error ? null : result.status;
12
+ return { status: result.error ? null : result.status, stdout: result.stdout ?? "" };
9
13
  }
10
- export function cliMcpClients(platform = process.platform) {
11
- const serverCommand = platform === "win32"
14
+ /** The command every client is registered with. */
15
+ export function serverCommand(platform = process.platform) {
16
+ // On Windows, npx is a .cmd shim that must be invoked through cmd.exe
17
+ return platform === "win32"
12
18
  ? ["cmd", "/c", "npx", "-y", `${PACKAGE_NAME}@latest`]
13
19
  : ["npx", "-y", `${PACKAGE_NAME}@latest`];
20
+ }
21
+ export function classifyRegistration(entry, platform = process.platform) {
22
+ if (entry === undefined)
23
+ return { state: "missing" };
24
+ if (entry === null || typeof entry !== "object") {
25
+ return { state: "different", current: "(an entry setup could not read)" };
26
+ }
27
+ const { command, args } = entry;
28
+ if (typeof command !== "string") {
29
+ return { state: "different", current: JSON.stringify(entry) };
30
+ }
31
+ const argv = [command, ...(Array.isArray(args) ? args.map(String) : [])];
32
+ const expected = serverCommand(platform);
33
+ if (argv.length === expected.length && argv.every((part, i) => part === expected[i])) {
34
+ return { state: "current" };
35
+ }
36
+ return { state: "different", current: argv.join(" ") };
37
+ }
38
+ function readJsonFile(filePath) {
39
+ try {
40
+ return JSON.parse(fs.readFileSync(filePath, "utf-8"));
41
+ }
42
+ catch {
43
+ return undefined;
44
+ }
45
+ }
46
+ /** Codex keeps one global registry; `--json` exposes the entry's command. */
47
+ function readCodexEntry(runCommand) {
48
+ const lookup = runCommand("codex", ["mcp", "get", "brightspace", "--json"], "pipe");
49
+ if (lookup.status === 0) {
50
+ try {
51
+ const parsed = JSON.parse(lookup.stdout);
52
+ return parsed.transport ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ // A Codex too old for `--json` still answers the plain lookup.
59
+ return runCommand("codex", ["mcp", "get", "brightspace"], "ignore").status === 0
60
+ ? null
61
+ : undefined;
62
+ }
63
+ /**
64
+ * Claude Code's `mcp get` resolves local, then project, then user scope from
65
+ * the current directory, so a project's `.mcp.json` can answer for a user
66
+ * registration that doesn't exist. Setup registers at user scope, which lives
67
+ * in the top-level `mcpServers` of `.claude.json`; read that directly.
68
+ */
69
+ function readClaudeCodeEntry() {
70
+ const configDir = process.env.CLAUDE_CONFIG_DIR || os.homedir();
71
+ const config = readJsonFile(path.join(configDir, ".claude.json"));
72
+ const servers = config?.mcpServers;
73
+ return servers && typeof servers === "object" ? servers.brightspace : undefined;
74
+ }
75
+ export function cliMcpClients(platform = process.platform) {
76
+ const command = serverCommand(platform);
14
77
  return [
15
78
  {
16
79
  id: "codex",
17
80
  displayName: "Codex Desktop and CLI",
18
81
  command: "codex",
19
- getArgs: ["mcp", "get", "brightspace"],
20
- addArgs: ["mcp", "add", "brightspace", "--", ...serverCommand],
82
+ addArgs: ["mcp", "add", "brightspace", "--", ...command],
83
+ removeArgs: ["mcp", "remove", "brightspace"],
84
+ readEntry: readCodexEntry,
21
85
  },
22
86
  {
23
87
  id: "claude-code",
24
88
  displayName: "Claude Code",
25
89
  command: "claude",
26
- getArgs: ["mcp", "get", "brightspace"],
27
- addArgs: ["mcp", "add", "--scope", "user", "brightspace", "--", ...serverCommand],
90
+ addArgs: ["mcp", "add", "--scope", "user", "brightspace", "--", ...command],
91
+ removeArgs: ["mcp", "remove", "--scope", "user", "brightspace"],
92
+ readEntry: readClaudeCodeEntry,
28
93
  },
29
94
  ];
30
95
  }
31
96
  export function isCliAvailable(client, runCommand = defaultRunCommand) {
32
- return runCommand(client.command, ["--version"], "ignore") === 0;
97
+ return runCommand(client.command, ["--version"], "ignore").status === 0;
98
+ }
99
+ export function inspectCliMcpClient(client, runCommand = defaultRunCommand) {
100
+ return classifyRegistration(client.readEntry(runCommand));
33
101
  }
34
- export function configureCliMcpClient(client, runCommand = defaultRunCommand) {
35
- if (runCommand(client.command, client.getArgs, "ignore") === 0) {
36
- return "already-configured";
102
+ /**
103
+ * Writes the expected entry. With `replace`, the existing one is removed
104
+ * first, since `claude mcp add` refuses a name that is already taken.
105
+ */
106
+ export function registerCliMcpClient(client, { replace }, runCommand = defaultRunCommand) {
107
+ if (replace && runCommand(client.command, client.removeArgs, "inherit").status !== 0) {
108
+ return false;
37
109
  }
38
- return runCommand(client.command, client.addArgs, "inherit") === 0
39
- ? "configured"
40
- : "failed";
110
+ return runCommand(client.command, client.addArgs, "inherit").status === 0;
41
111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.11.12",
3
+ "version": "3.11.14",
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",