brightspace-mcp-server 5.1.6 → 5.1.8

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 CHANGED
@@ -165,7 +165,10 @@ How often you're asked is up to your school, not this tool. Everything else abou
165
165
 
166
166
  ## Something not working?
167
167
 
168
- - 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.
168
+ - 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. For a global installation, use `brightspace-mcp-server doctor` to check the copy your client launches.
169
+ - **Browser removed during disk cleanup?** Sign-in restores a missing Chromium download once and retries automatically. It uses the installed Playwright dependency's browser revision and preserves saved credentials, MFA enrollment and sessions. Downloads have a two-minute limit; offline or failed repairs return an actionable installation error instead of asking you to reset credentials.
170
+ - **Repair without signing in or changing client configuration:** run `brightspace-mcp-server repair` for a global install, or `npx -y brightspace-mcp-server@latest repair` for an npx installation. Add `--json` for automation. Diagnostics print an absolute command for the exact installation being checked. Repair verifies both the full browser executable and a real headless launch and close. It does not register clients, update school settings, or start SSO/MFA.
171
+ - Browser downloads are disposable; files inside an installed package's `node_modules` are dependency code. If those files were deleted, diagnostics give an installation-scoped npm command to restore the package's declared dependencies first. Browser repair does not run npm or upgrade packages automatically.
169
172
  - **`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.
170
173
  - **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).
171
174
  - **The app says the server timed out while starting:** open the app's MCP server log. Each startup step prints a timestamped `Startup: ...` line before it begins and `done in N ms` when it ends, and a step still running after 10 seconds prints `Startup: still ...` once. The last such line names the step it was stuck in, often the saved-password lookup in your password store. These lines never include your username, password, or cookies.
@@ -10,6 +10,7 @@ import * as path from "node:path";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { AuthError } from "../utils/errors.js";
12
12
  import { AUTH_COMMAND } from "../utils/commands.js";
13
+ import { dependencyRepairHint, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
13
14
  import { devActivity, newRecoveryId, RECOVERY_ID_ENV } from "../utils/dev-activity.js";
14
15
  import { parsePhaseMarker } from "./auth-phases.js";
15
16
  import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
@@ -969,6 +970,8 @@ export class AuthRunner {
969
970
  }
970
971
  else {
971
972
  const failures = {
973
+ 8: ["browserInstall", PLAYWRIGHT_INSTALL_HINT],
974
+ 9: ["dependencyMissing", dependencyRepairHint()],
972
975
  2: ["busy", `Authentication already in progress in another process. Complete that attempt, then retry, or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`],
973
976
  3: ["cooldown", `Automatic MFA is paused after an unsuccessful attempt. Run ${AUTH_COMMAND} to retry immediately.`],
974
977
  4: ["unsupported", "This identity provider cannot complete headless authentication. See the authentication logs."],
@@ -14,7 +14,7 @@ import { acquireProcessLock, AuthenticationInProgressError, inspectLockOwner } f
14
14
  import { authLockPath, publishAutomaticProgress, publishChallenge, relayChallenge } from "./mfa-challenge.js";
15
15
  import { AuthCooldown } from "./auth-cooldown.js";
16
16
  import { mintAccessToken } from "./token-mint.js";
17
- import { isMissingBrowserError, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
17
+ import { launchWithBrowserRecovery } from "../utils/browser-install.js";
18
18
  import { signInCommand } from "../utils/commands.js";
19
19
  const SILENT_SSO_TIMEOUT_MS = 30000;
20
20
  const SILENT_SSO_POLL_MS = 1000;
@@ -164,22 +164,14 @@ export class BrowserAuth {
164
164
  };
165
165
  try {
166
166
  state = await this.stateStore.load();
167
- const { chromium } = await import("playwright");
168
167
  const args = ["--disable-blink-features=AutomationControlled"];
169
168
  if (BrowserAuth.isWSLOrDocker())
170
169
  args.push("--no-sandbox", "--disable-setuid-sandbox");
171
170
  // Use Playwright's own timeout, which cleans up an unsuccessful launch.
172
- try {
173
- browser = await this.timed("launch", () => chromium.launch({ headless: this.config.headless, timeout: 60000, args }));
174
- }
175
- catch (launchError) {
176
- // Keep the remedy attached to the failure. Without this the hint is
177
- // lost when the auth runner flattens errors into "Authentication failed".
178
- if (isMissingBrowserError(launchError)) {
179
- throw new BrowserAuthError(PLAYWRIGHT_INSTALL_HINT, "browser_missing", launchError);
180
- }
181
- throw launchError;
182
- }
171
+ browser = await this.timed("launch", () => launchWithBrowserRecovery(async () => {
172
+ const { chromium } = await import("playwright");
173
+ return chromium.launch({ headless: this.config.headless, timeout: 60000, args });
174
+ }));
183
175
  process.once("SIGINT", closeOnSignal);
184
176
  process.once("SIGTERM", closeOnSignal);
185
177
  context = await browser.newContext({ viewport: { width: 1280, height: 720 }, storageState: state });
package/build/auth-cli.js CHANGED
@@ -19,6 +19,7 @@ import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
19
19
  import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
20
20
  import { reexecLatestIfStale } from "./utils/self-update.js";
21
21
  import { detectSkew } from "./utils/install-sites.js";
22
+ import { BrowserInstallError } from "./utils/browser-install.js";
22
23
  dotenv.config({ quiet: true });
23
24
  const pkg = JSON.parse(readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "package.json"), "utf8"));
24
25
  async function requestMfaCode() {
@@ -118,15 +119,18 @@ async function main() {
118
119
  for (const line of relayedLockLines(error.challenge, error.ownerMode))
119
120
  console.log(line);
120
121
  }
121
- process.exitCode = error instanceof NativeCredentialStoreError ? 5
122
- : code === "AUTH_IN_PROGRESS" ? 2
123
- : code === "AUTH_COOLDOWN" ? 3
124
- : code === "AUTH_UNSUPPORTED" ? 4
125
- : code === "AUTH_TRANSPORT" ? 6
126
- : error instanceof MfaApprovalError ? 7 : 1;
122
+ process.exitCode = error instanceof BrowserInstallError ? (error.dependencyMissing ? 9 : 8)
123
+ : error instanceof NativeCredentialStoreError ? 5
124
+ : code === "AUTH_IN_PROGRESS" ? 2
125
+ : code === "AUTH_COOLDOWN" ? 3
126
+ : code === "AUTH_UNSUPPORTED" ? 4
127
+ : code === "AUTH_TRANSPORT" ? 6
128
+ : error instanceof MfaApprovalError ? 7 : 1;
127
129
  console.error("\nAuthentication failed:", error instanceof Error ? error.message : "Unknown authentication error");
128
- console.error(`Run \`${SETUP_COMMAND}\` to update saved credentials.`);
129
- console.error(`Run \`${AUTH_COMMAND}\` to retry explicitly. This bypasses the automatic MFA cooldown.`);
130
+ if (!(error instanceof BrowserInstallError)) {
131
+ console.error(`Run \`${SETUP_COMMAND}\` to update saved credentials.`);
132
+ console.error(`Run \`${AUTH_COMMAND}\` to retry explicitly. This bypasses the automatic MFA cooldown.`);
133
+ }
130
134
  }
131
135
  finally {
132
136
  // The failure path is exactly where knowing you are out of date matters
package/build/doctor.js CHANGED
@@ -29,7 +29,7 @@ import { discoverVersions } from "./api/version-discovery.js";
29
29
  import { D2LApiClient } from "./api/client.js";
30
30
  import { fetchLatestVersion, isNewerVersion, safeVersionLabel, installKindOf, } from "./utils/update-checker.js";
31
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";
32
+ import { chromiumReady, chromiumStatus, dependencyRepairHint, BROWSER_REPAIR_COMMAND } from "./utils/browser-install.js";
33
33
  import { nodeVersionSupported, readOnlyTokenManager, readSavedSessionToken, sessionDirFor } from "./utils/saved-session.js";
34
34
  // Tests import this from doctor.ts.
35
35
  export { readOnlySessionStore } from "./utils/saved-session.js";
@@ -213,7 +213,11 @@ async function checkBrowser(deps) {
213
213
  }
214
214
  if (chromiumReady(status))
215
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()}`);
216
+ if (status.headless === "dependency_missing")
217
+ return problem("browser", dependencyRepairHint());
218
+ if (status.headless === "unknown")
219
+ return problem("browser", `Chromium's test launch failed. Check operating system permissions and browser system dependencies — run: ${BROWSER_REPAIR_COMMAND}`);
220
+ return problem("browser", `Chromium is missing, so sign-in can't start — run: ${BROWSER_REPAIR_COMMAND}`);
217
221
  }
218
222
  async function checkNetwork(deps, baseUrl) {
219
223
  try {
package/build/index.js CHANGED
@@ -26,7 +26,7 @@ const PKG_VERSION = (() => {
26
26
  })();
27
27
  // ── Subcommand routing (before any MCP initialization) ──────────────
28
28
  const subcommand = process.argv[2];
29
- const COMMANDS = ['setup', 'auth', 'doctor', 'http', 'runtime'];
29
+ const COMMANDS = ['setup', 'auth', 'doctor', 'repair', 'http', 'runtime'];
30
30
  // A mistyped command (`htp`) must not quietly start a stdio server that sits
31
31
  // waiting on stdin. Flags alone still mean the default stdio server.
32
32
  if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.includes(subcommand)) {
@@ -37,7 +37,8 @@ if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.include
37
37
  ' http run one shared MCP server on localhost\n' +
38
38
  ' setup configure your school and sign-in\n' +
39
39
  ' auth sign in to Brightspace now\n' +
40
- ' doctor check the install and configuration\n');
40
+ ' doctor check the install and configuration\n' +
41
+ ' repair restore and verify this installation\'s Chromium\n');
41
42
  process.exit(2);
42
43
  }
43
44
  if (subcommand === 'setup') {
@@ -49,6 +50,9 @@ else if (subcommand === 'auth') {
49
50
  else if (subcommand === 'doctor') {
50
51
  await import('./doctor.js');
51
52
  }
53
+ else if (subcommand === 'repair') {
54
+ await (await import('./repair.js')).main();
55
+ }
52
56
  else {
53
57
  // ── MCP Server ──────────────────────────────────────────────────────
54
58
  // default: stdio, relayed to the one shared runtime, started on demand;
@@ -0,0 +1,37 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { resolve } from "node:path";
3
+ import { chromiumStatus, chromiumReady, installChromium, dependencyRepairHint, BROWSER_REPAIR_COMMAND } from "./utils/browser-install.js";
4
+ /** Browser maintenance only: no config, credential-store, client or sign-in imports. */
5
+ export async function runRepair(deps = { status: chromiumStatus, install: installChromium }) {
6
+ let status = await deps.status();
7
+ if (status.headless === "dependency_missing")
8
+ return { ok: false, message: dependencyRepairHint() };
9
+ if (status.headless === "missing" || !status.visible) {
10
+ const installed = await deps.install();
11
+ if (!installed.ok)
12
+ return { ok: false, message: `Chromium installation failed. Check your connection and retry: ${BROWSER_REPAIR_COMMAND}` };
13
+ status = await deps.status();
14
+ }
15
+ return chromiumReady(status)
16
+ ? { ok: true, message: "Chromium is installed and a headless launch and close succeeded." }
17
+ : { ok: false, message: "Chromium could not launch. Check operating system permissions and browser system dependencies, then run repair again." };
18
+ }
19
+ export async function main() {
20
+ const args = process.argv.slice(3);
21
+ if (args.some(arg => arg !== "--json")) {
22
+ console.error("Usage: brightspace-mcp-server repair [--json]");
23
+ process.exitCode = 2;
24
+ return;
25
+ }
26
+ try {
27
+ const result = await runRepair();
28
+ console.log(args.includes("--json") ? JSON.stringify(result) : result.message);
29
+ process.exitCode = result.ok ? 0 : 1;
30
+ }
31
+ catch {
32
+ console.error(`Browser repair failed. Run: ${BROWSER_REPAIR_COMMAND}`);
33
+ process.exitCode = 1;
34
+ }
35
+ }
36
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url))
37
+ void main();
@@ -13,7 +13,7 @@ import { nodeVersionSupported, readSavedSessionToken, sessionDirFor } from "../u
13
13
  import { discoverVersions } from "../api/version-discovery.js";
14
14
  import { getConfigStorePath } from "../utils/config-store.js";
15
15
  import { saveSecureConfig } from "../utils/secure-config.js";
16
- import { chromiumReady, chromiumStatus, installChromium, PLAYWRIGHT_INSTALL_HINT, } from "../utils/browser-install.js";
16
+ import { chromiumReady, chromiumStatus, dependencyRepairHint, installChromium, PLAYWRIGHT_INSTALL_HINT, } from "../utils/browser-install.js";
17
17
  import { DOCTOR_COMMAND, prepareCommand, signInCommand } from "../utils/commands.js";
18
18
  import { runCommandWithoutPrompts } from "../utils/mcp-client-cli.js";
19
19
  import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool } from "./config.js";
@@ -135,7 +135,7 @@ export async function runPrepare(args, overrides = {}) {
135
135
  steps.push(store.ok ? ok("credential-store", store.line) : fail("credential-store", store.line));
136
136
  // ── Chromium ──
137
137
  let browser = await deps.chromiumStatus();
138
- if (!chromiumReady(browser) && args.installBrowser) {
138
+ if (!chromiumReady(browser) && args.installBrowser && browser.headless !== "dependency_missing" && browser.headless !== "unknown") {
139
139
  deps.progress(" Downloading Chromium for sign-in (this can take a few minutes)...");
140
140
  const installed = await deps.installChromium();
141
141
  browser = installed.ok ? await deps.chromiumStatus() : browser;
@@ -143,14 +143,14 @@ export async function runPrepare(args, overrides = {}) {
143
143
  deps.progress(` ${installed.message}`);
144
144
  }
145
145
  if (chromiumReady(browser)) {
146
- steps.push(ok("browser", browser.headless === "unknown"
147
- ? `Chromium is installed (a test launch didn't work here: ${browser.detail ?? "unknown reason"}; sign-in in a terminal normally still works)`
148
- : "Chromium is installed for sign-in"));
146
+ steps.push(ok("browser", "Chromium is installed for sign-in"));
149
147
  }
150
148
  else {
151
- steps.push(fail("browser", args.installBrowser
152
- ? `Chromium could not be installed. ${PLAYWRIGHT_INSTALL_HINT}`
153
- : "Chromium is missing — run prepare again without --no-browser-install"));
149
+ steps.push(fail("browser", browser.headless === "dependency_missing" ? dependencyRepairHint()
150
+ : browser.headless === "unknown" ? "Chromium's test launch failed. Check operating system permissions and browser system dependencies."
151
+ : args.installBrowser
152
+ ? `Chromium could not be installed. ${PLAYWRIGHT_INSTALL_HINT}`
153
+ : "Chromium is missing — run prepare again without --no-browser-install"));
154
154
  }
155
155
  // ── Network, settings, session (need a school) ──
156
156
  let signedIn = false;
@@ -23,7 +23,8 @@ function tooLargeResponse(bytes) {
23
23
  export function registerDownloadDropboxSubmissionFile(server, apiClient) {
24
24
  server.registerTool("download_dropbox_submission_file", {
25
25
  title: "Download Dropbox Submission File",
26
- description: "Download a specific file from a student's (or group's) dropbox submission to a local directory, " +
26
+ description: "Download a specific file from a student's (or group's) dropbox submission to an existing local directory " +
27
+ "(it is not created for you), " +
27
28
  "for an instructor or TA. Use get_dropbox_submissions or get_dropbox_user_submissions first to " +
28
29
  "find submissionId and fileId. Requires instructor or TA access to the course. " +
29
30
  "IMPORTANT: Ask the user where they want to save the file before calling this tool. " +
@@ -48,7 +49,7 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
48
49
  }
49
50
  catch (error) {
50
51
  if (error?.code === "ENOENT") {
51
- return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
52
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
52
53
  }
53
54
  throw error;
54
55
  }
@@ -267,7 +267,7 @@ function extensionNote(requestedFilename, saved) {
267
267
  export function registerDownloadFile(server, apiClient) {
268
268
  server.registerTool("download_file", {
269
269
  title: "Download File",
270
- description: "Download a file from course content, files linked by path inside a module description (coursePath, from get_course_content's quickLink URLs with type=coursefile), instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
270
+ description: "Download a file from course content, files linked by path inside a module description (coursePath, from get_course_content's quickLink URLs with type=coursefile), instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. The directory must already exist; it is not created for you. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
271
271
  inputSchema: DownloadFileSchema,
272
272
  }, async (args) => {
273
273
  try {
@@ -299,7 +299,7 @@ export function registerDownloadFile(server, apiClient) {
299
299
  }
300
300
  catch (error) {
301
301
  if (error?.code === "ENOENT") {
302
- return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
302
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
303
303
  }
304
304
  throw error;
305
305
  }
@@ -110,7 +110,7 @@ async function discoverExternalSyllabusSources(apiClient, courseId) {
110
110
  export function registerGetSyllabus(server, apiClient) {
111
111
  server.registerTool("get_syllabus", {
112
112
  title: "Get Course Syllabus",
113
- description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. When the syllabus lives in an external LTI tool (e.g. Simple Syllabus), lists those sources in externalSyllabusSources; their contents cannot be read. If some of those lookups could not be completed (e.g. permission denied), externalSyllabusDiscovery says which, and an empty result is then not proof the course has no syllabus. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
113
+ description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. When the syllabus lives in an external LTI tool (e.g. Simple Syllabus), lists those sources in externalSyllabusSources; their contents cannot be read. If some of those lookups could not be completed (e.g. permission denied), externalSyllabusDiscovery says which, and an empty result is then not proof the course has no syllabus. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF); the directory must already exist, since it is not created for you. IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
114
114
  inputSchema: GetSyllabusSchema,
115
115
  }, async (args) => {
116
116
  try {
@@ -129,7 +129,7 @@ export function registerGetSyllabus(server, apiClient) {
129
129
  }
130
130
  catch (error) {
131
131
  if (error?.code === "ENOENT") {
132
- return errorResponse(`Download directory does not exist: ${downloadPath}`, "destination");
132
+ return errorResponse(`Download directory does not exist: ${downloadPath}. Create it, then retry, or choose an existing directory.`, "destination");
133
133
  }
134
134
  throw error;
135
135
  }
@@ -78,7 +78,7 @@ export const DownloadFileSchema = z.object({
78
78
  newsId: z.coerce.number().int().positive().optional()
79
79
  .describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
80
80
  downloadPath: z.string().min(1).optional()
81
- .describe("Absolute path to the directory where the file should be saved on disk. Omit this to receive the file inline in the tool response instead — the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see files an MCP server writes to its own host filesystem. Inline mode returns extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, and a short description (pointing back to disk mode) for anything else, capped at 10MB and 400,000 characters of extracted text."),
81
+ .describe("Absolute path to an existing directory where the file should be saved on disk. The directory must already exist; it is not created for you, so create it first if needed. Omit this to receive the file inline in the tool response instead — the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see files an MCP server writes to its own host filesystem. Inline mode returns extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, and a short description (pointing back to disk mode) for anything else, capped at 10MB and 400,000 characters of extracted text."),
82
82
  customFilename: z.string().max(255).optional()
83
83
  .describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
84
84
  });
@@ -86,7 +86,7 @@ export const GetSyllabusSchema = z.object({
86
86
  courseId: z.coerce.number().int().positive()
87
87
  .describe("Course ID to get syllabus for."),
88
88
  downloadPath: z.string().min(1).optional()
89
- .describe("Absolute path to the directory where the attachment should be saved."),
89
+ .describe("Absolute path to an existing directory where the attachment should be saved. The directory must already exist; it is not created for you, so create it first if needed."),
90
90
  });
91
91
  export const GetDiscussionsSchema = z.object({
92
92
  courseId: z.coerce.number().int().positive()
@@ -219,7 +219,7 @@ export const DownloadDropboxSubmissionFileSchema = z.object({
219
219
  fileId: z.coerce.number().int().positive()
220
220
  .describe("File ID within that submission."),
221
221
  downloadPath: z.string().min(1)
222
- .describe("Absolute path to the local directory where the file should be saved."),
222
+ .describe("Absolute path to an existing local directory where the file should be saved. The directory must already exist; it is not created for you, so create it first if needed."),
223
223
  customFilename: z.string().max(255).optional()
224
224
  .describe("Custom filename for the downloaded file (include extension). If omitted, uses the original submission filename."),
225
225
  });
@@ -9,6 +9,7 @@ import { TokenRefreshError } from "../api/errors.js";
9
9
  import { AuthProcessError } from "../auth/auth-runner.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
+ import { dependencyRepairHint, PLAYWRIGHT_INSTALL_HINT } from "../utils/browser-install.js";
12
13
  import { getUpdateNotice } from "../utils/update-checker.js";
13
14
  import { mfaShortcutTip } from "../utils/two-factor-shortcuts.js";
14
15
  import { DownloadError, isSafeDetail, } from "../utils/download-errors.js";
@@ -94,6 +95,8 @@ export function errorResponse(message, kind) {
94
95
  const MFA_SOLO_CALL = "Don't run other Brightspace calls in parallel until the sign-in completes: a batched " +
95
96
  "answer can reach the user too late to approve. Make one call at a time.";
96
97
  const AUTH_FAILURE_GUIDANCE = {
98
+ browserInstall: PLAYWRIGHT_INSTALL_HINT,
99
+ dependencyMissing: dependencyRepairHint(),
97
100
  busy: "A sign-in is already running in another process. Let it finish, then try again, " +
98
101
  `or run \`${AUTH_COMMAND}\` in a terminal to take over that background attempt.`,
99
102
  cooldown: "Automatic sign-in is paused because an MFA prompt went unanswered. " +
@@ -1,39 +1,46 @@
1
1
  /**
2
2
  * Brightspace MCP Server. Copyright (c) 2026 Rohan Muppa. MIT licensed.
3
3
  *
4
- * Recognising a missing Playwright browser.
5
- *
6
- * This package's postinstall downloads Chromium. npm skips postinstall under
7
- * `--ignore-scripts` and under the newer allow-scripts policy, both of which
8
- * are common on global installs, so the package can land without a browser and
9
- * fail only later, at first sign-in.
10
- *
11
- * Playwright's own message does say what to run, but by the time it has been
12
- * wrapped in BrowserAuthError and then flattened into a generic
13
- * "Authentication failed" by the auth runner, the actionable command is gone.
14
- * Matching the error lets us keep the fix attached to the failure.
15
- *
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).
4
+ * Browser downloads are disposable; installed dependency code is not.
5
+ * Repair downloads through this installation's own Playwright CLI. Sign-in
6
+ * repairs only a missing executable and retries once; it never runs npm.
20
7
  */
21
- import { spawn } from "node:child_process";
8
+ import { spawn, execFileSync } from "node:child_process";
22
9
  import * as fs from "node:fs";
23
10
  import * as path from "node:path";
24
11
  import { createRequire } from "node:module";
25
- import { CLEAR_NPX_CACHE_COMMAND } from "./commands.js";
26
- export const PLAYWRIGHT_INSTALL_HINT = "Chromium is not installed, so sign-in cannot start. Your package manager " +
27
- "skipped this package's postinstall script, which is what normally downloads it " +
28
- "(common with --ignore-scripts or an allow-scripts policy). Run: " +
29
- "npx playwright install chromium" +
30
- `. If that does not help, reinstall and clear caches with ${CLEAR_NPX_CACHE_COMMAND}.`;
12
+ import { fileURLToPath } from "node:url";
13
+ /** Run the installation that reported the error, even from another project. */
14
+ export const BROWSER_REPAIR_COMMAND = `node "${fileURLToPath(new URL("../index.js", import.meta.url)).replaceAll("\\", "/")}" repair`;
15
+ export function dependencyRepairHint() {
16
+ const root = fileURLToPath(new URL("../../", import.meta.url)).replaceAll("\\", "/");
17
+ const reinstall = root.split("/").includes("node_modules")
18
+ ? `npm install --prefix "${root}" --omit=dev`
19
+ : `npm ci --prefix "${root}", then npm run build in that directory`;
20
+ return "Playwright's package code is missing. Restore this installation's declared dependencies with " +
21
+ `${reinstall}, then run: ${BROWSER_REPAIR_COMMAND}`;
22
+ }
23
+ export class BrowserInstallError extends Error {
24
+ dependencyMissing;
25
+ constructor(dependencyMissing = false) {
26
+ super(dependencyMissing ? dependencyRepairHint() : PLAYWRIGHT_INSTALL_HINT);
27
+ this.dependencyMissing = dependencyMissing;
28
+ this.name = "BrowserInstallError";
29
+ }
30
+ }
31
+ export function isMissingPlaywrightError(error) {
32
+ const code = error?.code;
33
+ return (code === "ERR_MODULE_NOT_FOUND" || code === "MODULE_NOT_FOUND") &&
34
+ /playwright/.test(error instanceof Error ? error.message : "");
35
+ }
36
+ export const PLAYWRIGHT_INSTALL_HINT = "Chromium is missing or could not be restored. It may have been removed after installation, " +
37
+ `or the install script may have been skipped. Run: ${BROWSER_REPAIR_COMMAND}`;
31
38
  /** True when an error is Playwright reporting that no browser binary exists. */
32
39
  export function isMissingBrowserError(error) {
33
40
  const message = error instanceof Error ? error.message : typeof error === "string" ? error : "";
34
41
  if (!message)
35
42
  return false;
36
- return /Executable doesn't exist|playwright install|Failed to launch.*because executable doesn't exist/i.test(message);
43
+ return /Executable doesn't exist|Failed to launch.*because executable doesn't exist/i.test(message);
37
44
  }
38
45
  const defaultProbe = {
39
46
  async launchHeadless() {
@@ -56,7 +63,10 @@ export async function chromiumStatus(overrides = {}) {
56
63
  await probe.launchHeadless();
57
64
  }
58
65
  catch (error) {
59
- if (isMissingBrowserError(error)) {
66
+ if (isMissingPlaywrightError(error)) {
67
+ headless = "dependency_missing";
68
+ }
69
+ else if (isMissingBrowserError(error)) {
60
70
  headless = "missing";
61
71
  }
62
72
  else {
@@ -75,7 +85,42 @@ export async function chromiumStatus(overrides = {}) {
75
85
  }
76
86
  /** True when sign-in has every browser it might need. */
77
87
  export function chromiumReady(status) {
78
- return status.headless !== "missing" && status.visible;
88
+ return status.headless === "present" && status.visible;
89
+ }
90
+ /** One repair and one retry; unrelated launch failures never download anything.
91
+ * Authentication already holds its process lock; Playwright's installer also
92
+ * locks the shared browser cache across installations and accounts.
93
+ */
94
+ export async function launchWithBrowserRecovery(launch, install = installChromium, progress = () => console.error("Restoring Chromium for Brightspace sign-in...")) {
95
+ try {
96
+ return await launch();
97
+ }
98
+ catch (error) {
99
+ if (isMissingPlaywrightError(error))
100
+ throw new BrowserInstallError(true);
101
+ if (!isMissingBrowserError(error))
102
+ throw error;
103
+ }
104
+ progress();
105
+ let installed;
106
+ try {
107
+ installed = await install();
108
+ }
109
+ catch {
110
+ throw new BrowserInstallError();
111
+ }
112
+ if (!installed.ok)
113
+ throw new BrowserInstallError();
114
+ try {
115
+ return await launch();
116
+ }
117
+ catch (error) {
118
+ if (isMissingPlaywrightError(error))
119
+ throw new BrowserInstallError(true);
120
+ if (isMissingBrowserError(error))
121
+ throw new BrowserInstallError();
122
+ throw error;
123
+ }
79
124
  }
80
125
  /** The CLI of the Playwright this package depends on, so the browser revision matches. */
81
126
  export function playwrightCliPath() {
@@ -99,7 +144,30 @@ export function installChromium(options = {}) {
99
144
  return Promise.resolve({ ok: false, message: error instanceof Error ? error.message : String(error) });
100
145
  }
101
146
  return new Promise((resolve) => {
102
- const child = spawnImpl(execPath, [cliPath, "install", "chromium"], { stdio: ["ignore", 2, 2] });
147
+ const child = spawnImpl(execPath, [cliPath, "install", "chromium"], {
148
+ stdio: ["ignore", 2, 2], detached: process.platform !== "win32",
149
+ });
150
+ const timer = setTimeout(() => {
151
+ // Include Playwright's download worker, so a timeout cannot leave an
152
+ // orphan downloading into the cache or holding its installation lock.
153
+ try {
154
+ if (child.pid && process.platform === "win32") {
155
+ execFileSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { stdio: "ignore", timeout: 5000 });
156
+ }
157
+ else if (child.pid) {
158
+ process.kill(-child.pid, "SIGKILL");
159
+ }
160
+ else
161
+ child.kill("SIGKILL");
162
+ }
163
+ catch {
164
+ child.kill("SIGKILL");
165
+ }
166
+ resolve({ ok: false, message: "Chromium installation timed out. Check your connection and run repair again." });
167
+ }, options.timeoutMs ?? 120_000);
168
+ timer.unref();
169
+ child.once("close", () => clearTimeout(timer));
170
+ child.once("error", () => clearTimeout(timer));
103
171
  child.once("error", (error) => resolve({ ok: false, message: error.message }));
104
172
  child.once("close", (code) => resolve(code === 0 ? { ok: true } : { ok: false, message: `Playwright's installer exited with code ${code}.` }));
105
173
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.6",
3
+ "version": "5.1.8",
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",
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "build",
15
+ "scripts/install-browser.cjs",
15
16
  "README.md",
16
17
  "LICENSE"
17
18
  ],
@@ -24,7 +25,8 @@
24
25
  "dev": "tsc --watch",
25
26
  "test": "vitest",
26
27
  "test:run": "vitest run",
27
- "postinstall": "npx playwright install chromium"
28
+ "test:install": "node scripts/install-smoke.mjs",
29
+ "postinstall": "node scripts/install-browser.cjs"
28
30
  },
29
31
  "dependencies": {
30
32
  "@modelcontextprotocol/sdk": "^1.26.0",
@@ -0,0 +1,18 @@
1
+ // Works in both a source checkout (before build) and the published tarball.
2
+ // Resolve this package's dependency, never a Playwright from the caller's cwd.
3
+ const { spawnSync } = require("node:child_process");
4
+ const { dirname, join } = require("node:path");
5
+ const cli = join(dirname(require.resolve("playwright/package.json")), "cli.js");
6
+ const result = spawnSync(process.execPath, [cli, "install", "chromium"], {
7
+ stdio: "inherit",
8
+ });
9
+ if (result.error || result.status !== 0) {
10
+ console.error("Chromium installation failed. Retry with brightspace-mcp-server repair after installation completes.");
11
+ process.exit(1);
12
+ }
13
+ require("playwright").chromium.launch({ headless: true, timeout: 30_000 })
14
+ .then(browser => browser.close())
15
+ .catch(() => {
16
+ console.error("Chromium was downloaded but could not launch. Check operating system permissions and browser system dependencies.");
17
+ process.exitCode = 1;
18
+ });