brightspace-d2l-mcp 1.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.
Files changed (58) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +269 -0
  3. package/build/api/cache.js +49 -0
  4. package/build/api/client.js +345 -0
  5. package/build/api/errors.js +48 -0
  6. package/build/api/index.js +12 -0
  7. package/build/api/paginate.js +70 -0
  8. package/build/api/rate-limiter.js +52 -0
  9. package/build/api/retry.js +39 -0
  10. package/build/api/types.js +11 -0
  11. package/build/api/version-discovery.js +43 -0
  12. package/build/auth/authenticator.js +146 -0
  13. package/build/auth/browser.js +82 -0
  14. package/build/auth/index.js +6 -0
  15. package/build/auth/login.js +227 -0
  16. package/build/auth/session-store.js +46 -0
  17. package/build/auth/token-manager.js +150 -0
  18. package/build/auth/token-mint.js +75 -0
  19. package/build/cli/clients.js +344 -0
  20. package/build/cli/main.js +162 -0
  21. package/build/cli/school.js +74 -0
  22. package/build/cli/setup.js +189 -0
  23. package/build/cli/ui.js +39 -0
  24. package/build/index.js +13 -0
  25. package/build/server.js +175 -0
  26. package/build/tools/download-file.js +167 -0
  27. package/build/tools/get-announcements.js +116 -0
  28. package/build/tools/get-assignment-files.js +159 -0
  29. package/build/tools/get-assignments.js +343 -0
  30. package/build/tools/get-classlist-emails.js +38 -0
  31. package/build/tools/get-course-content.js +175 -0
  32. package/build/tools/get-discussions.js +192 -0
  33. package/build/tools/get-my-courses.js +42 -0
  34. package/build/tools/get-my-grades.js +93 -0
  35. package/build/tools/get-roster.js +99 -0
  36. package/build/tools/get-syllabus.js +154 -0
  37. package/build/tools/get-todo.js +61 -0
  38. package/build/tools/get-upcoming-due-dates.js +138 -0
  39. package/build/tools/index.js +18 -0
  40. package/build/tools/schemas.js +91 -0
  41. package/build/tools/submission-status.js +71 -0
  42. package/build/tools/tool-helpers.js +62 -0
  43. package/build/types/index.js +2 -0
  44. package/build/utils/atomic-write.js +66 -0
  45. package/build/utils/config.js +60 -0
  46. package/build/utils/course-filter.js +24 -0
  47. package/build/utils/deep-links.js +13 -0
  48. package/build/utils/download-errors.js +14 -0
  49. package/build/utils/download-helpers.js +73 -0
  50. package/build/utils/errors.js +31 -0
  51. package/build/utils/file-validator.js +121 -0
  52. package/build/utils/html-converter.js +20 -0
  53. package/build/utils/logger.js +69 -0
  54. package/build/utils/paths.js +15 -0
  55. package/build/utils/pdf-extractor.js +18 -0
  56. package/build/utils/version.js +18 -0
  57. package/build/utils/zip-extract.js +139 -0
  58. package/package.json +104 -0
@@ -0,0 +1,75 @@
1
+ import { packageVersion } from "../utils/version.js";
2
+ import { log } from "../utils/logger.js";
3
+ const EXPIRED_MARKER = "/d2l/login?sessionExpired=1";
4
+ const USER_AGENT = `brightspace-d2l-mcp/${packageVersion()}`;
5
+ const DEFAULT_TIMEOUT_MS = 15000;
6
+ const transport = (detail) => ({
7
+ ok: false,
8
+ reason: "transport",
9
+ detail,
10
+ });
11
+ function expiredRedirect(location, tokenUrl) {
12
+ if (!location)
13
+ return false;
14
+ try {
15
+ const target = new URL(location, tokenUrl);
16
+ return target.origin === new URL(tokenUrl).origin &&
17
+ target.pathname === "/d2l/login" && target.searchParams.get("sessionExpired") === "1";
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ export async function mintAccessToken({ baseUrl, cookieHeader, csrfToken, fetchImpl = fetch, timeoutMs = DEFAULT_TIMEOUT_MS, }) {
24
+ const url = `${baseUrl.replace(/\/+$/, "")}/d2l/lp/auth/oauth2/token`;
25
+ let status;
26
+ let body;
27
+ let location;
28
+ try {
29
+ const response = await fetchImpl(url, {
30
+ method: "POST",
31
+ headers: {
32
+ "content-type": "application/x-www-form-urlencoded",
33
+ cookie: cookieHeader,
34
+ "x-csrf-token": csrfToken,
35
+ "User-Agent": USER_AGENT,
36
+ },
37
+ body: "scope=*:*:*",
38
+ signal: AbortSignal.timeout(timeoutMs),
39
+ redirect: "manual",
40
+ });
41
+ status = response.status;
42
+ body = await response.text();
43
+ location = response.headers?.get("location") ?? null;
44
+ }
45
+ catch (error) {
46
+ const message = error instanceof Error ? error.message : String(error);
47
+ return transport(message);
48
+ }
49
+ if (status >= 500 || status === 429)
50
+ return transport(`HTTP ${status}`);
51
+ let payload = null;
52
+ try {
53
+ payload = JSON.parse(body);
54
+ }
55
+ catch {
56
+ // The known expired-session response is an HTML login redirect.
57
+ }
58
+ if (status === 401 || (!payload && body.includes(EXPIRED_MARKER)) ||
59
+ expiredRedirect(location, url)) {
60
+ log("DEBUG", "The token mint answered with the session-expired stub");
61
+ return { ok: false, reason: "sessionExpired" };
62
+ }
63
+ if (status < 200 || status >= 300) {
64
+ return transport(`HTTP ${status}`);
65
+ }
66
+ if (!payload) {
67
+ return transport("the token mint returned an unparseable body");
68
+ }
69
+ const accessToken = payload.access_token;
70
+ if (typeof accessToken !== "string" || accessToken.length === 0) {
71
+ return transport("the token mint returned no access_token");
72
+ }
73
+ return { ok: true, accessToken };
74
+ }
75
+ //# sourceMappingURL=token-mint.js.map
@@ -0,0 +1,344 @@
1
+ import * as fs from "node:fs";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
4
+ import { execFileSync, spawn } from "node:child_process";
5
+ import { fileURLToPath } from "node:url";
6
+ import { PACKAGE_NAME, SERVER_NAME } from "../utils/paths.js";
7
+ export const APP_IDS = [
8
+ "claude-desktop",
9
+ "claude-code",
10
+ "cursor",
11
+ "vscode",
12
+ "windsurf",
13
+ "codex",
14
+ "gemini-cli",
15
+ "cline",
16
+ "roo-code",
17
+ "opencode",
18
+ "zed",
19
+ "lm-studio",
20
+ "kiro",
21
+ ];
22
+ const home = os.homedir();
23
+ const isWindows = process.platform === "win32";
24
+ const isMac = process.platform === "darwin";
25
+ const exists = (p) => {
26
+ try {
27
+ fs.accessSync(p);
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ };
34
+ /**
35
+ * How AI apps should launch this server: via npx from a published install so it
36
+ * stays up to date, or the exact file from a local checkout.
37
+ */
38
+ export function serverEntry() {
39
+ const entryFile = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "index.js");
40
+ const fromNpx = /[\\/]_npx[\\/]/.test(entryFile) || process.env.BRIGHTSPACE_MCP_USE_NPX === "1";
41
+ if (!fromNpx)
42
+ return { command: process.execPath, args: [entryFile] };
43
+ return isWindows
44
+ ? { command: "cmd", args: ["/c", "npx", "-y", `${PACKAGE_NAME}@latest`] }
45
+ : { command: "npx", args: ["-y", `${PACKAGE_NAME}@latest`] };
46
+ }
47
+ const appData = process.env.APPDATA ?? path.join(home, "AppData", "Roaming");
48
+ const localAppData = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
49
+ const xdgConfig = process.env.XDG_CONFIG_HOME ?? path.join(home, ".config");
50
+ /** Per-OS application support directory (where Electron apps keep settings). */
51
+ function appSupport(name) {
52
+ if (isWindows)
53
+ return path.join(appData, name);
54
+ if (isMac)
55
+ return path.join(home, "Library", "Application Support", name);
56
+ return path.join(xdgConfig, name);
57
+ }
58
+ function claudeDesktopFiles() {
59
+ const files = [];
60
+ const classic = appSupport("Claude");
61
+ if (exists(classic))
62
+ files.push(path.join(classic, "claude_desktop_config.json"));
63
+ if (isWindows) {
64
+ // The Microsoft Store build is sandboxed and reads its own copy.
65
+ try {
66
+ for (const dir of fs.readdirSync(path.join(localAppData, "Packages"))) {
67
+ if (!dir.startsWith("Claude_"))
68
+ continue;
69
+ const roaming = path.join(localAppData, "Packages", dir, "LocalCache", "Roaming", "Claude");
70
+ if (exists(roaming))
71
+ files.push(path.join(roaming, "claude_desktop_config.json"));
72
+ }
73
+ }
74
+ catch {
75
+ // No Store apps.
76
+ }
77
+ }
78
+ return files;
79
+ }
80
+ /** VS Code extension settings, for VS Code and its forks. */
81
+ function extensionSettingsFiles(extensionId, file) {
82
+ return ["Code", "Cursor", "Windsurf", "VSCodium"]
83
+ .map((editor) => path.join(appSupport(editor), "User", "globalStorage", extensionId, "settings"))
84
+ .filter(exists)
85
+ .map((dir) => path.join(dir, file));
86
+ }
87
+ /** Every supported client found on this machine. */
88
+ export function detectClients() {
89
+ const found = [];
90
+ const add = (target, present) => {
91
+ if (present && target.files.length)
92
+ found.push(target);
93
+ };
94
+ add({ id: "claude-desktop", name: "Claude Desktop", files: claudeDesktopFiles(), format: "json", keyPath: ["mcpServers"], mustBeClosed: true }, true);
95
+ add({
96
+ id: "claude-code",
97
+ name: "Claude Code",
98
+ files: [path.join(home, ".claude.json")],
99
+ format: "json",
100
+ keyPath: ["mcpServers"],
101
+ shape: (e) => ({ type: "stdio", ...e }),
102
+ }, exists(path.join(home, ".claude.json")) || exists(path.join(home, ".claude")));
103
+ add({ id: "cursor", name: "Cursor", files: [path.join(home, ".cursor", "mcp.json")], format: "json", keyPath: ["mcpServers"] }, exists(path.join(home, ".cursor")));
104
+ add({
105
+ id: "vscode",
106
+ name: "VS Code (Copilot)",
107
+ files: [path.join(appSupport("Code"), "User", "mcp.json")],
108
+ format: "json",
109
+ keyPath: ["servers"],
110
+ shape: (e) => ({ type: "stdio", ...e }),
111
+ }, exists(path.join(appSupport("Code"), "User")));
112
+ add({
113
+ id: "windsurf",
114
+ name: "Windsurf",
115
+ files: [path.join(home, ".codeium", "windsurf", "mcp_config.json")],
116
+ format: "json",
117
+ keyPath: ["mcpServers"],
118
+ }, exists(path.join(home, ".codeium", "windsurf")));
119
+ add({
120
+ id: "codex",
121
+ name: "OpenAI Codex",
122
+ files: [path.join(process.env.CODEX_HOME ?? path.join(home, ".codex"), "config.toml")],
123
+ format: "toml",
124
+ keyPath: ["mcp_servers"],
125
+ }, exists(process.env.CODEX_HOME ?? path.join(home, ".codex")));
126
+ add({ id: "gemini-cli", name: "Gemini CLI", files: [path.join(home, ".gemini", "settings.json")], format: "json", keyPath: ["mcpServers"] }, exists(path.join(home, ".gemini")));
127
+ add({
128
+ id: "cline",
129
+ name: "Cline",
130
+ files: extensionSettingsFiles("saoudrizwan.claude-dev", "cline_mcp_settings.json"),
131
+ format: "json",
132
+ keyPath: ["mcpServers"],
133
+ }, true);
134
+ add({
135
+ id: "roo-code",
136
+ name: "Roo Code",
137
+ files: extensionSettingsFiles("rooveterinaryinc.roo-cline", "mcp_settings.json"),
138
+ format: "json",
139
+ keyPath: ["mcpServers"],
140
+ }, true);
141
+ add({
142
+ id: "opencode",
143
+ name: "OpenCode",
144
+ files: [path.join(xdgConfig, "opencode", "opencode.json")],
145
+ format: "json",
146
+ keyPath: ["mcp"],
147
+ shape: (e) => ({ type: "local", command: [e.command, ...e.args], enabled: true }),
148
+ }, exists(path.join(xdgConfig, "opencode")));
149
+ add({
150
+ id: "zed",
151
+ name: "Zed",
152
+ files: [path.join(isMac || !isWindows ? path.join(xdgConfig, "zed") : path.join(appData, "Zed"), "settings.json")],
153
+ format: "json",
154
+ keyPath: ["context_servers"],
155
+ shape: (e) => ({ source: "custom", ...e }),
156
+ }, exists(isMac || !isWindows ? path.join(xdgConfig, "zed") : path.join(appData, "Zed")));
157
+ add({ id: "lm-studio", name: "LM Studio", files: [path.join(home, ".lmstudio", "mcp.json")], format: "json", keyPath: ["mcpServers"] }, exists(path.join(home, ".lmstudio")));
158
+ add({ id: "kiro", name: "Kiro", files: [path.join(home, ".kiro", "settings", "mcp.json")], format: "json", keyPath: ["mcpServers"] }, exists(path.join(home, ".kiro")));
159
+ return found;
160
+ }
161
+ function readJson(file) {
162
+ if (!exists(file))
163
+ return {};
164
+ const text = fs.readFileSync(file, "utf8");
165
+ if (!text.trim())
166
+ return {};
167
+ try {
168
+ return JSON.parse(text);
169
+ }
170
+ catch {
171
+ throw new Error(`${file} has comments or isn't plain JSON, so it wasn't changed. Add the server by hand (run \`npx ${PACKAGE_NAME} config\`).`);
172
+ }
173
+ }
174
+ function backupOnce(file) {
175
+ if (exists(file) && !exists(`${file}.bak`))
176
+ fs.copyFileSync(file, `${file}.bak`);
177
+ }
178
+ function writeText(file, text) {
179
+ fs.mkdirSync(path.dirname(file), { recursive: true });
180
+ backupOnce(file);
181
+ const temp = `${file}.tmp-${process.pid}`;
182
+ fs.writeFileSync(temp, text);
183
+ fs.renameSync(temp, file);
184
+ }
185
+ function writeJson(file, data) {
186
+ writeText(file, JSON.stringify(data, null, 2) + "\n");
187
+ }
188
+ function serverMap(config, keyPath, create) {
189
+ let node = config;
190
+ for (const key of keyPath) {
191
+ if (typeof node[key] !== "object" || node[key] === null) {
192
+ if (!create)
193
+ return undefined;
194
+ node[key] = {};
195
+ }
196
+ node = node[key];
197
+ }
198
+ return node;
199
+ }
200
+ // Codex keeps MCP servers in TOML tables: [mcp_servers.<name>]
201
+ const tomlString = (s) => JSON.stringify(s);
202
+ function tomlBlock(entry) {
203
+ const lines = [
204
+ `[mcp_servers.${SERVER_NAME}]`,
205
+ `command = ${tomlString(entry.command)}`,
206
+ `args = [${entry.args.map(tomlString).join(", ")}]`,
207
+ ];
208
+ if (entry.env && Object.keys(entry.env).length) {
209
+ lines.push(`env = { ${Object.entries(entry.env).map(([k, v]) => `${k} = ${tomlString(v)}`).join(", ")} }`);
210
+ }
211
+ return lines.join("\n") + "\n";
212
+ }
213
+ function withoutTomlBlock(text) {
214
+ const header = new RegExp(`^\\[mcp_servers\\.(?:${SERVER_NAME}|"${SERVER_NAME}")\\]\\s*$`, "m");
215
+ const match = header.exec(text);
216
+ if (!match)
217
+ return { text, removed: false };
218
+ const rest = text.slice(match.index + match[0].length);
219
+ const next = rest.search(/^\s*\[/m);
220
+ const end = next === -1 ? text.length : match.index + match[0].length + next;
221
+ return { text: (text.slice(0, match.index) + text.slice(end)).replace(/\n{3,}/g, "\n\n"), removed: true };
222
+ }
223
+ export function addToClient(client, entry = serverEntry()) {
224
+ for (const file of client.files) {
225
+ if (client.format === "toml") {
226
+ const current = exists(file) ? withoutTomlBlock(fs.readFileSync(file, "utf8")).text.trimEnd() : "";
227
+ writeText(file, `${current ? `${current}\n\n` : ""}${tomlBlock(entry)}`);
228
+ continue;
229
+ }
230
+ const config = readJson(file);
231
+ serverMap(config, client.keyPath, true)[SERVER_NAME] = client.shape ? client.shape(entry) : entry;
232
+ writeJson(file, config);
233
+ }
234
+ }
235
+ export function removeFromClient(client) {
236
+ let removed = false;
237
+ for (const file of client.files) {
238
+ if (!exists(file))
239
+ continue;
240
+ if (client.format === "toml") {
241
+ const result = withoutTomlBlock(fs.readFileSync(file, "utf8"));
242
+ if (result.removed) {
243
+ writeText(file, result.text);
244
+ removed = true;
245
+ }
246
+ continue;
247
+ }
248
+ const config = readJson(file);
249
+ const servers = serverMap(config, client.keyPath, false);
250
+ if (servers && SERVER_NAME in servers) {
251
+ delete servers[SERVER_NAME];
252
+ writeJson(file, config);
253
+ removed = true;
254
+ }
255
+ }
256
+ return removed;
257
+ }
258
+ export function isConfiguredIn(client) {
259
+ return client.files.some((file) => {
260
+ try {
261
+ if (client.format === "toml")
262
+ return exists(file) && withoutTomlBlock(fs.readFileSync(file, "utf8")).removed;
263
+ return SERVER_NAME in (serverMap(readJson(file), client.keyPath, false) ?? {});
264
+ }
265
+ catch {
266
+ return false;
267
+ }
268
+ });
269
+ }
270
+ /** Lets Claude Code use every Brightspace tool without asking each time. */
271
+ export function allowToolsInClaudeCode() {
272
+ const file = path.join(home, ".claude", "settings.json");
273
+ const settings = readJson(file);
274
+ const permissions = settings.permissions ?? {};
275
+ const rule = `mcp__${SERVER_NAME}`;
276
+ if (permissions.allow?.includes(rule))
277
+ return;
278
+ permissions.allow = [...(permissions.allow ?? []), rule];
279
+ settings.permissions = permissions;
280
+ writeJson(file, settings);
281
+ }
282
+ function desktopProcesses() {
283
+ try {
284
+ if (isWindows) {
285
+ const out = execFileSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command",
286
+ "Get-Process claude -ErrorAction SilentlyContinue | Where-Object { $_.Path -and ($_.Path -like '*\\WindowsApps\\Claude_*' -or $_.Path -like '*\\AnthropicClaude\\*') } | ForEach-Object { \"$($_.Id)|$($_.Path)\" }"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 15_000 });
287
+ return out.split(/\r?\n/).filter(Boolean).map((line) => {
288
+ const [pid, exe] = line.split("|");
289
+ return { pid: Number(pid), exe };
290
+ });
291
+ }
292
+ if (isMac) {
293
+ const out = execFileSync("pgrep", ["-x", "Claude"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
294
+ return out.split("\n").filter(Boolean).map((pid) => ({ pid: Number(pid), exe: "/Applications/Claude.app" }));
295
+ }
296
+ }
297
+ catch {
298
+ // Not running.
299
+ }
300
+ return [];
301
+ }
302
+ export function isClaudeDesktopRunning() {
303
+ return desktopProcesses().length > 0;
304
+ }
305
+ /** Quits Claude Desktop. Returns a function that starts it again. */
306
+ export async function quitClaudeDesktop() {
307
+ const running = desktopProcesses();
308
+ const storeExe = running.find((p) => p.exe.includes("\\WindowsApps\\Claude_"))?.exe;
309
+ const classicExe = running.find((p) => p.exe.includes("AnthropicClaude"))?.exe;
310
+ if (isMac) {
311
+ try {
312
+ execFileSync("osascript", ["-e", 'quit app "Claude"'], { stdio: "ignore", timeout: 15_000 });
313
+ }
314
+ catch {
315
+ // Checked below.
316
+ }
317
+ }
318
+ else {
319
+ for (const p of running) {
320
+ try {
321
+ process.kill(p.pid);
322
+ }
323
+ catch {
324
+ // Already gone.
325
+ }
326
+ }
327
+ }
328
+ for (let i = 0; i < 30 && isClaudeDesktopRunning(); i++)
329
+ await new Promise((r) => setTimeout(r, 500));
330
+ return () => {
331
+ const detached = { detached: true, stdio: "ignore" };
332
+ if (isMac) {
333
+ spawn("open", ["-a", "Claude"], detached).unref();
334
+ }
335
+ else if (storeExe) {
336
+ const family = storeExe.match(/WindowsApps\\(Claude)_[^_\\]+_[^_\\]+__([a-z0-9]+)/i);
337
+ spawn("explorer.exe", [family ? `shell:AppsFolder\\${family[1]}_${family[2]}!Claude` : storeExe], detached).unref();
338
+ }
339
+ else if (classicExe) {
340
+ spawn(classicExe, [], detached).unref();
341
+ }
342
+ };
343
+ }
344
+ //# sourceMappingURL=clients.js.map
@@ -0,0 +1,162 @@
1
+ import * as fs from "node:fs";
2
+ import { loadConfig } from "../utils/config.js";
3
+ import { browserProfileDir, dataDir, LOGIN_COMMAND, savedCookiesPath, SETUP_COMMAND } from "../utils/paths.js";
4
+ import { packageVersion } from "../utils/version.js";
5
+ import { loginWithBrowser, LoginError, SessionStore, TokenManager } from "../auth/index.js";
6
+ import { APP_IDS, detectClients, isConfiguredIn, removeFromClient, serverEntry } from "./clients.js";
7
+ import { runSetup, printManualConfig } from "./setup.js";
8
+ import * as ui from "./ui.js";
9
+ function flagValue(args, name) {
10
+ const index = args.indexOf(name);
11
+ if (index >= 0)
12
+ return args[index + 1];
13
+ const inline = args.find((a) => a.startsWith(`${name}=`));
14
+ return inline?.slice(name.length + 1);
15
+ }
16
+ export async function runCli(command, args) {
17
+ switch (command) {
18
+ case "setup":
19
+ return runSetup({
20
+ school: flagValue(args, "--school"),
21
+ yes: args.includes("--yes") || args.includes("-y"),
22
+ skipLogin: args.includes("--skip-login"),
23
+ apps: flagValue(args, "--apps")?.split(",").map((s) => s.trim()),
24
+ });
25
+ case "login":
26
+ return login();
27
+ case "status":
28
+ return status();
29
+ case "logout":
30
+ await new SessionStore().clear();
31
+ fs.rmSync(browserProfileDir(), { recursive: true, force: true });
32
+ fs.rmSync(savedCookiesPath(), { force: true });
33
+ ui.ok("Signed out. The saved session and browser profile were deleted.");
34
+ return 0;
35
+ case "uninstall":
36
+ return uninstall(args.includes("--yes") || args.includes("-y"));
37
+ case "serve":
38
+ return serve(args);
39
+ case "config":
40
+ console.log(JSON.stringify({ mcpServers: { brightspace: serverEntry() } }, null, 2));
41
+ return 0;
42
+ case "--version":
43
+ case "-v":
44
+ console.log(packageVersion());
45
+ return 0;
46
+ default:
47
+ printHelp();
48
+ return 0;
49
+ }
50
+ }
51
+ async function login() {
52
+ const config = loadConfig();
53
+ if (!config.baseUrl) {
54
+ ui.fail(`No school set up yet. Run: ${SETUP_COMMAND}`);
55
+ return 1;
56
+ }
57
+ ui.info(`Opening ${ui.bold(config.baseUrl)} in a browser window. Sign in there.`);
58
+ try {
59
+ const { token, displayName } = await loginWithBrowser({
60
+ baseUrl: config.baseUrl,
61
+ mode: "interactive",
62
+ tokenTtlSeconds: config.tokenTtl,
63
+ onStatus: (m) => ui.info(ui.dim(m)),
64
+ });
65
+ await new TokenManager({ baseUrl: config.baseUrl }).setToken(token);
66
+ ui.ok(displayName ? `Signed in as ${ui.bold(displayName)}` : "Signed in");
67
+ return 0;
68
+ }
69
+ catch (error) {
70
+ ui.fail(error instanceof Error ? error.message : String(error));
71
+ return error instanceof LoginError && error.kind === "cancelled" ? 2 : 1;
72
+ }
73
+ }
74
+ async function status() {
75
+ const config = loadConfig();
76
+ console.log(`\n${ui.bold("brightspace-d2l-mcp")} ${ui.dim(`v${packageVersion()}`)}`);
77
+ if (!config.baseUrl) {
78
+ ui.fail(`Not set up. Run: ${SETUP_COMMAND}`);
79
+ return 1;
80
+ }
81
+ ui.ok(`School: ${config.baseUrl}`);
82
+ const tokenManager = new TokenManager({ baseUrl: config.baseUrl, tokenTtl: config.tokenTtl });
83
+ try {
84
+ const token = await tokenManager.getToken();
85
+ if (token)
86
+ ui.ok("Session: signed in");
87
+ else
88
+ ui.warn(`Session: expired. It will reopen sign-in when needed, or run: ${LOGIN_COMMAND}`);
89
+ }
90
+ catch {
91
+ ui.warn("Session: couldn't check right now (network).");
92
+ }
93
+ const clients = detectClients();
94
+ if (!clients.length)
95
+ ui.warn("No supported AI apps found.");
96
+ for (const client of clients) {
97
+ if (isConfiguredIn(client))
98
+ ui.ok(`${client.name}: connected`);
99
+ else
100
+ ui.info(`${ui.dim("-")} ${client.name}: not connected ${ui.dim(`(run ${SETUP_COMMAND})`)}`);
101
+ }
102
+ console.log();
103
+ return 0;
104
+ }
105
+ async function serve(args) {
106
+ const { startServer, startHttpServer } = await import("../server.js");
107
+ if (!args.includes("--http")) {
108
+ await startServer();
109
+ return 0;
110
+ }
111
+ const port = Number(flagValue(args, "--port") ?? process.env.PORT ?? 8787);
112
+ const host = flagValue(args, "--host") ?? "127.0.0.1";
113
+ const noAuth = args.includes("--no-auth");
114
+ try {
115
+ const { url } = await startHttpServer({ port, host, noAuth });
116
+ console.log(`\n${ui.bold("Brightspace MCP server")} ${ui.dim("(streamable HTTP)")}`);
117
+ ui.ok(`Listening: ${ui.cyan(url)}`);
118
+ if (!noAuth) {
119
+ ui.info(ui.dim("The last part of that URL is your access token. Keep it private."));
120
+ ui.info(ui.dim("Clients that support headers can use /mcp with Authorization: Bearer <token>."));
121
+ }
122
+ ui.info(ui.dim("Press Ctrl+C to stop."));
123
+ await new Promise(() => { });
124
+ return 0;
125
+ }
126
+ catch (error) {
127
+ ui.fail(error instanceof Error ? error.message : String(error));
128
+ return 1;
129
+ }
130
+ }
131
+ async function uninstall(yes) {
132
+ if (yes)
133
+ ui.setNonInteractive();
134
+ if (!(await ui.confirm("Remove Brightspace from your AI apps and delete its saved data?", false)) && !yes)
135
+ return 0;
136
+ for (const client of detectClients()) {
137
+ if (removeFromClient(client))
138
+ ui.ok(`Removed from ${client.name}`);
139
+ }
140
+ fs.rmSync(dataDir(), { recursive: true, force: true });
141
+ ui.ok(`Deleted ${dataDir()}`);
142
+ return 0;
143
+ }
144
+ function printHelp() {
145
+ console.log(`
146
+ ${ui.bold("brightspace-d2l-mcp")} ${ui.dim(`v${packageVersion()}`)} Brightspace (D2L) for Claude, Cursor and other AI apps
147
+
148
+ ${ui.cyan("setup")} Pick your school, sign in, and connect your AI apps
149
+ ${ui.cyan("login")} Sign in again
150
+ ${ui.cyan("status")} Show your school, session and connected apps
151
+ ${ui.cyan("logout")} Delete the saved session
152
+ ${ui.cyan("uninstall")} Remove from AI apps and delete all local data
153
+ ${ui.cyan("serve")} Run the MCP server over stdio (what AI apps launch)
154
+ ${ui.cyan("serve --http")} Run over HTTP for ChatGPT and HTTP agents [--port 8787] [--host 127.0.0.1] [--no-auth]
155
+ ${ui.cyan("config")} Print the MCP config JSON for any other app
156
+
157
+ Setup options: --school <address> --yes --skip-login --apps ${APP_IDS.join(",")}
158
+ `);
159
+ printManualConfig();
160
+ console.log();
161
+ }
162
+ //# sourceMappingURL=main.js.map
@@ -0,0 +1,74 @@
1
+ import { normalizeSchoolUrl } from "../utils/config.js";
2
+ function otherPlatform(headers) {
3
+ if (headers.get("x-blackboard-product"))
4
+ return "Blackboard";
5
+ if (headers.get("x-canvas-meta") || headers.get("x-canvas-user-id") !== null)
6
+ return "Canvas";
7
+ if ([...headers.keys()].some((k) => k.startsWith("x-moodle")))
8
+ return "Moodle";
9
+ return null;
10
+ }
11
+ // Brightspace lists its API versions publicly, which makes it easy to recognize.
12
+ async function probe(origin) {
13
+ try {
14
+ const response = await fetch(`${origin}/d2l/api/versions/`, { signal: AbortSignal.timeout(10_000), redirect: "manual" });
15
+ const platform = otherPlatform(response.headers);
16
+ if (!response.ok)
17
+ return { result: "no", platform };
18
+ const body = (await response.json());
19
+ const isBrightspace = Array.isArray(body) && body.some((p) => p?.ProductCode === "lp");
20
+ return { result: isBrightspace ? "yes" : "no", platform };
21
+ }
22
+ catch (error) {
23
+ return { result: error?.name === "SyntaxError" ? "no" : "unreachable", platform: null };
24
+ }
25
+ }
26
+ const IDENTITY_PROVIDER_HOSTS = [
27
+ "login.microsoftonline.com",
28
+ "okta.com",
29
+ "duosecurity.com",
30
+ "accounts.google.com",
31
+ "adfs",
32
+ "sts.",
33
+ "shibboleth",
34
+ "idp.",
35
+ "cas.",
36
+ ];
37
+ export async function resolveSchool(input) {
38
+ const trimmed = input.trim();
39
+ if (!trimmed)
40
+ return { ok: false, reason: "Nothing was entered." };
41
+ // A short name like "yourschool" could be either of the hostnames D2L hands out.
42
+ const candidates = /^[a-z0-9-]+$/i.test(trimmed)
43
+ ? [`https://${trimmed}.brightspace.com`, `https://${trimmed}.desire2learn.com`]
44
+ : [normalizeSchoolUrl(trimmed)].filter((u) => !!u);
45
+ if (candidates.length === 0)
46
+ return { ok: false, reason: "That doesn't look like a web address." };
47
+ let unreachable = false;
48
+ let platform = null;
49
+ for (const origin of candidates) {
50
+ const probed = await probe(origin);
51
+ if (probed.result === "yes")
52
+ return { ok: true, baseUrl: origin };
53
+ if (probed.result === "unreachable")
54
+ unreachable = true;
55
+ platform ??= probed.platform;
56
+ }
57
+ const host = new URL(candidates[0]).hostname;
58
+ if (platform) {
59
+ return { ok: false, reason: `${host} runs ${platform}, not Brightspace (D2L). This tool only works with Brightspace.` };
60
+ }
61
+ if (IDENTITY_PROVIDER_HOSTS.some((idp) => host.includes(idp))) {
62
+ return {
63
+ ok: false,
64
+ reason: "That's your school's sign-in page, not Brightspace. Sign in first, then copy the address of your Brightspace home page.",
65
+ };
66
+ }
67
+ return {
68
+ ok: false,
69
+ reason: unreachable
70
+ ? `Couldn't reach ${host}. Check the address and your internet connection.`
71
+ : `${host} isn't a Brightspace site. Open Brightspace in your browser and copy the address from the address bar.`,
72
+ };
73
+ }
74
+ //# sourceMappingURL=school.js.map