brightspace-mcp-server 3.15.4 → 4.0.0
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 +4 -2
- package/build/auth/browser-auth.js +2 -1
- package/build/doctor.js +47 -47
- package/build/setup/args.js +107 -0
- package/build/setup/clients.js +189 -0
- package/build/setup/config.js +90 -0
- package/build/setup/prepare.js +298 -0
- package/build/setup/presets.js +91 -0
- package/build/setup.js +238 -395
- package/build/utils/browser-install.js +77 -3
- package/build/utils/commands.js +30 -0
- package/build/utils/mcp-client-cli.js +13 -0
- package/build/utils/saved-session.js +59 -0
- 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
|
|
28
|
+
It asks for your school's Brightspace address, your username and password, and how you confirm it's you when you sign in (a phone app, or another way like a text message). Your password goes into your computer's own password store (Keychain on Mac, Credential Manager on Windows), never into a file. Before signing you in, 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.
|
|
@@ -40,6 +40,8 @@ https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
|
|
|
40
40
|
(use --purdue at Purdue, --suny at SUNY, --tudelft at TU Delft, --cuny at CUNY, --leiden at Leiden, --mcgill at McGill, --ngeeann at Ngee Ann Polytechnic, or --javeriana at Javeriana Cali).
|
|
41
41
|
```
|
|
42
42
|
|
|
43
|
+
The AI does everything it can by itself: it checks your computer, downloads the browser sign-in uses, and connects your AI apps. Then it gives you one command to run in a terminal. That command asks for your password and waits for you to approve the sign-in on your phone. That's the only part that needs you. Afterwards the AI checks that it can see your courses.
|
|
44
|
+
|
|
43
45
|
Using a different AI app? Add the command `npx -y brightspace-mcp-server@latest` to its MCP settings (on Windows: `cmd /c npx -y brightspace-mcp-server@latest`). Exact steps for each app: [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
44
46
|
|
|
45
47
|
## Does my school work?
|
|
@@ -125,7 +127,7 @@ How often you're asked is up to your school, not this tool. Everything else abou
|
|
|
125
127
|
|
|
126
128
|
## Something not working?
|
|
127
129
|
|
|
128
|
-
- Run `npx -y brightspace-mcp-server@latest doctor` first — it checks your Node version, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix.
|
|
130
|
+
- Run `npx -y brightspace-mcp-server@latest doctor` first — it checks your Node version, the sign-in browser, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix.
|
|
129
131
|
- **`Authentication failed: The native credential store is locked or unavailable.`** Your password store is locked or hasn't let this server read the saved password. Unlock it (on a Mac, log in or unlock Keychain Access), then run `npx -y brightspace-mcp-server@latest auth` again, or `setup` if that still fails, and choose **Always Allow** when Keychain asks.
|
|
130
132
|
- **It works in the terminal but not in the app:** the app starts it separately and may not see your password store yet — see [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
131
133
|
- **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
|
|
@@ -15,6 +15,7 @@ import { authLockPath, publishAutomaticProgress, publishChallenge, relayChalleng
|
|
|
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";
|
|
18
|
+
import { signInCommand } from "../utils/commands.js";
|
|
18
19
|
const SILENT_SSO_TIMEOUT_MS = 30000;
|
|
19
20
|
const SILENT_SSO_POLL_MS = 1000;
|
|
20
21
|
const INITIAL_NAVIGATION_TIMEOUT_MS = 60000;
|
|
@@ -419,7 +420,7 @@ export class BrowserAuth {
|
|
|
419
420
|
await this.waitForVisibleLogin(page);
|
|
420
421
|
return false;
|
|
421
422
|
}
|
|
422
|
-
throw new UnsupportedAuthenticationError(
|
|
423
|
+
throw new UnsupportedAuthenticationError(`Automatic sign-in requires saved credentials. Run this in a terminal: ${signInCommand(this.config.baseUrl)}`);
|
|
423
424
|
}
|
|
424
425
|
if (!pendingMfa && !credentialPrompt) {
|
|
425
426
|
if (this.config.headless === false) {
|
package/build/doctor.js
CHANGED
|
@@ -21,17 +21,18 @@
|
|
|
21
21
|
import { readFileSync } from "node:fs";
|
|
22
22
|
import * as path from "node:path";
|
|
23
23
|
import { fileURLToPath } from "node:url";
|
|
24
|
-
import * as os from "node:os";
|
|
25
24
|
import { configStoreExists, loadConfigStore, } from "./utils/config-store.js";
|
|
26
|
-
import {
|
|
25
|
+
import { envBoolean, readEnvSecret, parseSessionCookieEnv } from "./utils/config.js";
|
|
27
26
|
import { getStoredPassword, getStoredTotpUri } from "./auth/credential-store.js";
|
|
28
27
|
import { describeShortcuts } from "./utils/two-factor-shortcuts.js";
|
|
29
28
|
import { discoverVersions } from "./api/version-discovery.js";
|
|
30
|
-
import { TokenManager } from "./auth/token-manager.js";
|
|
31
|
-
import { SessionStore } from "./auth/session-store.js";
|
|
32
29
|
import { D2LApiClient } from "./api/client.js";
|
|
33
30
|
import { fetchLatestVersion, isNewerVersion, safeVersionLabel, installKindOf, } from "./utils/update-checker.js";
|
|
34
|
-
import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, } from "./utils/commands.js";
|
|
31
|
+
import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, prepareCommand, signInCommand, } from "./utils/commands.js";
|
|
32
|
+
import { chromiumReady, chromiumStatus } from "./utils/browser-install.js";
|
|
33
|
+
import { nodeVersionSupported, readOnlyTokenManager, readSavedSessionToken, sessionDirFor } from "./utils/saved-session.js";
|
|
34
|
+
// Tests import this from doctor.ts.
|
|
35
|
+
export { readOnlySessionStore } from "./utils/saved-session.js";
|
|
35
36
|
const __filename = fileURLToPath(import.meta.url);
|
|
36
37
|
const __dirname = path.dirname(__filename);
|
|
37
38
|
function readInstalledVersion() {
|
|
@@ -43,33 +44,6 @@ function readInstalledVersion() {
|
|
|
43
44
|
return "0.0.0";
|
|
44
45
|
}
|
|
45
46
|
}
|
|
46
|
-
/**
|
|
47
|
-
* doctor only ever reads a saved session — it must never write session.json,
|
|
48
|
-
* trash it, or take the write lock a v1-record migration uses (that lock can
|
|
49
|
-
* also throw AuthenticationInProgressError while a real sign-in holds it).
|
|
50
|
-
* SessionStore.peek() is the read-only sibling of load() that does none of
|
|
51
|
-
* that, so every write-shaped method here is a no-op and load() delegates to
|
|
52
|
-
* peek() instead of the real load(). Exported for tests, which pass a fake
|
|
53
|
-
* in place of a real SessionStore to prove the no-op wiring.
|
|
54
|
-
*/
|
|
55
|
-
export function readOnlySessionStore(store) {
|
|
56
|
-
return {
|
|
57
|
-
load: () => store.peek(),
|
|
58
|
-
save: async () => { },
|
|
59
|
-
clear: async () => { },
|
|
60
|
-
saveIfCurrent: async () => true,
|
|
61
|
-
clearIfCurrent: async () => true,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
/** A TokenManager that can mint from a saved cookie but can never write, clear, or migrate session.json. */
|
|
65
|
-
function readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie) {
|
|
66
|
-
return new TokenManager({
|
|
67
|
-
baseUrl,
|
|
68
|
-
envAccessToken,
|
|
69
|
-
envSessionCookie,
|
|
70
|
-
sessionStore: readOnlySessionStore(new SessionStore(sessionDir)),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
47
|
function defaultDoctorDeps() {
|
|
74
48
|
return {
|
|
75
49
|
nodeVersion: process.version,
|
|
@@ -80,7 +54,7 @@ function defaultDoctorDeps() {
|
|
|
80
54
|
getStoredPassword,
|
|
81
55
|
getStoredTotpUri: (baseUrl, username) => getStoredTotpUri(baseUrl, username),
|
|
82
56
|
discoverVersions,
|
|
83
|
-
getSessionToken:
|
|
57
|
+
getSessionToken: readSavedSessionToken,
|
|
84
58
|
countCourses: async (baseUrl, sessionDir, envAccessToken, envSessionCookie) => {
|
|
85
59
|
const apiClient = new D2LApiClient({
|
|
86
60
|
baseUrl,
|
|
@@ -91,6 +65,7 @@ function defaultDoctorDeps() {
|
|
|
91
65
|
return { count: response?.Items?.length ?? 0, hasMore: Boolean(response?.PagingInfo?.HasMoreItems) };
|
|
92
66
|
},
|
|
93
67
|
fetchLatestVersion,
|
|
68
|
+
chromiumStatus: () => chromiumStatus(),
|
|
94
69
|
installKind: installKindOf(),
|
|
95
70
|
installedVersion: readInstalledVersion(),
|
|
96
71
|
};
|
|
@@ -103,8 +78,7 @@ function problem(id, message) {
|
|
|
103
78
|
}
|
|
104
79
|
// ── Individual checks ────────────────────────────────────────────────
|
|
105
80
|
function checkNode(deps) {
|
|
106
|
-
|
|
107
|
-
if (Number.isFinite(major) && major >= 20) {
|
|
81
|
+
if (nodeVersionSupported(deps.nodeVersion)) {
|
|
108
82
|
return pass("node", `Node.js ${deps.nodeVersion} (20 or newer)`);
|
|
109
83
|
}
|
|
110
84
|
return problem("node", `Node.js ${deps.nodeVersion} is too old — install Node 20 or newer from https://nodejs.org/, then try again.`);
|
|
@@ -154,12 +128,7 @@ function checkConfig(deps) {
|
|
|
154
128
|
catch {
|
|
155
129
|
return { ok: false, result: problem("config", `The configured Brightspace URL is invalid — run: ${SETUP_COMMAND}`) };
|
|
156
130
|
}
|
|
157
|
-
const
|
|
158
|
-
? expandTilde(deps.env.D2L_SESSION_DIR)
|
|
159
|
-
: store?.sessionDir
|
|
160
|
-
? expandTilde(store.sessionDir)
|
|
161
|
-
: path.join(os.homedir(), ".d2l-session");
|
|
162
|
-
const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
|
|
131
|
+
const sessionDir = sessionDirFor(deps.env, store, baseUrl, username);
|
|
163
132
|
const label = username ? `signed in as ${username} at ${baseUrl}` : `configured for ${baseUrl} via environment variables`;
|
|
164
133
|
return {
|
|
165
134
|
ok: true,
|
|
@@ -183,7 +152,7 @@ async function checkCredential(deps, baseUrl, username, envAccessToken, envSessi
|
|
|
183
152
|
const password = await deps.getStoredPassword(baseUrl, username);
|
|
184
153
|
if (password)
|
|
185
154
|
return pass("credential", "Password saved in your operating system's credential store");
|
|
186
|
-
return problem("credential", `No saved password found for this account — run: ${
|
|
155
|
+
return problem("credential", `No saved password found for this account — run this in a terminal: ${signInCommand(baseUrl)}`);
|
|
187
156
|
}
|
|
188
157
|
catch (error) {
|
|
189
158
|
// On Linux the store itself (secret-tool / an unlocked Secret Service)
|
|
@@ -193,7 +162,7 @@ async function checkCredential(deps, baseUrl, username, envAccessToken, envSessi
|
|
|
193
162
|
if (deps.platform === "linux") {
|
|
194
163
|
return problem("credential", error instanceof Error ? error.message : "The Linux credential store is locked or unavailable. Unlock your keyring and try again.");
|
|
195
164
|
}
|
|
196
|
-
return problem("credential", `Run
|
|
165
|
+
return problem("credential", `Run this in a terminal to save your password again: ${signInCommand(baseUrl)}`);
|
|
197
166
|
}
|
|
198
167
|
}
|
|
199
168
|
/**
|
|
@@ -229,6 +198,23 @@ async function describeTwoFactorShortcuts(deps, baseUrl, username) {
|
|
|
229
198
|
}
|
|
230
199
|
return describeShortcuts({ rememberMfa, keySaved });
|
|
231
200
|
}
|
|
201
|
+
async function checkBrowser(deps) {
|
|
202
|
+
// An environment credential signs in without a browser, so a container
|
|
203
|
+
// with no Chromium is still a working install.
|
|
204
|
+
if (deps.env.D2L_ACCESS_TOKEN || deps.env.D2L_SESSION_COOKIE) {
|
|
205
|
+
return pass("browser", "Chromium not needed: signing in via an environment variable");
|
|
206
|
+
}
|
|
207
|
+
let status;
|
|
208
|
+
try {
|
|
209
|
+
status = await deps.chromiumStatus();
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
return problem("browser", `Could not check for Chromium — run: ${prepareCommand()}`);
|
|
213
|
+
}
|
|
214
|
+
if (chromiumReady(status))
|
|
215
|
+
return pass("browser", "Chromium is installed for sign-in");
|
|
216
|
+
return problem("browser", `Chromium is missing, so sign-in can't start — run: ${prepareCommand()}`);
|
|
217
|
+
}
|
|
232
218
|
async function checkNetwork(deps, baseUrl) {
|
|
233
219
|
try {
|
|
234
220
|
await deps.discoverVersions(baseUrl);
|
|
@@ -238,8 +224,12 @@ async function checkNetwork(deps, baseUrl) {
|
|
|
238
224
|
return problem("network", `Could not reach ${baseUrl} — check your internet connection and the school address, then run doctor again.`);
|
|
239
225
|
}
|
|
240
226
|
}
|
|
241
|
-
async function checkSession(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
|
|
242
|
-
|
|
227
|
+
async function checkSession(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie, passwordSaved) {
|
|
228
|
+
// Without a saved password (an install `setup --prepare` got ready), asking
|
|
229
|
+
// the AI app can't sign in on its own: the person has to run sign-in.
|
|
230
|
+
const failMessage = passwordSaved
|
|
231
|
+
? `No working saved sign-in — open your AI app and ask a question to sign in, or run: ${AUTH_COMMAND}`
|
|
232
|
+
: `Not signed in yet — run this in a terminal: ${signInCommand(baseUrl)}`;
|
|
243
233
|
try {
|
|
244
234
|
const token = await deps.getSessionToken(baseUrl, sessionDir, envAccessToken, envSessionCookie);
|
|
245
235
|
if (token)
|
|
@@ -302,6 +292,7 @@ async function checkVersion(deps) {
|
|
|
302
292
|
/** Human-readable name for each check, used to prefix its "Skipped" line. */
|
|
303
293
|
const CHECK_NAMES = {
|
|
304
294
|
node: "Node.js version",
|
|
295
|
+
browser: "Chromium",
|
|
305
296
|
config: "Setup",
|
|
306
297
|
credential: "Credential store",
|
|
307
298
|
network: "Brightspace reachability",
|
|
@@ -319,6 +310,7 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
319
310
|
const checks = [];
|
|
320
311
|
const notes = [];
|
|
321
312
|
checks.push(checkNode(deps));
|
|
313
|
+
checks.push(await checkBrowser(deps));
|
|
322
314
|
const config = checkConfig(deps);
|
|
323
315
|
checks.push(config.result);
|
|
324
316
|
if (!config.ok) {
|
|
@@ -328,10 +320,11 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
328
320
|
checks.push(skip("courses", SKIPPED_SETUP));
|
|
329
321
|
}
|
|
330
322
|
else {
|
|
331
|
-
|
|
323
|
+
const credential = await checkCredential(deps, config.baseUrl, config.username, config.envAccessToken, config.envSessionCookie);
|
|
324
|
+
checks.push(credential);
|
|
332
325
|
notes.push(await describeTwoFactorShortcuts(deps, config.baseUrl, config.username));
|
|
333
326
|
checks.push(await checkNetwork(deps, config.baseUrl));
|
|
334
|
-
const session = await checkSession(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie);
|
|
327
|
+
const session = await checkSession(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie, credential.ok);
|
|
335
328
|
checks.push(session.result);
|
|
336
329
|
if (session.ok) {
|
|
337
330
|
checks.push(await checkCourses(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie));
|
|
@@ -346,6 +339,13 @@ export async function runDoctorChecks(overrides = {}) {
|
|
|
346
339
|
// ── CLI entry point ──────────────────────────────────────────────────
|
|
347
340
|
async function main() {
|
|
348
341
|
const result = await runDoctorChecks();
|
|
342
|
+
// For agents finishing an install: the same result, machine-readable, and
|
|
343
|
+
// nothing else on stdout.
|
|
344
|
+
if (process.argv.includes("--json")) {
|
|
345
|
+
console.log(JSON.stringify(result, null, 2));
|
|
346
|
+
process.exitCode = result.allOk ? 0 : 1;
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
349
|
console.log("");
|
|
350
350
|
console.log("Brightspace MCP Server — doctor");
|
|
351
351
|
console.log("");
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*
|
|
6
|
+
* Command-line flags for `setup`. Three modes share one entry point:
|
|
7
|
+
*
|
|
8
|
+
* - wizard (no mode flag): the interactive setup a person runs start to finish.
|
|
9
|
+
* - `--prepare`: everything an AI agent can do without the person — checks,
|
|
10
|
+
* Chromium, saved settings, registering the server in AI apps. Never reads
|
|
11
|
+
* stdin and never asks for a password.
|
|
12
|
+
* - `--sign-in`: the person's last step. Uses what prepare saved and asks
|
|
13
|
+
* only what needs them (password, how they confirm sign-in), then signs in.
|
|
14
|
+
*/
|
|
15
|
+
import { SCHOOL_PRESETS, presetForFlag } from "./presets.js";
|
|
16
|
+
const VALUE_FLAGS = new Set(["url", "username", "campus"]);
|
|
17
|
+
const BOOLEAN_FLAGS = new Set(["prepare", "sign-in", "json", "replace", "no-browser-install", "help"]);
|
|
18
|
+
export const SETUP_USAGE = `Usage: brightspace-mcp-server setup [--<school>] [options]
|
|
19
|
+
|
|
20
|
+
Schools: ${Object.keys(SCHOOL_PRESETS).map((flag) => `--${flag}`).join(", ")}
|
|
21
|
+
|
|
22
|
+
Modes:
|
|
23
|
+
(none) Interactive setup, start to finish
|
|
24
|
+
--prepare For AI agents: get everything ready without asking
|
|
25
|
+
for a password or opening a sign-in (no prompts)
|
|
26
|
+
--sign-in The last step, for the person: password and sign-in
|
|
27
|
+
|
|
28
|
+
Options:
|
|
29
|
+
--url <address> Your school's Brightspace address, when it has no flag above
|
|
30
|
+
--username <name> The username you sign in with (prepare, sign-in)
|
|
31
|
+
--campus <name> SUNY campus (prepare, sign-in)
|
|
32
|
+
--json Print the prepare result as JSON on stdout
|
|
33
|
+
--replace Replace a "brightspace" entry that runs something else
|
|
34
|
+
--no-browser-install Don't download Chromium during prepare
|
|
35
|
+
--help Show this help`;
|
|
36
|
+
/**
|
|
37
|
+
* Read `setup`'s flags. `argv` is the full process.argv: the subcommand name
|
|
38
|
+
* is skipped when present (`brightspace-mcp-server setup ...`) and absent for
|
|
39
|
+
* the `brightspace-setup` bin. Both `--url x` and `--url=x` work. An unknown
|
|
40
|
+
* flag is an error rather than ignored, so an agent that mistypes `--prepare`
|
|
41
|
+
* is told so instead of landing in a wizard that waits for typing.
|
|
42
|
+
*/
|
|
43
|
+
export function parseSetupArgs(argv = process.argv) {
|
|
44
|
+
const tokens = argv.slice(2);
|
|
45
|
+
if (tokens[0] === "setup")
|
|
46
|
+
tokens.shift();
|
|
47
|
+
const args = { mode: "wizard", json: false, replace: false, installBrowser: true, help: false };
|
|
48
|
+
const modes = [];
|
|
49
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
50
|
+
const token = tokens[i];
|
|
51
|
+
if (token === "-h") {
|
|
52
|
+
args.help = true;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (!token.startsWith("--"))
|
|
56
|
+
return { ok: false, error: `Unexpected argument: ${token}` };
|
|
57
|
+
const eq = token.indexOf("=");
|
|
58
|
+
const name = (eq === -1 ? token.slice(2) : token.slice(2, eq)).toLowerCase();
|
|
59
|
+
const inline = eq === -1 ? undefined : token.slice(eq + 1);
|
|
60
|
+
if (VALUE_FLAGS.has(name)) {
|
|
61
|
+
const value = inline ?? tokens[++i];
|
|
62
|
+
if (value === undefined || value.startsWith("--") || !value.trim()) {
|
|
63
|
+
return { ok: false, error: `--${name} needs a value.` };
|
|
64
|
+
}
|
|
65
|
+
if (name === "url")
|
|
66
|
+
args.url = value.trim();
|
|
67
|
+
else if (name === "username")
|
|
68
|
+
args.username = value.trim();
|
|
69
|
+
else
|
|
70
|
+
args.campus = value.trim();
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (inline !== undefined)
|
|
74
|
+
return { ok: false, error: `--${name} does not take a value.` };
|
|
75
|
+
if (BOOLEAN_FLAGS.has(name)) {
|
|
76
|
+
if (name === "prepare")
|
|
77
|
+
modes.push("prepare");
|
|
78
|
+
else if (name === "sign-in")
|
|
79
|
+
modes.push("sign-in");
|
|
80
|
+
else if (name === "json")
|
|
81
|
+
args.json = true;
|
|
82
|
+
else if (name === "replace")
|
|
83
|
+
args.replace = true;
|
|
84
|
+
else if (name === "no-browser-install")
|
|
85
|
+
args.installBrowser = false;
|
|
86
|
+
else
|
|
87
|
+
args.help = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const preset = presetForFlag(name);
|
|
91
|
+
if (!preset)
|
|
92
|
+
return { ok: false, error: `Unknown option: --${name}` };
|
|
93
|
+
if (args.preset && args.preset !== preset)
|
|
94
|
+
return { ok: false, error: "Pass only one school flag." };
|
|
95
|
+
args.school = name;
|
|
96
|
+
args.preset = preset;
|
|
97
|
+
}
|
|
98
|
+
if (modes.length > 1)
|
|
99
|
+
return { ok: false, error: "Use either --prepare or --sign-in, not both." };
|
|
100
|
+
if (modes.length === 1)
|
|
101
|
+
args.mode = modes[0];
|
|
102
|
+
if (args.preset && args.url)
|
|
103
|
+
return { ok: false, error: "Pass either a school flag or --url, not both." };
|
|
104
|
+
if (args.json && args.mode !== "prepare")
|
|
105
|
+
return { ok: false, error: "--json works only with --prepare." };
|
|
106
|
+
return { ok: true, args };
|
|
107
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*
|
|
6
|
+
* The AI apps setup can register the server in. The interactive wizard asks
|
|
7
|
+
* about each one; `setup --prepare` registers every one it finds without
|
|
8
|
+
* asking. Both walk the same list from detectMcpClients.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "node:fs";
|
|
11
|
+
import * as os from "node:os";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import { writeFileAtomicSync } from "../utils/atomic-write.js";
|
|
14
|
+
import { classifyRegistration, cliMcpClients, inspectCliMcpClient, isCliAvailable, registerCliMcpClient, serverCommand, } from "../utils/mcp-client-cli.js";
|
|
15
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
16
|
+
export function getClaudeDesktopConfigPath(platform = os.platform(), home = os.homedir(), env = process.env) {
|
|
17
|
+
if (platform === "darwin") {
|
|
18
|
+
return path.join(home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
|
|
19
|
+
}
|
|
20
|
+
if (platform === "win32") {
|
|
21
|
+
const appdata = env.APPDATA || path.join(home, "AppData", "Roaming");
|
|
22
|
+
return path.join(appdata, "Claude", "claude_desktop_config.json");
|
|
23
|
+
}
|
|
24
|
+
if (platform === "linux") {
|
|
25
|
+
return path.join(home, ".config", "Claude", "claude_desktop_config.json");
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
export function isChatGPTInstalled() {
|
|
30
|
+
const platform = os.platform();
|
|
31
|
+
if (platform === "darwin") {
|
|
32
|
+
return (fs.existsSync("/Applications/ChatGPT.app") ||
|
|
33
|
+
fs.existsSync(path.join(os.homedir(), "Applications", "ChatGPT.app")));
|
|
34
|
+
}
|
|
35
|
+
if (platform === "win32") {
|
|
36
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
37
|
+
return fs.existsSync(path.join(localAppData, "Programs", "ChatGPT", "ChatGPT.exe"));
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
export function getCursorConfigPath(home = os.homedir()) {
|
|
42
|
+
return path.join(home, ".cursor", "mcp.json");
|
|
43
|
+
}
|
|
44
|
+
export function getAntigravityConfigPath(home = os.homedir()) {
|
|
45
|
+
return path.join(home, ".gemini", "antigravity", "mcp_config.json");
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* A JSON value we can safely merge a server entry into. An array passes
|
|
49
|
+
* `typeof x === "object"` but drops every added key when it is stringified
|
|
50
|
+
* again, so it has to be rejected alongside `null`.
|
|
51
|
+
*/
|
|
52
|
+
function isJsonObject(value) {
|
|
53
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
/** How the client's existing "brightspace" entry compares with ours. */
|
|
56
|
+
export function inspectMcpClient(configPath) {
|
|
57
|
+
let config;
|
|
58
|
+
try {
|
|
59
|
+
config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// No file, or one configureMcpClient would replace anyway.
|
|
63
|
+
return { state: "missing" };
|
|
64
|
+
}
|
|
65
|
+
const servers = isJsonObject(config) ? config.mcpServers : undefined;
|
|
66
|
+
return classifyRegistration(isJsonObject(servers) ? servers.brightspace : undefined);
|
|
67
|
+
}
|
|
68
|
+
export function configureMcpClient(configPath, warn = console.log) {
|
|
69
|
+
let config = {};
|
|
70
|
+
// Read existing config if present
|
|
71
|
+
if (fs.existsSync(configPath)) {
|
|
72
|
+
let parsed;
|
|
73
|
+
let readable = true;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
readable = false;
|
|
79
|
+
}
|
|
80
|
+
if (isJsonObject(parsed)) {
|
|
81
|
+
config = parsed;
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Unparseable, or valid JSON that is not an object (null, an array, a
|
|
85
|
+
// bare string). Merging into it would either throw or silently discard
|
|
86
|
+
// the entry we just reported as written, so start fresh and say so.
|
|
87
|
+
warn(yellow(` Warning: existing config was ${readable ? "not a JSON object" : "invalid"}, creating new one.`));
|
|
88
|
+
config = {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Same reasoning for the servers map itself, which is hand-edited far more
|
|
92
|
+
// often than the file around it.
|
|
93
|
+
const servers = isJsonObject(config.mcpServers) ? config.mcpServers : {};
|
|
94
|
+
config.mcpServers = servers;
|
|
95
|
+
// Add/update brightspace entry
|
|
96
|
+
const [command, ...args] = serverCommand();
|
|
97
|
+
servers["brightspace"] = { command, args };
|
|
98
|
+
// Ensure parent directory exists
|
|
99
|
+
const dir = path.dirname(configPath);
|
|
100
|
+
if (!fs.existsSync(dir)) {
|
|
101
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
102
|
+
}
|
|
103
|
+
// This file holds every MCP server the user has configured, not just ours.
|
|
104
|
+
// A plain write truncates it first, so a write that fails part way through
|
|
105
|
+
// (a full disk, an I/O error) would leave the user with no MCP servers at
|
|
106
|
+
// all. Staging and renaming leaves either the old file or the new one.
|
|
107
|
+
// The existing permissions are carried over, since the rename would
|
|
108
|
+
// otherwise replace them with the umask default.
|
|
109
|
+
let mode;
|
|
110
|
+
try {
|
|
111
|
+
if (fs.existsSync(configPath))
|
|
112
|
+
mode = fs.statSync(configPath).mode & 0o777;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Unreadable metadata is not a reason to skip the write.
|
|
116
|
+
}
|
|
117
|
+
writeFileAtomicSync(configPath, JSON.stringify(config, null, 2) + "\n", mode === undefined ? {} : { mode });
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
export function detectMcpClients(overrides = {}) {
|
|
121
|
+
const deps = {
|
|
122
|
+
platform: os.platform(),
|
|
123
|
+
home: os.homedir(),
|
|
124
|
+
env: process.env,
|
|
125
|
+
existsSync: fs.existsSync,
|
|
126
|
+
warn: console.log,
|
|
127
|
+
...overrides,
|
|
128
|
+
};
|
|
129
|
+
const targets = [];
|
|
130
|
+
const fileTarget = (id, name, configPath, question) => ({
|
|
131
|
+
id,
|
|
132
|
+
name,
|
|
133
|
+
detected: deps.existsSync(path.dirname(configPath)),
|
|
134
|
+
question,
|
|
135
|
+
inspect: () => inspectMcpClient(configPath),
|
|
136
|
+
write: () => configureMcpClient(configPath, deps.warn),
|
|
137
|
+
});
|
|
138
|
+
const claudePath = getClaudeDesktopConfigPath(deps.platform, deps.home, deps.env);
|
|
139
|
+
if (claudePath) {
|
|
140
|
+
targets.push(fileTarget("claude-desktop", "Claude Desktop", claudePath, "Would you like to automatically configure Claude Desktop? Highly recommended. (yes/no): "));
|
|
141
|
+
}
|
|
142
|
+
const cursorPath = getCursorConfigPath(deps.home);
|
|
143
|
+
if (deps.existsSync(path.dirname(cursorPath))) {
|
|
144
|
+
targets.push(fileTarget("cursor", "Cursor", cursorPath, "Cursor detected. Would you like to configure it too? (yes/no): "));
|
|
145
|
+
}
|
|
146
|
+
const antigravityPath = getAntigravityConfigPath(deps.home);
|
|
147
|
+
if (deps.existsSync(path.dirname(antigravityPath))) {
|
|
148
|
+
targets.push(fileTarget("antigravity", "Antigravity", antigravityPath, "Antigravity detected. Would you like to configure it too? (yes/no): "));
|
|
149
|
+
}
|
|
150
|
+
for (const client of cliMcpClients(deps.platform)) {
|
|
151
|
+
if (!isCliAvailable(client, deps.runCommand))
|
|
152
|
+
continue;
|
|
153
|
+
targets.push({
|
|
154
|
+
id: client.id,
|
|
155
|
+
name: client.displayName,
|
|
156
|
+
detected: true,
|
|
157
|
+
question: `${client.displayName} detected. Configure it automatically? Highly recommended. (yes/no): `,
|
|
158
|
+
inspect: () => inspectCliMcpClient(client, deps.runCommand),
|
|
159
|
+
write: (replace) => registerCliMcpClient(client, { replace }, deps.runCommand),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
return targets;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Register the server in every given app without asking. An entry that runs
|
|
166
|
+
* something else is left alone unless `replace` is set, and a write that
|
|
167
|
+
* fails (an agent's sandbox may block another app's settings folder) is
|
|
168
|
+
* reported for that app instead of stopping the rest.
|
|
169
|
+
*/
|
|
170
|
+
export function registerAll(targets, { replace }) {
|
|
171
|
+
return targets.map((target) => {
|
|
172
|
+
const base = { id: target.id, name: target.name };
|
|
173
|
+
try {
|
|
174
|
+
const existing = target.inspect();
|
|
175
|
+
if (existing.state === "current")
|
|
176
|
+
return { ...base, outcome: "current" };
|
|
177
|
+
if (existing.state === "different" && !replace) {
|
|
178
|
+
return { ...base, outcome: "different_left", current: existing.current };
|
|
179
|
+
}
|
|
180
|
+
if (!target.write(existing.state === "different")) {
|
|
181
|
+
return { ...base, outcome: "failed", message: `${target.name} did not accept the registration.` };
|
|
182
|
+
}
|
|
183
|
+
return { ...base, outcome: existing.state === "different" ? "replaced" : "added" };
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return { ...base, outcome: "failed", message: error instanceof Error ? error.message : String(error) };
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*
|
|
6
|
+
* The settings setup saves, shared by the wizard, prepare and sign-in.
|
|
7
|
+
*/
|
|
8
|
+
import { configStoreExists, loadConfigStore } from "../utils/config-store.js";
|
|
9
|
+
// ── URL validation ─────────────────────────────────────────────────
|
|
10
|
+
export function normalizeUrl(input) {
|
|
11
|
+
let url = input.trim();
|
|
12
|
+
// Strip trailing slashes
|
|
13
|
+
url = url.replace(/\/+$/, "");
|
|
14
|
+
// Auto-prepend https://
|
|
15
|
+
if (!/^https?:\/\//i.test(url)) {
|
|
16
|
+
url = `https://${url}`;
|
|
17
|
+
}
|
|
18
|
+
// Upgrade http:// to https://
|
|
19
|
+
url = url.replace(/^http:\/\//i, "https://");
|
|
20
|
+
return url;
|
|
21
|
+
}
|
|
22
|
+
export function isValidUrl(url) {
|
|
23
|
+
try {
|
|
24
|
+
const parsed = new URL(url);
|
|
25
|
+
return parsed.protocol === "https:" && !parsed.username && !parsed.password;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The settings already on disk, or null when there are none to read. */
|
|
32
|
+
export function readExistingConfig() {
|
|
33
|
+
try {
|
|
34
|
+
return configStoreExists() ? loadConfigStore() : null;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// An unreadable config is replaced by the setup values.
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function sameSchool(stored, chosen) {
|
|
42
|
+
// A config that never recorded a school (environment-driven installs) is
|
|
43
|
+
// not a *different* school, so its settings are still ours to keep.
|
|
44
|
+
if (!stored)
|
|
45
|
+
return true;
|
|
46
|
+
try {
|
|
47
|
+
return new URL(stored).origin === new URL(chosen).origin;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Merge the wizard's answers over the settings already saved.
|
|
55
|
+
*
|
|
56
|
+
* `saveConfigStore` replaces the whole file, and setup is the documented way
|
|
57
|
+
* to update a saved password — so it runs again on configurations that carry
|
|
58
|
+
* settings it never prompts for: the SUNY campus, course filters, a custom
|
|
59
|
+
* session directory or token TTL. Writing only the answers deleted all of
|
|
60
|
+
* them; most visibly, a SUNY user who reran plain `setup` lost the campus
|
|
61
|
+
* that lets sign-in skip the shared campus picker.
|
|
62
|
+
*
|
|
63
|
+
* Settings are carried only within one school, since course ids and the
|
|
64
|
+
* campus belong to a single tenant.
|
|
65
|
+
*/
|
|
66
|
+
export function buildConfigToSave(existing, answers) {
|
|
67
|
+
const carried = existing && sameSchool(existing.baseUrl, answers.baseUrl) ? existing : null;
|
|
68
|
+
const config = {
|
|
69
|
+
...carried,
|
|
70
|
+
baseUrl: answers.baseUrl,
|
|
71
|
+
username: answers.username,
|
|
72
|
+
// Always the freshly typed one: a carried v1 plaintext password would
|
|
73
|
+
// otherwise be the value written to the native store.
|
|
74
|
+
password: answers.password,
|
|
75
|
+
// Only ever the freshly typed one. Never read back out of `carried`: the
|
|
76
|
+
// enrollment lives in the credential store, never in config.json, so a
|
|
77
|
+
// value here could only have come from this run.
|
|
78
|
+
...(answers.totpUri ? { totpUri: answers.totpUri } : {}),
|
|
79
|
+
headless: answers.headless,
|
|
80
|
+
};
|
|
81
|
+
if (answers.rememberMfa !== undefined)
|
|
82
|
+
config.rememberMfa = answers.rememberMfa;
|
|
83
|
+
// Setup no longer offers passwordless sign-in and always saves a password,
|
|
84
|
+
// so a carried `passwordless: true` would make sign-in ignore that password.
|
|
85
|
+
// D2L_PASSWORDLESS still turns it on for anyone who wants it.
|
|
86
|
+
delete config.passwordless;
|
|
87
|
+
if (answers.campus)
|
|
88
|
+
config.campus = answers.campus;
|
|
89
|
+
return config;
|
|
90
|
+
}
|