brightspace-mcp-server 3.9.17 → 3.9.18
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 -0
- package/build/doctor.js +331 -0
- package/build/index.js +3 -0
- package/build/setup.js +24 -16
- package/build/utils/commands.js +2 -0
- package/build/utils/config.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -110,6 +110,7 @@ How often you're asked is up to your school, not this tool. Everything else abou
|
|
|
110
110
|
|
|
111
111
|
## Something not working?
|
|
112
112
|
|
|
113
|
+
- 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.
|
|
113
114
|
- **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).
|
|
114
115
|
- **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
|
|
115
116
|
- **Still stuck?** [Open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) and paste what the terminal printed.
|
package/build/doctor.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Brightspace MCP Server
|
|
4
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
5
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
6
|
+
*
|
|
7
|
+
* `doctor` — a beginner-facing diagnostic. It runs a fixed, ordered list of
|
|
8
|
+
* checks (Node version, saved setup, credential store, school reachability,
|
|
9
|
+
* saved sign-in, one real API call, installed version) and prints one line
|
|
10
|
+
* per check: a checkmark plus a short description, or a cross plus exactly
|
|
11
|
+
* one plain-English next step.
|
|
12
|
+
*
|
|
13
|
+
* Every check after "setup found" depends on the ones before it, so a
|
|
14
|
+
* missing config or a broken sign-in is reported once, with everything
|
|
15
|
+
* downstream marked "skipped" instead of printing a cascade of confusing
|
|
16
|
+
* errors for the same root cause.
|
|
17
|
+
*
|
|
18
|
+
* Like `setup` and `auth`, this never prints a secret: no password, token,
|
|
19
|
+
* or cookie value appears in any line.
|
|
20
|
+
*/
|
|
21
|
+
import { readFileSync } from "node:fs";
|
|
22
|
+
import * as path from "node:path";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
24
|
+
import * as os from "node:os";
|
|
25
|
+
import { configStoreExists, loadConfigStore, } from "./utils/config-store.js";
|
|
26
|
+
import { accountSessionDirectory, expandTilde, readEnvSecret, parseSessionCookieEnv } from "./utils/config.js";
|
|
27
|
+
import { getStoredPassword } from "./auth/credential-store.js";
|
|
28
|
+
import { discoverVersions } from "./api/version-discovery.js";
|
|
29
|
+
import { TokenManager } from "./auth/token-manager.js";
|
|
30
|
+
import { SessionStore } from "./auth/session-store.js";
|
|
31
|
+
import { D2LApiClient } from "./api/client.js";
|
|
32
|
+
import { fetchLatestVersion, isNewerVersion, safeVersionLabel, installKindOf, } from "./utils/update-checker.js";
|
|
33
|
+
import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, } from "./utils/commands.js";
|
|
34
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
35
|
+
const __dirname = path.dirname(__filename);
|
|
36
|
+
function readInstalledVersion() {
|
|
37
|
+
try {
|
|
38
|
+
const pkg = JSON.parse(readFileSync(path.resolve(__dirname, "..", "package.json"), "utf-8"));
|
|
39
|
+
return pkg.version ?? "0.0.0";
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return "0.0.0";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* doctor only ever reads a saved session — it must never write session.json,
|
|
47
|
+
* trash it, or take the write lock a v1-record migration uses (that lock can
|
|
48
|
+
* also throw AuthenticationInProgressError while a real sign-in holds it).
|
|
49
|
+
* SessionStore.peek() is the read-only sibling of load() that does none of
|
|
50
|
+
* that, so every write-shaped method here is a no-op and load() delegates to
|
|
51
|
+
* peek() instead of the real load(). Exported for tests, which pass a fake
|
|
52
|
+
* in place of a real SessionStore to prove the no-op wiring.
|
|
53
|
+
*/
|
|
54
|
+
export function readOnlySessionStore(store) {
|
|
55
|
+
return {
|
|
56
|
+
load: () => store.peek(),
|
|
57
|
+
save: async () => { },
|
|
58
|
+
clear: async () => { },
|
|
59
|
+
saveIfCurrent: async () => true,
|
|
60
|
+
clearIfCurrent: async () => true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** A TokenManager that can mint from a saved cookie but can never write, clear, or migrate session.json. */
|
|
64
|
+
function readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie) {
|
|
65
|
+
return new TokenManager({
|
|
66
|
+
baseUrl,
|
|
67
|
+
envAccessToken,
|
|
68
|
+
envSessionCookie,
|
|
69
|
+
sessionStore: readOnlySessionStore(new SessionStore(sessionDir)),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
function defaultDoctorDeps() {
|
|
73
|
+
return {
|
|
74
|
+
nodeVersion: process.version,
|
|
75
|
+
platform: process.platform,
|
|
76
|
+
env: process.env,
|
|
77
|
+
configStoreExists,
|
|
78
|
+
loadConfigStore,
|
|
79
|
+
getStoredPassword,
|
|
80
|
+
discoverVersions,
|
|
81
|
+
getSessionToken: (baseUrl, sessionDir, envAccessToken, envSessionCookie) => readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie).getToken(),
|
|
82
|
+
countCourses: async (baseUrl, sessionDir, envAccessToken, envSessionCookie) => {
|
|
83
|
+
const apiClient = new D2LApiClient({
|
|
84
|
+
baseUrl,
|
|
85
|
+
tokenManager: readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie),
|
|
86
|
+
});
|
|
87
|
+
const coursesPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3");
|
|
88
|
+
const response = await apiClient.get(coursesPath);
|
|
89
|
+
return { count: response?.Items?.length ?? 0, hasMore: Boolean(response?.PagingInfo?.HasMoreItems) };
|
|
90
|
+
},
|
|
91
|
+
fetchLatestVersion,
|
|
92
|
+
installKind: installKindOf(),
|
|
93
|
+
installedVersion: readInstalledVersion(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function pass(id, message) {
|
|
97
|
+
return { id, ok: true, line: `✓ ${message}` };
|
|
98
|
+
}
|
|
99
|
+
function problem(id, message) {
|
|
100
|
+
return { id, ok: false, line: `✗ ${message}` };
|
|
101
|
+
}
|
|
102
|
+
// ── Individual checks ────────────────────────────────────────────────
|
|
103
|
+
function checkNode(deps) {
|
|
104
|
+
const major = Number(deps.nodeVersion.replace(/^v/, "").split(".")[0]);
|
|
105
|
+
if (Number.isFinite(major) && major >= 20) {
|
|
106
|
+
return pass("node", `Node.js ${deps.nodeVersion} (20 or newer)`);
|
|
107
|
+
}
|
|
108
|
+
return problem("node", `Node.js ${deps.nodeVersion} is too old — install Node 20 or newer from https://nodejs.org/, then try again.`);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Checks the resolved configuration — config.json AND the same env overrides
|
|
112
|
+
* loadConfig() honours (D2L_BASE_URL, D2L_USERNAME, D2L_ACCESS_TOKEN,
|
|
113
|
+
* D2L_SESSION_COOKIE) — since a Docker/headless setup legitimately has no
|
|
114
|
+
* config.json at all and is configured entirely through the environment.
|
|
115
|
+
* "Neither config.json nor the environment says anything" is the single most
|
|
116
|
+
* common first-run state for everyone else.
|
|
117
|
+
*/
|
|
118
|
+
function checkConfig(deps) {
|
|
119
|
+
let store = null;
|
|
120
|
+
try {
|
|
121
|
+
if (deps.configStoreExists())
|
|
122
|
+
store = deps.loadConfigStore();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
store = null;
|
|
126
|
+
}
|
|
127
|
+
let envAccessToken;
|
|
128
|
+
let envSessionCookie;
|
|
129
|
+
try {
|
|
130
|
+
envAccessToken = readEnvSecret(deps.env.D2L_ACCESS_TOKEN, "D2L_ACCESS_TOKEN");
|
|
131
|
+
const rawCookie = readEnvSecret(deps.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
|
|
132
|
+
envSessionCookie = rawCookie ? parseSessionCookieEnv(rawCookie) : undefined;
|
|
133
|
+
}
|
|
134
|
+
catch (error) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
result: problem("config", error instanceof Error ? error.message : "D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE is malformed."),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const rawBaseUrl = deps.env.D2L_BASE_URL || store?.baseUrl;
|
|
141
|
+
const username = deps.env.D2L_USERNAME || store?.username;
|
|
142
|
+
const hasEnvCredential = Boolean(envAccessToken || envSessionCookie);
|
|
143
|
+
// A username is required only for the normal stored-password flow: an env
|
|
144
|
+
// credential signs in on its own and never needs one.
|
|
145
|
+
if (!rawBaseUrl || (!username && !hasEnvCredential)) {
|
|
146
|
+
return { ok: false, result: problem("config", `No setup found — run: ${SETUP_COMMAND}`) };
|
|
147
|
+
}
|
|
148
|
+
let baseUrl;
|
|
149
|
+
try {
|
|
150
|
+
baseUrl = new URL(rawBaseUrl).origin;
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { ok: false, result: problem("config", `The configured Brightspace URL is invalid — run: ${SETUP_COMMAND}`) };
|
|
154
|
+
}
|
|
155
|
+
const sessionRoot = deps.env.D2L_SESSION_DIR
|
|
156
|
+
? expandTilde(deps.env.D2L_SESSION_DIR)
|
|
157
|
+
: store?.sessionDir
|
|
158
|
+
? expandTilde(store.sessionDir)
|
|
159
|
+
: path.join(os.homedir(), ".d2l-session");
|
|
160
|
+
const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
|
|
161
|
+
const label = username ? `signed in as ${username} at ${baseUrl}` : `configured for ${baseUrl} via environment variables`;
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
result: pass("config", `Setup found — ${label}`),
|
|
165
|
+
baseUrl,
|
|
166
|
+
username,
|
|
167
|
+
sessionDir,
|
|
168
|
+
envAccessToken,
|
|
169
|
+
envSessionCookie,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function checkCredential(deps, baseUrl, username, envAccessToken, envSessionCookie) {
|
|
173
|
+
// Never print the value — only which variable is supplying it.
|
|
174
|
+
if (envAccessToken)
|
|
175
|
+
return pass("credential", "Signed in via the D2L_ACCESS_TOKEN environment variable (no password needed)");
|
|
176
|
+
if (envSessionCookie)
|
|
177
|
+
return pass("credential", "Signed in via the D2L_SESSION_COOKIE environment variable (no password needed)");
|
|
178
|
+
if (!username)
|
|
179
|
+
return problem("credential", `No username configured — run: ${SETUP_COMMAND}`);
|
|
180
|
+
try {
|
|
181
|
+
const password = await deps.getStoredPassword(baseUrl, username);
|
|
182
|
+
if (password)
|
|
183
|
+
return pass("credential", "Password saved in your operating system's credential store");
|
|
184
|
+
return problem("credential", `No saved password found for this account — run: ${SETUP_COMMAND}`);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
// On Linux the store itself (secret-tool / an unlocked Secret Service)
|
|
188
|
+
// is usually the problem, and the thrown error already explains that in
|
|
189
|
+
// plain English. Elsewhere, re-running setup is the one fix that covers
|
|
190
|
+
// every native-store failure a student could hit.
|
|
191
|
+
if (deps.platform === "linux") {
|
|
192
|
+
return problem("credential", error instanceof Error ? error.message : "The Linux credential store is locked or unavailable. Unlock your keyring and try again.");
|
|
193
|
+
}
|
|
194
|
+
return problem("credential", `Run setup again: ${SETUP_COMMAND}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
async function checkNetwork(deps, baseUrl) {
|
|
198
|
+
try {
|
|
199
|
+
await deps.discoverVersions(baseUrl);
|
|
200
|
+
return pass("network", `${baseUrl} is reachable`);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return problem("network", `Could not reach ${baseUrl} — check your internet connection and the school address, then run doctor again.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
async function checkSession(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
|
|
207
|
+
const failMessage = `No working saved sign-in — open your AI app and ask a question to sign in, or run: ${AUTH_COMMAND}`;
|
|
208
|
+
try {
|
|
209
|
+
const token = await deps.getSessionToken(baseUrl, sessionDir, envAccessToken, envSessionCookie);
|
|
210
|
+
if (token)
|
|
211
|
+
return { ok: true, result: pass("session", "Saved sign-in works — a token was issued without opening a browser") };
|
|
212
|
+
return { ok: false, result: problem("session", failMessage) };
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { ok: false, result: problem("session", failMessage) };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Trims an upstream error message down to something safe to show a beginner:
|
|
220
|
+
* a 403/404 body can be a whole HTML page, and either way the raw text can
|
|
221
|
+
* carry the endpoint URL. Collapses newlines, drops URLs, and caps length.
|
|
222
|
+
*/
|
|
223
|
+
function sanitizeErrorDetail(message) {
|
|
224
|
+
const noUrls = message.replace(/https?:\/\/\S+/gi, "");
|
|
225
|
+
const singleLine = noUrls.replace(/\s+/g, " ").trim();
|
|
226
|
+
const LIMIT = 120;
|
|
227
|
+
return singleLine.length > LIMIT ? `${singleLine.slice(0, LIMIT).trimEnd()}…` : singleLine;
|
|
228
|
+
}
|
|
229
|
+
async function checkCourses(deps, baseUrl, sessionDir, envAccessToken, envSessionCookie) {
|
|
230
|
+
try {
|
|
231
|
+
const { count, hasMore } = await deps.countCourses(baseUrl, sessionDir, envAccessToken, envSessionCookie);
|
|
232
|
+
const label = hasMore ? `at least ${count}` : `${count}`;
|
|
233
|
+
return pass("courses", `Found ${label} course${count === 1 ? "" : "s"} on Brightspace`);
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
const detail = error instanceof Error ? sanitizeErrorDetail(error.message) : "an unknown error";
|
|
237
|
+
return problem("courses", `Could not load your courses (${detail}) — run doctor again after checking your connection.`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
async function checkVersion(deps) {
|
|
241
|
+
let latest = null;
|
|
242
|
+
try {
|
|
243
|
+
latest = await deps.fetchLatestVersion();
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
latest = null;
|
|
247
|
+
}
|
|
248
|
+
const sourceNote = deps.installKind === "source-checkout" ? " (source checkout)" : "";
|
|
249
|
+
// Offline, or the registry hiccuped: never treat "couldn't check" as a
|
|
250
|
+
// failure — there is nothing actionable to tell a student here.
|
|
251
|
+
if (latest === null) {
|
|
252
|
+
return pass("version", `Running v${safeVersionLabel(deps.installedVersion)}${sourceNote} — could not check for a newer version (you may be offline)`);
|
|
253
|
+
}
|
|
254
|
+
if (!isNewerVersion(latest, deps.installedVersion)) {
|
|
255
|
+
return pass("version", `Running the latest version (v${safeVersionLabel(deps.installedVersion)})${sourceNote}`);
|
|
256
|
+
}
|
|
257
|
+
const from = safeVersionLabel(deps.installedVersion);
|
|
258
|
+
const to = safeVersionLabel(latest);
|
|
259
|
+
const nextStep = deps.installKind === "source-checkout"
|
|
260
|
+
? "pull the latest changes and run npm run build"
|
|
261
|
+
: deps.installKind === "npm-install"
|
|
262
|
+
? `run: ${GLOBAL_INSTALL_COMMAND}`
|
|
263
|
+
: `it updates automatically next time, or run: ${CLEAR_NPX_CACHE_COMMAND}`;
|
|
264
|
+
return problem("version", `A newer version is available (v${from} to v${to}) — ${nextStep}.`);
|
|
265
|
+
}
|
|
266
|
+
// ── Orchestration ────────────────────────────────────────────────────
|
|
267
|
+
/** Human-readable name for each check, used to prefix its "Skipped" line. */
|
|
268
|
+
const CHECK_NAMES = {
|
|
269
|
+
node: "Node.js version",
|
|
270
|
+
config: "Setup",
|
|
271
|
+
credential: "Credential store",
|
|
272
|
+
network: "Brightspace reachability",
|
|
273
|
+
session: "Saved sign-in",
|
|
274
|
+
courses: "Course list",
|
|
275
|
+
version: "Version",
|
|
276
|
+
};
|
|
277
|
+
const SKIPPED_SETUP = `finish setup first, then run doctor again: ${SETUP_COMMAND}`;
|
|
278
|
+
const SKIPPED_SESSION = "fix the saved sign-in step above first, then run doctor again.";
|
|
279
|
+
function skip(id, reason) {
|
|
280
|
+
return problem(id, `${CHECK_NAMES[id]}: Skipped — ${reason}`);
|
|
281
|
+
}
|
|
282
|
+
export async function runDoctorChecks(overrides = {}) {
|
|
283
|
+
const deps = { ...defaultDoctorDeps(), ...overrides };
|
|
284
|
+
const checks = [];
|
|
285
|
+
checks.push(checkNode(deps));
|
|
286
|
+
const config = checkConfig(deps);
|
|
287
|
+
checks.push(config.result);
|
|
288
|
+
if (!config.ok) {
|
|
289
|
+
checks.push(skip("credential", SKIPPED_SETUP));
|
|
290
|
+
checks.push(skip("network", SKIPPED_SETUP));
|
|
291
|
+
checks.push(skip("session", SKIPPED_SETUP));
|
|
292
|
+
checks.push(skip("courses", SKIPPED_SETUP));
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
checks.push(await checkCredential(deps, config.baseUrl, config.username, config.envAccessToken, config.envSessionCookie));
|
|
296
|
+
checks.push(await checkNetwork(deps, config.baseUrl));
|
|
297
|
+
const session = await checkSession(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie);
|
|
298
|
+
checks.push(session.result);
|
|
299
|
+
if (session.ok) {
|
|
300
|
+
checks.push(await checkCourses(deps, config.baseUrl, config.sessionDir, config.envAccessToken, config.envSessionCookie));
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
checks.push(skip("courses", SKIPPED_SESSION));
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
checks.push(await checkVersion(deps));
|
|
307
|
+
return { checks, allOk: checks.every((c) => c.ok) };
|
|
308
|
+
}
|
|
309
|
+
// ── CLI entry point ──────────────────────────────────────────────────
|
|
310
|
+
async function main() {
|
|
311
|
+
const result = await runDoctorChecks();
|
|
312
|
+
console.log("");
|
|
313
|
+
console.log("Brightspace MCP Server — doctor");
|
|
314
|
+
console.log("");
|
|
315
|
+
for (const check of result.checks)
|
|
316
|
+
console.log(` ${check.line}`);
|
|
317
|
+
console.log("");
|
|
318
|
+
console.log(result.allOk
|
|
319
|
+
? "Everything looks good — open your AI app and ask a question."
|
|
320
|
+
: "Fix the ✗ items above, then run doctor again.");
|
|
321
|
+
console.log("");
|
|
322
|
+
process.exitCode = result.allOk ? 0 : 1;
|
|
323
|
+
}
|
|
324
|
+
// VITEST is set only by the test runner, which imports this module for the
|
|
325
|
+
// functions above and must never run the real checks against this machine.
|
|
326
|
+
if (!process.env.VITEST) {
|
|
327
|
+
main().catch((err) => {
|
|
328
|
+
console.error("doctor failed:", err instanceof Error ? err.message : String(err));
|
|
329
|
+
process.exitCode = 1;
|
|
330
|
+
});
|
|
331
|
+
}
|
package/build/index.js
CHANGED
|
@@ -37,6 +37,9 @@ if (subcommand === 'setup') {
|
|
|
37
37
|
else if (subcommand === 'auth') {
|
|
38
38
|
await import('./auth-cli.js');
|
|
39
39
|
}
|
|
40
|
+
else if (subcommand === 'doctor') {
|
|
41
|
+
await import('./doctor.js');
|
|
42
|
+
}
|
|
40
43
|
else {
|
|
41
44
|
// ── MCP Server (default) ────────────────────────────────────────────
|
|
42
45
|
// CRITICAL: Enable stdout guard IMMEDIATELY to prevent corruption of stdio transport
|
package/build/setup.js
CHANGED
|
@@ -15,7 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
import { configStoreExists, getConfigStorePath, loadConfigStore, } from "./utils/config-store.js";
|
|
16
16
|
import { saveSecureConfig } from "./utils/secure-config.js";
|
|
17
17
|
import { writeFileAtomicSync } from "./utils/atomic-write.js";
|
|
18
|
-
import { AUTH_COMMAND } from "./utils/commands.js";
|
|
18
|
+
import { AUTH_COMMAND, DOCTOR_COMMAND } from "./utils/commands.js";
|
|
19
19
|
import { cliMcpClients, configureCliMcpClient, isCliAvailable, } from "./utils/mcp-client-cli.js";
|
|
20
20
|
// ANSI helpers
|
|
21
21
|
const bold = (s) => `\x1b[1m${s}\x1b[0m`;
|
|
@@ -387,11 +387,12 @@ async function main() {
|
|
|
387
387
|
console.log("");
|
|
388
388
|
}
|
|
389
389
|
else {
|
|
390
|
+
console.log(dim(" Your school's Brightspace address — the web address you open to see your courses."));
|
|
390
391
|
while (!baseUrl) {
|
|
391
|
-
const raw = await ask(rl, "What is your Brightspace
|
|
392
|
+
const raw = await ask(rl, "What is your Brightspace address? (e.g., purdue.brightspace.com): ");
|
|
392
393
|
const normalized = normalizeUrl(raw);
|
|
393
394
|
if (!raw || !isValidUrl(normalized)) {
|
|
394
|
-
console.log(yellow(" Please enter a valid
|
|
395
|
+
console.log(yellow(" Please enter a valid address, like purdue.brightspace.com"));
|
|
395
396
|
continue;
|
|
396
397
|
}
|
|
397
398
|
baseUrl = normalized;
|
|
@@ -416,10 +417,13 @@ async function main() {
|
|
|
416
417
|
// ── Step 2: Username ─────────────────────────────────────────────
|
|
417
418
|
const usernamePrompt = preset
|
|
418
419
|
? `What is your ${preset.usernameLabel}? `
|
|
419
|
-
: "What
|
|
420
|
+
: "What username do you use to sign in to Brightspace? ";
|
|
420
421
|
if (preset?.usernameHint) {
|
|
421
422
|
console.log(dim(` ${preset.usernameHint}`));
|
|
422
423
|
}
|
|
424
|
+
else {
|
|
425
|
+
console.log(dim(" The same username you type on your school's Brightspace sign-in page."));
|
|
426
|
+
}
|
|
423
427
|
let username = "";
|
|
424
428
|
while (!username) {
|
|
425
429
|
username = await ask(rl, usernamePrompt);
|
|
@@ -431,6 +435,7 @@ async function main() {
|
|
|
431
435
|
// ── Step 3: Password (hidden) ────────────────────────────────────
|
|
432
436
|
// Close the rl temporarily since askPassword manages its own
|
|
433
437
|
rl.close();
|
|
438
|
+
console.log(dim(" The password you use to sign in. It won't be shown as you type — asterisks stand in for each character."));
|
|
434
439
|
const passwordPrompt = preset
|
|
435
440
|
? `What is your ${preset.name} password? `
|
|
436
441
|
: "What is your Brightspace password? ";
|
|
@@ -449,20 +454,21 @@ async function main() {
|
|
|
449
454
|
});
|
|
450
455
|
const configuredClients = [];
|
|
451
456
|
// ── Step 4: MFA info ─────────────────────────────────────────────
|
|
457
|
+
console.log(dim(" Your school may ask you to approve the sign-in a second way — on your phone, with a code, or something else."));
|
|
452
458
|
if (preset) {
|
|
453
|
-
console.log(dim(`
|
|
459
|
+
console.log(dim(` ${preset.mfaNote}`));
|
|
454
460
|
}
|
|
455
461
|
else {
|
|
456
|
-
console.log(dim("
|
|
462
|
+
console.log(dim(" You'll be prompted to approve the sign-in on your phone during auth."));
|
|
457
463
|
}
|
|
458
464
|
console.log("");
|
|
459
465
|
// Only two outcomes exist: a hidden browser or a visible one. Which kind of
|
|
460
466
|
// MFA you have is detected at sign-in time, so offering "approve a prompt"
|
|
461
467
|
// and "type a code" as separate choices would be a distinction the code does
|
|
462
468
|
// not make, and picking between them would change nothing on disk.
|
|
463
|
-
console.log("
|
|
464
|
-
console.log(" 1.
|
|
465
|
-
console.log(" 2.
|
|
469
|
+
console.log(" When your school asks you to approve the sign-in, how will you do it?");
|
|
470
|
+
console.log(" 1. I approve on my phone, or I type a code from an authenticator app (recommended)");
|
|
471
|
+
console.log(" 2. Something else — open a browser window I can use");
|
|
466
472
|
let savedHeadless;
|
|
467
473
|
try {
|
|
468
474
|
savedHeadless = configStoreExists() ? loadConfigStore().headless : undefined;
|
|
@@ -495,23 +501,23 @@ async function main() {
|
|
|
495
501
|
console.log(green(" Config saved to: " + getConfigStorePath()));
|
|
496
502
|
console.log("");
|
|
497
503
|
// ── Step 6: Authenticate now? ────────────────────────────────────
|
|
498
|
-
const authNow = await ask(rl2, "
|
|
504
|
+
const authNow = await ask(rl2, "Sign in now, so everything is ready to go? (yes/no): ");
|
|
499
505
|
if (/^y(es)?$/i.test(authNow)) {
|
|
500
506
|
rl2.close();
|
|
501
507
|
console.log("");
|
|
502
|
-
console.log(dim("
|
|
508
|
+
console.log(dim(" Signing you in..."));
|
|
503
509
|
console.log("");
|
|
504
510
|
const ok = await runAuth();
|
|
505
511
|
rl2 = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
506
512
|
if (ok) {
|
|
507
|
-
console.log(green("\n
|
|
513
|
+
console.log(green("\n Signed in!"));
|
|
508
514
|
}
|
|
509
515
|
else {
|
|
510
|
-
console.log(yellow(`\n
|
|
516
|
+
console.log(yellow(`\n Sign-in didn't finish. You can try again later by running: ${AUTH_COMMAND}`));
|
|
511
517
|
}
|
|
512
518
|
}
|
|
513
519
|
else {
|
|
514
|
-
console.log(dim(` You can
|
|
520
|
+
console.log(dim(` You can sign in later by running: ${AUTH_COMMAND}`));
|
|
515
521
|
}
|
|
516
522
|
console.log("");
|
|
517
523
|
// ── Step 7: Claude Desktop auto-config ───────────────────────────
|
|
@@ -590,14 +596,16 @@ async function main() {
|
|
|
590
596
|
console.log(" Next steps:");
|
|
591
597
|
if (configuredClients.length > 0) {
|
|
592
598
|
console.log(` 1. Restart ${configuredClients.join(", ")}`);
|
|
593
|
-
console.log(
|
|
599
|
+
console.log(` 2. Ask it: "What's due this week?"`);
|
|
594
600
|
console.log(" Sign-in runs automatically if your saved session has expired.");
|
|
595
601
|
}
|
|
596
602
|
else {
|
|
597
603
|
console.log(" 1. Register the MCP server in your AI client using the command in README.md");
|
|
598
|
-
console.log(
|
|
604
|
+
console.log(` 2. Restart your AI client, then ask it: "What's due this week?"`);
|
|
599
605
|
}
|
|
600
606
|
console.log("");
|
|
607
|
+
console.log(dim(` If something doesn't work, run: ${DOCTOR_COMMAND}`));
|
|
608
|
+
console.log("");
|
|
601
609
|
}
|
|
602
610
|
// Both entry points — the `brightspace-setup` bin and `brightspace-mcp-server
|
|
603
611
|
// setup`, which imports this module — start the wizard here. VITEST is set
|
package/build/utils/commands.js
CHANGED
|
@@ -19,6 +19,8 @@ export const PACKAGE_NAME = "brightspace-mcp-server";
|
|
|
19
19
|
export const AUTH_COMMAND = `npx -y ${PACKAGE_NAME}@latest auth`;
|
|
20
20
|
/** Re-run the setup wizard. Always runs the current published release. */
|
|
21
21
|
export const SETUP_COMMAND = `npx -y ${PACKAGE_NAME}@latest setup`;
|
|
22
|
+
/** Diagnose a broken setup. Always runs the current published release. */
|
|
23
|
+
export const DOCTOR_COMMAND = `npx -y ${PACKAGE_NAME}@latest doctor`;
|
|
22
24
|
/** Bring a global install up to date. The only fix for a stale `npm i -g` copy. */
|
|
23
25
|
export const GLOBAL_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
|
|
24
26
|
/** Drop cached npx copies so the next `@latest` genuinely refetches. */
|
package/build/utils/config.js
CHANGED
|
@@ -211,7 +211,8 @@ export function parseSessionCookieEnv(raw) {
|
|
|
211
211
|
}
|
|
212
212
|
throw new Error(SESSION_COOKIE_FORMAT_ERROR);
|
|
213
213
|
}
|
|
214
|
-
|
|
214
|
+
/** Exported for the `doctor` CLI, which resolves the same session directory without loading the full app config. */
|
|
215
|
+
export function expandTilde(filePath) {
|
|
215
216
|
if (filePath.startsWith("~")) {
|
|
216
217
|
return path.join(os.homedir(), filePath.slice(1));
|
|
217
218
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.18",
|
|
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",
|