brightspace-mcp-server 3.15.3 → 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.
@@ -232,9 +232,10 @@ export function registerSearchCourse(server, apiClient) {
232
232
  }
233
233
  catch (error) {
234
234
  // One forum's topics failing (e.g. no access) shouldn't drop
235
- // every other forum — same graceful-degradation rule
236
- // get_discussions already follows for this exact call.
235
+ // every other forum, but it still has to be named in `note` so
236
+ // a search missing those topics never reads as complete.
237
237
  log("DEBUG", `search_course: topics fetch failed for forum ${forum.ForumId}`, error);
238
+ notes.push(`discussions: topics in "${forum.Name}" ${describeFailure(error)}`);
238
239
  }
239
240
  }
240
241
  }
@@ -13,10 +13,15 @@
13
13
  * "Authentication failed" by the auth runner, the actionable command is gone.
14
14
  * Matching the error lets us keep the fix attached to the failure.
15
15
  *
16
- * Matching reactively rather than probing for the executable up front keeps us
17
- * off Playwright's internals, and means no false positives: this only ever
18
- * fires after a launch has genuinely failed.
16
+ * Sign-in matches reactively, so it never reports a missing browser unless a
17
+ * launch really failed. `setup --prepare` and `doctor` check up front instead
18
+ * (chromiumStatus), through Playwright's public API only, and prepare installs
19
+ * a missing browser with the package's own Playwright (installChromium).
19
20
  */
21
+ import { spawn } from "node:child_process";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { createRequire } from "node:module";
20
25
  import { CLEAR_NPX_CACHE_COMMAND } from "./commands.js";
21
26
  export const PLAYWRIGHT_INSTALL_HINT = "Chromium is not installed, so sign-in cannot start. Your package manager " +
22
27
  "skipped this package's postinstall script, which is what normally downloads it " +
@@ -30,3 +35,72 @@ export function isMissingBrowserError(error) {
30
35
  return false;
31
36
  return /Executable doesn't exist|playwright install|Failed to launch.*because executable doesn't exist/i.test(message);
32
37
  }
38
+ const defaultProbe = {
39
+ async launchHeadless() {
40
+ const { chromium } = await import("playwright");
41
+ const browser = await chromium.launch({ headless: true, timeout: 30000 });
42
+ await browser.close();
43
+ },
44
+ async executablePath() {
45
+ const { chromium } = await import("playwright");
46
+ return chromium.executablePath();
47
+ },
48
+ existsSync: fs.existsSync,
49
+ };
50
+ /** Whether both browsers sign-in can use are installed. Never opens a window. */
51
+ export async function chromiumStatus(overrides = {}) {
52
+ const probe = { ...defaultProbe, ...overrides };
53
+ let headless = "present";
54
+ let detail;
55
+ try {
56
+ await probe.launchHeadless();
57
+ }
58
+ catch (error) {
59
+ if (isMissingBrowserError(error)) {
60
+ headless = "missing";
61
+ }
62
+ else {
63
+ headless = "unknown";
64
+ detail = (error instanceof Error ? error.message : String(error)).split("\n")[0].slice(0, 200);
65
+ }
66
+ }
67
+ let visible = false;
68
+ try {
69
+ visible = probe.existsSync(await probe.executablePath());
70
+ }
71
+ catch {
72
+ visible = false;
73
+ }
74
+ return { headless, visible, ...(detail ? { detail } : {}) };
75
+ }
76
+ /** True when sign-in has every browser it might need. */
77
+ export function chromiumReady(status) {
78
+ return status.headless !== "missing" && status.visible;
79
+ }
80
+ /** The CLI of the Playwright this package depends on, so the browser revision matches. */
81
+ export function playwrightCliPath() {
82
+ const require = createRequire(import.meta.url);
83
+ return path.join(path.dirname(require.resolve("playwright/package.json")), "cli.js");
84
+ }
85
+ /**
86
+ * Download Chromium with the package's own Playwright: `npx playwright` can
87
+ * resolve a different Playwright whose browser this package can't launch,
88
+ * and needs a shell on Windows. Never `--with-deps`, which needs sudo on
89
+ * Linux. Progress goes to stderr; stdin is closed so nothing can prompt.
90
+ */
91
+ export function installChromium(options = {}) {
92
+ const spawnImpl = options.spawnImpl ?? spawn;
93
+ const execPath = options.execPath ?? process.execPath;
94
+ let cliPath;
95
+ try {
96
+ cliPath = options.cliPath ?? playwrightCliPath();
97
+ }
98
+ catch (error) {
99
+ return Promise.resolve({ ok: false, message: error instanceof Error ? error.message : String(error) });
100
+ }
101
+ return new Promise((resolve) => {
102
+ const child = spawnImpl(execPath, [cliPath, "install", "chromium"], { stdio: ["ignore", 2, 2] });
103
+ child.once("error", (error) => resolve({ ok: false, message: error.message }));
104
+ child.once("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, message: `Playwright's installer exited with code ${code}.` }));
105
+ });
106
+ }
@@ -14,6 +14,7 @@
14
14
  * Import these instead of writing the command inline. A release test greps the
15
15
  * source for untagged forms and fails the build if one reappears.
16
16
  */
17
+ import { presetForBaseUrl } from "../setup/presets.js";
17
18
  export const PACKAGE_NAME = "brightspace-mcp-server";
18
19
  /** Re-authenticate. Always runs the current published release. */
19
20
  export const AUTH_COMMAND = `npx -y ${PACKAGE_NAME}@latest auth`;
@@ -27,3 +28,32 @@ export const DOCTOR_COMMAND = `npx -y ${PACKAGE_NAME}@latest doctor`;
27
28
  export const GLOBAL_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
28
29
  /** Drop cached npx copies so the next `@latest` genuinely refetches. */
29
30
  export const CLEAR_NPX_CACHE_COMMAND = "npx clear-npx-cache";
31
+ /**
32
+ * The school's flag for a setup command, or `--url <origin>` for a school
33
+ * with no preset. An unreadable URL gives no school at all, which `setup`
34
+ * then fills from the saved configuration.
35
+ */
36
+ function schoolArgument(baseUrl) {
37
+ if (!baseUrl)
38
+ return "";
39
+ const match = presetForBaseUrl(baseUrl);
40
+ if (match)
41
+ return ` --${match.flag}`;
42
+ try {
43
+ return ` --url ${new URL(baseUrl).origin}`;
44
+ }
45
+ catch {
46
+ return "";
47
+ }
48
+ }
49
+ /**
50
+ * The person's last setup step: enter the password, approve sign-in. Always
51
+ * runs the current published release.
52
+ */
53
+ export function signInCommand(baseUrl) {
54
+ return `${SETUP_COMMAND}${schoolArgument(baseUrl)} --sign-in`;
55
+ }
56
+ /** The no-prompt setup an agent runs before handing over. Always runs the current published release. */
57
+ export function prepareCommand(baseUrl) {
58
+ return `${SETUP_COMMAND}${schoolArgument(baseUrl)} --prepare`;
59
+ }
@@ -11,6 +11,19 @@ function defaultRunCommand(command, args, stdio) {
11
11
  });
12
12
  return { status: result.error ? null : result.status, stdout: result.stdout ?? "" };
13
13
  }
14
+ /**
15
+ * For `setup --prepare`, which an agent runs and reads as JSON: the client CLIs
16
+ * get no stdin, so they can never stop to ask anything, and what they print
17
+ * goes to stderr, so it can't corrupt the JSON on stdout.
18
+ */
19
+ export const runCommandWithoutPrompts = (command, args, stdio) => {
20
+ const result = spawnSync(command, args, {
21
+ stdio: stdio === "pipe" ? ["ignore", "pipe", "ignore"] : stdio === "inherit" ? ["ignore", 2, 2] : "ignore",
22
+ encoding: "utf-8",
23
+ shell: process.platform === "win32",
24
+ });
25
+ return { status: result.error ? null : result.status, stdout: result.stdout ?? "" };
26
+ };
14
27
  /** The command every client is registered with. */
15
28
  export function serverCommand(platform = process.platform) {
16
29
  // On Windows, npx is a .cmd shim that must be invoked through cmd.exe
@@ -0,0 +1,59 @@
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
+ * Read-only access to the saved sign-in, shared by `doctor` and
7
+ * `setup --prepare`. Lives apart from doctor.ts because importing that module
8
+ * runs the doctor command.
9
+ */
10
+ import * as path from "node:path";
11
+ import * as os from "node:os";
12
+ import { accountSessionDirectory, expandTilde } from "./config.js";
13
+ import { TokenManager } from "../auth/token-manager.js";
14
+ import { SessionStore } from "../auth/session-store.js";
15
+ /**
16
+ * doctor only ever reads a saved session — it must never write session.json,
17
+ * trash it, or take the write lock a v1-record migration uses (that lock can
18
+ * also throw AuthenticationInProgressError while a real sign-in holds it).
19
+ * SessionStore.peek() is the read-only sibling of load() that does none of
20
+ * that, so every write-shaped method here is a no-op and load() delegates to
21
+ * peek() instead of the real load(). Exported for tests, which pass a fake
22
+ * in place of a real SessionStore to prove the no-op wiring.
23
+ */
24
+ export function readOnlySessionStore(store) {
25
+ return {
26
+ load: () => store.peek(),
27
+ save: async () => { },
28
+ clear: async () => { },
29
+ saveIfCurrent: async () => true,
30
+ clearIfCurrent: async () => true,
31
+ };
32
+ }
33
+ /** A TokenManager that can mint from a saved cookie but can never write, clear, or migrate session.json. */
34
+ export function readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie) {
35
+ return new TokenManager({
36
+ baseUrl,
37
+ envAccessToken,
38
+ envSessionCookie,
39
+ sessionStore: readOnlySessionStore(new SessionStore(sessionDir)),
40
+ });
41
+ }
42
+ /** The saved sign-in's token, read-only: never a browser and never a write. Shared with `setup --prepare`. */
43
+ export function readSavedSessionToken(baseUrl, sessionDir, envAccessToken, envSessionCookie) {
44
+ return readOnlyTokenManager(baseUrl, sessionDir, envAccessToken, envSessionCookie).getToken();
45
+ }
46
+ /** Where the saved session for this account lives, honouring D2L_SESSION_DIR and config.json's sessionDir. */
47
+ export function sessionDirFor(env, store, baseUrl, username) {
48
+ const sessionRoot = env.D2L_SESSION_DIR
49
+ ? expandTilde(env.D2L_SESSION_DIR)
50
+ : store?.sessionDir
51
+ ? expandTilde(store.sessionDir)
52
+ : path.join(os.homedir(), ".d2l-session");
53
+ return accountSessionDirectory(sessionRoot, baseUrl, username);
54
+ }
55
+ /** True for the Node versions this package supports (20 and newer). */
56
+ export function nodeVersionSupported(version) {
57
+ const major = Number(version.replace(/^v/, "").split(".")[0]);
58
+ return Number.isFinite(major) && major >= 20;
59
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.15.3",
3
+ "version": "4.0.0",
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",