brightspace-mcp-server 3.11.13 → 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.
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
  }
@@ -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.13",
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",