brightspace-mcp-server 3.15.4 → 4.0.1

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.
@@ -27,7 +27,7 @@ export function registerGetMyGroups(server, apiClient) {
27
27
  try {
28
28
  log("DEBUG", "get_my_groups tool called", { args });
29
29
  const { courseId } = GetMyGroupsSchema.parse(args);
30
- const { matches, categoriesUnavailable } = await fetchMyGroupMemberships(apiClient, courseId);
30
+ const { matches, categoriesUnavailable, unreadCategories } = await fetchMyGroupMemberships(apiClient, courseId);
31
31
  if (categoriesUnavailable) {
32
32
  return toolResponse({
33
33
  courseId,
@@ -35,6 +35,18 @@ export function registerGetMyGroups(server, apiClient) {
35
35
  note: "No groups are visible for this course (it may not use groups, or group info isn't accessible).",
36
36
  });
37
37
  }
38
+ const unreadNote = unreadCategories.length === 0 ? undefined
39
+ : `Couldn't read the groups in ${unreadCategories.map((c) => `"${c.Name || c.GroupCategoryId}"`).join(", ")}, ` +
40
+ "so any group the current user has there is not listed.";
41
+ if (unreadNote) {
42
+ log("INFO", "get_my_groups: Some group categories could not be read", {
43
+ courseId,
44
+ categoryIds: unreadCategories.map((c) => c.GroupCategoryId),
45
+ });
46
+ }
47
+ if (matches.length === 0 && unreadNote) {
48
+ return toolResponse({ courseId, groups: [], note: unreadNote });
49
+ }
38
50
  if (matches.length === 0) {
39
51
  log("INFO", `get_my_groups: User is not a member of any group in course ${courseId}`);
40
52
  return toolResponse({
@@ -78,7 +90,7 @@ export function registerGetMyGroups(server, apiClient) {
78
90
  };
79
91
  });
80
92
  log("INFO", `get_my_groups: Found ${groups.length} groups for course ${courseId}`);
81
- return toolResponse({ courseId, groups });
93
+ return toolResponse({ courseId, groups, ...(unreadNote ? { note: unreadNote } : {}) });
82
94
  }
83
95
  catch (error) {
84
96
  return sanitizeError(error);
@@ -20,19 +20,24 @@ export async function fetchMyGroupMemberships(apiClient, courseId, categoryId) {
20
20
  : [{ GroupCategoryId: categoryId, Name: "" }];
21
21
  }
22
22
  catch (error) {
23
- if (inaccessible(error))
24
- return { matches: [], categoriesUnavailable: true };
23
+ if (inaccessible(error)) {
24
+ return { matches: [], categoriesUnavailable: true, unreadCategories: [] };
25
+ }
25
26
  throw error;
26
27
  }
27
28
  const matches = [];
29
+ // Categories whose groups answered 403/404: membership there is unknown, not "none".
30
+ const unreadCategories = [];
28
31
  for (const category of categories) {
29
32
  let groups;
30
33
  try {
31
34
  groups = await apiClient.get(apiClient.lp(`/${courseId}/groupcategories/${category.GroupCategoryId}/groups/`), { ttl: DEFAULT_CACHE_TTLS.roster });
32
35
  }
33
36
  catch (error) {
34
- if (inaccessible(error))
37
+ if (inaccessible(error)) {
38
+ unreadCategories.push(category);
35
39
  continue;
40
+ }
36
41
  throw error;
37
42
  }
38
43
  for (const group of groups) {
@@ -40,5 +45,5 @@ export async function fetchMyGroupMemberships(apiClient, courseId, categoryId) {
40
45
  matches.push({ category, group });
41
46
  }
42
47
  }
43
- return { matches, categoriesUnavailable: false };
48
+ return { matches, categoriesUnavailable: false, unreadCategories };
44
49
  }
@@ -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.4",
3
+ "version": "4.0.1",
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",