brightspace-mcp-server 3.11.13 → 3.11.15
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 +1 -1
- package/build/setup.js +70 -42
- package/build/utils/due-in.js +11 -7
- package/build/utils/mcp-client-cli.js +85 -15
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
596
|
-
|
|
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
|
}
|
package/build/utils/due-in.js
CHANGED
|
@@ -27,15 +27,19 @@ export function dueIn(dueDate, now = Date.now()) {
|
|
|
27
27
|
if (!Number.isFinite(due))
|
|
28
28
|
return null;
|
|
29
29
|
const diffMs = due - now;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
// Pick the unit from the rounded count, not the raw distance: 23.6 hours
|
|
31
|
+
// rounds to 24, which reads as "tomorrow", not "in 24 hours".
|
|
32
|
+
const minutes = Math.round(diffMs / MINUTE_MS);
|
|
33
|
+
if (Math.abs(minutes) < 60) {
|
|
34
|
+
return relativeTimeFormat.format(minutes, "minute");
|
|
33
35
|
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
+
const hours = Math.round(diffMs / HOUR_MS);
|
|
37
|
+
if (Math.abs(hours) < 24) {
|
|
38
|
+
return relativeTimeFormat.format(hours, "hour");
|
|
36
39
|
}
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const days = Math.round(diffMs / DAY_MS);
|
|
41
|
+
if (Math.abs(days) < 7) {
|
|
42
|
+
return relativeTimeFormat.format(days, "day");
|
|
39
43
|
}
|
|
40
44
|
return relativeTimeFormat.format(Math.round(diffMs / WEEK_MS), "week");
|
|
41
45
|
}
|
|
@@ -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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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.
|
|
3
|
+
"version": "3.11.15",
|
|
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",
|