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 +4 -1
- package/build/auth/auth-runner.js +3 -0
- package/build/auth/browser-auth.js +5 -13
- package/build/auth-cli.js +12 -8
- package/build/doctor.js +6 -2
- package/build/index.js +6 -2
- package/build/repair.js +37 -0
- package/build/setup/prepare.js +8 -8
- package/build/tools/download-dropbox-submission-file.js +3 -2
- package/build/tools/download-file.js +2 -2
- package/build/tools/get-syllabus.js +2 -2
- package/build/tools/schemas.js +3 -3
- package/build/tools/tool-helpers.js +3 -0
- package/build/utils/browser-install.js +95 -27
- package/package.json +4 -2
- package/scripts/install-browser.cjs +18 -0
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 {
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
|
122
|
-
:
|
|
123
|
-
: code === "
|
|
124
|
-
: code === "
|
|
125
|
-
: code === "
|
|
126
|
-
:
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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;
|
package/build/repair.js
ADDED
|
@@ -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();
|
package/build/setup/prepare.js
CHANGED
|
@@ -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",
|
|
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",
|
|
152
|
-
?
|
|
153
|
-
|
|
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
|
|
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}
|
|
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}
|
|
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}
|
|
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
|
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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 {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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|
|
|
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 (
|
|
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
|
|
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"], {
|
|
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.
|
|
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
|
-
"
|
|
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
|
+
});
|