brightspace-mcp-server 5.1.21 → 5.1.23
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 +1 -1
- package/build/auth/auth-lock.js +1 -1
- package/build/auth/auth-runner.js +20 -8
- package/build/auth/logout.js +33 -7
- package/build/auth/token-manager.js +3 -0
- package/build/doctor.js +15 -1
- package/build/server.js +4 -7
- package/build/tools/download-file.js +16 -3
- package/build/tools/get-syllabus.js +6 -2
- package/build/tools/index.js +1 -1
- package/build/tools/search-course.js +13 -1
- package/build/tools/sign-out.js +4 -4
- package/build/utils/commands.js +15 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,7 +159,7 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
159
159
|
|
|
160
160
|
**Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
|
|
161
161
|
|
|
162
|
-
**To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else.
|
|
162
|
+
**To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Both also cancel a sign-in an AI app started that is still waiting for you to approve it (`auth --logout` can't on Windows, and leaves an `auth` run in another terminal alone, deleting nothing). Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
|
|
163
163
|
|
|
164
164
|
How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
|
|
165
165
|
|
package/build/auth/auth-lock.js
CHANGED
|
@@ -119,7 +119,7 @@ export async function inspectLockOwner(lockPath) {
|
|
|
119
119
|
/**
|
|
120
120
|
* The PID of a live "automatic" owner (a background AuthRunner child), or
|
|
121
121
|
* undefined when the lock is free, its owner is gone, or it is held some other
|
|
122
|
-
* way. Used by sign_out to cancel another app's waiting sign-in.
|
|
122
|
+
* way. Used by sign_out and `auth --logout` to cancel another app's waiting sign-in.
|
|
123
123
|
*/
|
|
124
124
|
export async function liveAutomaticOwner(lockPath) {
|
|
125
125
|
const owner = await readOwner(lockPath);
|
|
@@ -243,16 +243,25 @@ function forceStop(pid) {
|
|
|
243
243
|
const CANCEL_WAIT_MS = 5000;
|
|
244
244
|
/**
|
|
245
245
|
* Stop a background sign-in that ANOTHER server process on this computer
|
|
246
|
-
* started and that holds the cross-process lock, so sign_out
|
|
247
|
-
* sign-in whichever app began it. Only an "automatic" owner is stopped (a
|
|
246
|
+
* started and that holds the cross-process lock, so sign_out and
|
|
247
|
+
* `auth --logout` can cancel a sign-in whichever app began it. Only an "automatic" owner is stopped (a
|
|
248
248
|
* person's `auth` run in a terminal never is), and only after its command
|
|
249
249
|
* line confirms it is this package's sign-in child: the PID comes from a file,
|
|
250
250
|
* and a PID the system has since reused must never be killed. That check
|
|
251
|
-
* needs ps, so on Windows nothing is stopped and sign_out
|
|
252
|
-
* as one it cannot cancel. Its own server's waiting calls are answered
|
|
251
|
+
* needs ps, so on Windows nothing is stopped and sign_out or `auth --logout`
|
|
252
|
+
* reports the sign-in as one it cannot cancel. Its own server's waiting calls are answered
|
|
253
253
|
* "cancelled" (see the close handler in spawnAuth). The dead owner leaves a
|
|
254
254
|
* stale lock the next lock holder reclaims. Resolves true once it has exited.
|
|
255
255
|
*/
|
|
256
|
+
/**
|
|
257
|
+
* True when a `ps` command line is this package's background sign-in child:
|
|
258
|
+
* `auth-cli.js` as a whole path segment, followed later by a standalone
|
|
259
|
+
* `--automatic`. The runtime binary is not checked, since an app may run the
|
|
260
|
+
* server on an embedded runtime that isn't named node.
|
|
261
|
+
*/
|
|
262
|
+
export function isAutomaticSignInCommand(command) {
|
|
263
|
+
return /(?:^|[\s/\\])auth-cli\.js\s(?:.*\s)?--automatic(?:\s|$)/.test(command.trim());
|
|
264
|
+
}
|
|
256
265
|
export async function stopAutomaticSignIn(lockPath) {
|
|
257
266
|
if (process.platform === "win32")
|
|
258
267
|
return false;
|
|
@@ -266,17 +275,20 @@ export async function stopAutomaticSignIn(lockPath) {
|
|
|
266
275
|
catch {
|
|
267
276
|
return false;
|
|
268
277
|
}
|
|
269
|
-
if (
|
|
278
|
+
if (!isAutomaticSignInCommand(command))
|
|
270
279
|
return false;
|
|
271
|
-
log("WARN", `Cancelling another app's background sign-in (pid ${pid})
|
|
280
|
+
log("WARN", `Cancelling another app's background sign-in (pid ${pid}) to sign out`);
|
|
272
281
|
forceStop(pid);
|
|
273
282
|
const deadline = Date.now() + CANCEL_WAIT_MS;
|
|
274
283
|
while (Date.now() < deadline) {
|
|
284
|
+
// Only "no such process" means it exited: EPERM is a live process this
|
|
285
|
+
// user cannot signal, which the SIGKILL did not stop either.
|
|
275
286
|
try {
|
|
276
287
|
process.kill(pid, 0);
|
|
277
288
|
}
|
|
278
|
-
catch {
|
|
279
|
-
|
|
289
|
+
catch (error) {
|
|
290
|
+
if (error.code === "ESRCH")
|
|
291
|
+
return true;
|
|
280
292
|
}
|
|
281
293
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
282
294
|
}
|
package/build/auth/logout.js
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `auth --logout`: end the saved Brightspace session on this computer early.
|
|
5
5
|
*
|
|
6
|
-
* This is a local sign-out only. It
|
|
7
|
-
*
|
|
6
|
+
* This is a local sign-out only. It first stops a background sign-in an AI
|
|
7
|
+
* app started (as sign_out does), then deletes the files that let the next
|
|
8
|
+
* tool call skip the browser, so that call does a full sign-in. It does not log out
|
|
8
9
|
* of Microsoft or D2L on their servers, and it leaves the saved password,
|
|
9
10
|
* config.json, and every credential-store entry (including the session
|
|
10
11
|
* encryption key, which the next sign-in reuses) exactly as they were.
|
|
@@ -15,6 +16,7 @@ import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.j
|
|
|
15
16
|
import { BROWSER_STATE_FILE, LEGACY_BROWSER_STATE_FILE } from "./browser-state-store.js";
|
|
16
17
|
import { MICROSOFT_SESSION_FILE } from "./microsoft-session.js";
|
|
17
18
|
import { authLockPath } from "./mfa-challenge.js";
|
|
19
|
+
import { stopAutomaticSignIn } from "./auth-runner.js";
|
|
18
20
|
import { SESSION_FILE, SessionStore } from "./session-store.js";
|
|
19
21
|
import { AUTH_COMMAND } from "../utils/commands.js";
|
|
20
22
|
import { resolveSessionLocation } from "../utils/config.js";
|
|
@@ -94,16 +96,38 @@ export async function clearSavedSession(sessionDir) {
|
|
|
94
96
|
const KEPT = "Kept: your saved password, config.json, and any authenticator or credential-store entries.";
|
|
95
97
|
const MEMORY_NOTE = "Other Brightspace apps open on this computer sign out on their next request. One you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.";
|
|
96
98
|
const LOCAL_ONLY = "This only clears files on this computer. It does not sign you out of Microsoft or Brightspace on their servers.";
|
|
99
|
+
const CANCELLED = "Cancelled the Brightspace sign-in that was waiting for approval, so the prompt or number from it no longer works.";
|
|
100
|
+
const NEXT_PROMPT = "Microsoft keeps that prompt open for up to 5 minutes after sending it and sends this account no other until then, so the next sign-in can take that long to show a new number to approve.";
|
|
97
101
|
/**
|
|
98
102
|
* The `auth --logout` command. Resolves to the process exit code: 0 when the
|
|
99
|
-
* session was cleared or there was nothing to clear, 2 when a sign-in
|
|
100
|
-
*
|
|
103
|
+
* session was cleared or there was nothing to clear, 2 when a sign-in or
|
|
104
|
+
* sign-out it can't stop holds the lock (the same code `auth` uses for that),
|
|
105
|
+
* 1 for anything else. A background sign-in an AI app started is stopped
|
|
106
|
+
* first; a person's `auth` run is never stopped.
|
|
101
107
|
*/
|
|
102
108
|
export async function runLogout(options = {}) {
|
|
103
109
|
const print = options.print ?? ((line) => console.error(line));
|
|
104
110
|
try {
|
|
105
111
|
const sessionDir = options.sessionDir ?? resolveSessionLocation().sessionDir;
|
|
106
|
-
|
|
112
|
+
// A sign-in an AI app started in the background holds the lock, and a
|
|
113
|
+
// terminal has no other way to stop it, so stop it as sign_out does.
|
|
114
|
+
const stopBackground = options.stopBackground ?? ((dir) => stopAutomaticSignIn(authLockPath(dir)));
|
|
115
|
+
const cancelled = await stopBackground(sessionDir);
|
|
116
|
+
if (cancelled)
|
|
117
|
+
print(CANCELLED);
|
|
118
|
+
let removed;
|
|
119
|
+
try {
|
|
120
|
+
({ removed } = await clearSavedSession(sessionDir));
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
// The lock was taken again between stopping one sign-in and clearing:
|
|
124
|
+
// most likely the AI app's next question started a new one.
|
|
125
|
+
if (cancelled && error instanceof AuthenticationInProgressError) {
|
|
126
|
+
print("Not signed out: a new sign-in started before the session could be cleared, so nothing was deleted. Run this command again.");
|
|
127
|
+
return 2;
|
|
128
|
+
}
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
107
131
|
if (removed.length === 0) {
|
|
108
132
|
print("No saved Brightspace session was found on this computer, so there is nothing to clear.");
|
|
109
133
|
}
|
|
@@ -114,13 +138,15 @@ export async function runLogout(options = {}) {
|
|
|
114
138
|
print(`The next Brightspace request, or \`${AUTH_COMMAND}\`, will do a full sign-in.`);
|
|
115
139
|
}
|
|
116
140
|
print(MEMORY_NOTE);
|
|
141
|
+
if (cancelled)
|
|
142
|
+
print(NEXT_PROMPT);
|
|
117
143
|
print(LOCAL_ONLY);
|
|
118
144
|
return 0;
|
|
119
145
|
}
|
|
120
146
|
catch (error) {
|
|
121
147
|
if (error instanceof AuthenticationInProgressError) {
|
|
122
|
-
print("Not signed out: a sign-in is in progress
|
|
123
|
-
print("
|
|
148
|
+
print("Not signed out: a sign-in or sign-out this command can't stop is in progress, so nothing was deleted.");
|
|
149
|
+
print("If it is an `auth` run in another terminal, finish or close it there. On Windows, a sign-in an AI app started can't be stopped from here: wait for it to finish (up to 5 minutes). Then run this command again.");
|
|
124
150
|
return 2;
|
|
125
151
|
}
|
|
126
152
|
if (error instanceof RemoveError) {
|
|
@@ -262,6 +262,9 @@ export class TokenManager {
|
|
|
262
262
|
capturedAt: now,
|
|
263
263
|
expiresAt: now + FAR_FUTURE_MS,
|
|
264
264
|
source: "env",
|
|
265
|
+
// The token answers API calls; web pages such as course files only
|
|
266
|
+
// accept the session cookie, so keep it when both were pasted.
|
|
267
|
+
...(this.envSessionCookie ? { cookieHeader: this.envSessionCookie } : {}),
|
|
265
268
|
};
|
|
266
269
|
}
|
|
267
270
|
return {
|
package/build/doctor.js
CHANGED
|
@@ -28,7 +28,8 @@ import { describeShortcuts } from "./utils/two-factor-shortcuts.js";
|
|
|
28
28
|
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
|
-
import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, prepareCommand, signInCommand, } from "./utils/commands.js";
|
|
31
|
+
import { SETUP_COMMAND, AUTH_COMMAND, GLOBAL_INSTALL_COMMAND, CLEAR_NPX_CACHE_COMMAND, prepareCommand, signInCommand, envCredentialRejectedMessage, } from "./utils/commands.js";
|
|
32
|
+
import { ApiError } from "./api/errors.js";
|
|
32
33
|
import { chromiumReady, chromiumStatus, dependencyRepairHint, BROWSER_REPAIR_COMMAND } from "./utils/browser-install.js";
|
|
33
34
|
import { nodeVersionSupported, readOnlyTokenManager, readSavedSessionToken, sessionDirFor } from "./utils/saved-session.js";
|
|
34
35
|
// Tests import this from doctor.ts.
|
|
@@ -236,6 +237,11 @@ async function checkSession(deps, baseUrl, sessionDir, envAccessToken, envSessio
|
|
|
236
237
|
: `Not signed in yet — run this in a terminal: ${signInCommand(baseUrl)}`;
|
|
237
238
|
try {
|
|
238
239
|
const token = await deps.getSessionToken(baseUrl, sessionDir, envAccessToken, envSessionCookie);
|
|
240
|
+
// A pasted credential is used as given, so nothing was issued here: the
|
|
241
|
+
// course check below is what shows whether Brightspace accepts it.
|
|
242
|
+
const pasted = envAccessToken ? "D2L_ACCESS_TOKEN" : envSessionCookie ? "D2L_SESSION_COOKIE" : undefined;
|
|
243
|
+
if (token && pasted)
|
|
244
|
+
return { ok: true, result: pass("session", `Using the credential in ${pasted}; the course check below shows whether Brightspace accepts it`) };
|
|
239
245
|
if (token)
|
|
240
246
|
return { ok: true, result: pass("session", "Saved sign-in works — a token was issued without opening a browser") };
|
|
241
247
|
return { ok: false, result: problem("session", failMessage) };
|
|
@@ -262,6 +268,14 @@ async function checkCourses(deps, baseUrl, sessionDir, envAccessToken, envSessio
|
|
|
262
268
|
return pass("courses", `Found ${label} course${count === 1 ? "" : "s"} on Brightspace`);
|
|
263
269
|
}
|
|
264
270
|
catch (error) {
|
|
271
|
+
const rejected = error instanceof ApiError && error.status === 401
|
|
272
|
+
? envCredentialRejectedMessage(envAccessToken, envSessionCookie)
|
|
273
|
+
: undefined;
|
|
274
|
+
if (rejected)
|
|
275
|
+
return problem("courses", `Brightspace refused the course list. ${rejected}`);
|
|
276
|
+
if (error instanceof ApiError && error.status === 401) {
|
|
277
|
+
return problem("courses", `Brightspace refused the saved sign-in when loading your courses — sign in again: ${AUTH_COMMAND}`);
|
|
278
|
+
}
|
|
265
279
|
const detail = error instanceof Error ? sanitizeErrorDetail(error.message) : "an unknown error";
|
|
266
280
|
return problem("courses", `Could not load your courses (${detail}) — run doctor again after checking your connection.`);
|
|
267
281
|
}
|
package/build/server.js
CHANGED
|
@@ -8,7 +8,8 @@ import { TokenManager, AuthRunner } from "./auth/index.js";
|
|
|
8
8
|
import { D2LApiClient } from "./api/index.js";
|
|
9
9
|
import { trackToolActivity } from "./utils/dev-activity.js";
|
|
10
10
|
import { log } from "./utils/logger.js";
|
|
11
|
-
import {
|
|
11
|
+
import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
|
|
12
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
12
13
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
13
14
|
/**
|
|
14
15
|
* Build the shared runtime. Nothing here reaches Brightspace: API versions
|
|
@@ -48,11 +49,7 @@ export function createRuntime(config, version) {
|
|
|
48
49
|
baseUrl: config.baseUrl,
|
|
49
50
|
tokenManager,
|
|
50
51
|
onAuthExpired: authRunner ? () => authRunner.run(currentMfaAnnouncer()) : undefined,
|
|
51
|
-
authExpiredMessage: config.envAccessToken
|
|
52
|
-
? "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set."
|
|
53
|
-
: config.envSessionCookie
|
|
54
|
-
? "The pasted D2L_SESSION_COOKIE has expired. Copy a fresh d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser and update the environment variable; this server does not fall back to a browser login while D2L_SESSION_COOKIE is set."
|
|
55
|
-
: undefined,
|
|
52
|
+
authExpiredMessage: envCredentialRejectedMessage(config.envAccessToken, config.envSessionCookie),
|
|
56
53
|
});
|
|
57
54
|
return { config, version, tokenManager, apiClient, authRunner };
|
|
58
55
|
}
|
|
@@ -87,7 +84,7 @@ export function createMcpServer(runtime) {
|
|
|
87
84
|
registerGetAssignmentFiles(server, apiClient, config.baseUrl);
|
|
88
85
|
registerGetAnnouncementFiles(server, apiClient);
|
|
89
86
|
registerGetCourseContent(server, apiClient);
|
|
90
|
-
registerDownloadFile(server, apiClient);
|
|
87
|
+
registerDownloadFile(server, apiClient, config.envSessionCookie ? ENV_COOKIE_REJECTED : config.envAccessToken ? COURSE_FILE_NEEDS_COOKIE : undefined);
|
|
91
88
|
registerGetClasslistEmails(server, apiClient);
|
|
92
89
|
registerGetRoster(server, apiClient);
|
|
93
90
|
registerGetMyGroups(server, apiClient);
|
|
@@ -261,10 +261,21 @@ function extensionNote(requestedFilename, saved) {
|
|
|
261
261
|
`not the format the requested name implied. Open it with an application that reads ${savedExt} files.`,
|
|
262
262
|
};
|
|
263
263
|
}
|
|
264
|
+
/**
|
|
265
|
+
* Course files need a Brightspace web session, which D2L_ACCESS_TOKEN alone
|
|
266
|
+
* does not carry, and signing in again would not give it one.
|
|
267
|
+
*/
|
|
268
|
+
export const COURSE_FILE_NEEDS_COOKIE = "Course files are served only to a signed-in Brightspace web session, and D2L_ACCESS_TOKEN alone carries none. Set D2L_SESSION_COOKIE as well (a d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser) and restart the server.";
|
|
264
269
|
/**
|
|
265
270
|
* Register download_file tool
|
|
266
271
|
*/
|
|
267
|
-
export function registerDownloadFile(server, apiClient
|
|
272
|
+
export function registerDownloadFile(server, apiClient,
|
|
273
|
+
/**
|
|
274
|
+
* The fix to show when the session cookie a course file needs is missing
|
|
275
|
+
* or refused, set when the server runs on a pasted credential: there is no
|
|
276
|
+
* browser sign-in behind it, so signing in again would not help.
|
|
277
|
+
*/
|
|
278
|
+
cookieRejectedMessage) {
|
|
268
279
|
server.registerTool("download_file", {
|
|
269
280
|
title: "Download File",
|
|
270
281
|
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).",
|
|
@@ -309,7 +320,7 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
309
320
|
const filePath = parseCoursePath(coursePath, courseId, apiClient.origin);
|
|
310
321
|
if (typeof filePath !== "string")
|
|
311
322
|
return errorResponse(filePath.error, "invalidInput");
|
|
312
|
-
return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename);
|
|
323
|
+
return await downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename, cookieRejectedMessage);
|
|
313
324
|
}
|
|
314
325
|
else if (topicId !== undefined) {
|
|
315
326
|
// Content file download
|
|
@@ -343,10 +354,12 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
343
354
|
* topics, so they have no topicId. Brightspace serves them to the signed-in
|
|
344
355
|
* web session, not to the API token, so this goes through the session cookie.
|
|
345
356
|
*/
|
|
346
|
-
async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename) {
|
|
357
|
+
async function downloadCourseFile(apiClient, courseId, filePath, downloadPath, customFilename, cookieRejectedMessage) {
|
|
347
358
|
log("INFO", `Downloading course file: courseId=${courseId}, path=${filePath}`);
|
|
348
359
|
const response = await apiClient.getPageRaw(`${QUICKLINK_PATH}?ou=${courseId}&type=coursefile&fileId=${encodeURIComponent(filePath)}`);
|
|
349
360
|
if (!response) {
|
|
361
|
+
if (cookieRejectedMessage)
|
|
362
|
+
return errorResponse(cookieRejectedMessage, "auth");
|
|
350
363
|
return errorResponse(`Course files are served only to a signed-in Brightspace web session, and there is no valid one: the session cookie is missing or has expired. Sign in again with \`${AUTH_COMMAND}\` and retry. A server that runs on D2L_ACCESS_TOKEN alone has no cookie: set D2L_SESSION_COOKIE as well, or sign in through the browser.`, "auth");
|
|
351
364
|
}
|
|
352
365
|
// A missing file or a refused one can come back as an ordinary Brightspace
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { GetSyllabusSchema } from "./schemas.js";
|
|
8
|
-
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
8
|
+
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable } from "./tool-helpers.js";
|
|
9
9
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
10
10
|
import { stripD2lSessionParams } from "../utils/session-params.js";
|
|
11
11
|
import { secureDownload, readBodyCapped } from "../utils/download-helpers.js";
|
|
@@ -61,9 +61,13 @@ async function tryLookup(lookup, fetch) {
|
|
|
61
61
|
return { ok: true, value: await fetch() };
|
|
62
62
|
}
|
|
63
63
|
catch (error) {
|
|
64
|
+
// A pending or refused sign-in is not a permission the student lacks:
|
|
65
|
+
// the lookup never ran, so the call fails with the sign-in's answer.
|
|
66
|
+
if (isAuthUnavailable(error))
|
|
67
|
+
throw error;
|
|
64
68
|
log("DEBUG", `get_syllabus: could not read ${LOOKUP_LABELS[lookup]}`, error);
|
|
65
69
|
if (error instanceof ApiError) {
|
|
66
|
-
const forbidden = error.status ===
|
|
70
|
+
const forbidden = error.status === 403;
|
|
67
71
|
return {
|
|
68
72
|
ok: false,
|
|
69
73
|
unavailable: { lookup, reason: forbidden ? "permission-denied" : "request-failed", status: error.status },
|
package/build/tools/index.js
CHANGED
|
@@ -14,7 +14,7 @@ export { registerGetAssignmentRubric } from "./get-assignment-rubric.js";
|
|
|
14
14
|
export { registerGetAssignmentFiles } from "./get-assignment-files.js";
|
|
15
15
|
export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
|
|
16
16
|
export { registerGetCourseContent } from "./get-course-content.js";
|
|
17
|
-
export { registerDownloadFile } from "./download-file.js";
|
|
17
|
+
export { registerDownloadFile, COURSE_FILE_NEEDS_COOKIE } from "./download-file.js";
|
|
18
18
|
export { registerGetClasslistEmails } from "./get-classlist-emails.js";
|
|
19
19
|
export { registerGetRoster } from "./get-roster.js";
|
|
20
20
|
export { registerGetMyGroups } from "./get-my-groups.js";
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { ApiError } from "../api/index.js";
|
|
7
7
|
import { SearchCourseSchema } from "./schemas.js";
|
|
8
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
8
|
+
import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
|
|
9
9
|
import { log } from "../utils/logger.js";
|
|
10
10
|
import { fetchRootContent, buildContentTree } from "./get-course-content.js";
|
|
11
11
|
import { fetchCourseNews, isPublishedNewsItem, mapNewsItem } from "./get-announcements.js";
|
|
@@ -188,6 +188,10 @@ export function registerSearchCourse(server, apiClient) {
|
|
|
188
188
|
entries.push(...flattenContentTree(tree));
|
|
189
189
|
}
|
|
190
190
|
catch (error) {
|
|
191
|
+
// A pending or refused sign-in is not a source that failed: the
|
|
192
|
+
// search never ran, and its answer carries what the user must do.
|
|
193
|
+
if (isAuthUnavailable(error))
|
|
194
|
+
throw error;
|
|
191
195
|
log("DEBUG", `search_course: content fetch failed for course ${courseId}`, error);
|
|
192
196
|
notes.push(`content: ${describeFailure(error)}`);
|
|
193
197
|
}
|
|
@@ -205,6 +209,10 @@ export function registerSearchCourse(server, apiClient) {
|
|
|
205
209
|
}
|
|
206
210
|
}
|
|
207
211
|
catch (error) {
|
|
212
|
+
// A pending or refused sign-in is not a source that failed: the
|
|
213
|
+
// search never ran, and its answer carries what the user must do.
|
|
214
|
+
if (isAuthUnavailable(error))
|
|
215
|
+
throw error;
|
|
208
216
|
log("DEBUG", `search_course: announcements fetch failed for course ${courseId}`, error);
|
|
209
217
|
notes.push(`announcements: ${describeFailure(error)}`);
|
|
210
218
|
}
|
|
@@ -234,12 +242,16 @@ export function registerSearchCourse(server, apiClient) {
|
|
|
234
242
|
// One forum's topics failing (e.g. no access) shouldn't drop
|
|
235
243
|
// every other forum, but it still has to be named in `note` so
|
|
236
244
|
// a search missing those topics never reads as complete.
|
|
245
|
+
if (isAuthUnavailable(error))
|
|
246
|
+
throw error;
|
|
237
247
|
log("DEBUG", `search_course: topics fetch failed for forum ${forum.ForumId}`, error);
|
|
238
248
|
notes.push(`discussions: topics in "${forum.Name}" ${describeFailure(error)}`);
|
|
239
249
|
}
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
catch (error) {
|
|
253
|
+
if (isAuthUnavailable(error))
|
|
254
|
+
throw error;
|
|
243
255
|
log("DEBUG", `search_course: discussions fetch failed for course ${courseId}`, error);
|
|
244
256
|
notes.push(`discussions: ${describeFailure(error)}`);
|
|
245
257
|
}
|
package/build/tools/sign-out.js
CHANGED
|
@@ -27,10 +27,10 @@ function text(message, isError = false) {
|
|
|
27
27
|
/**
|
|
28
28
|
* Register sign_out tool.
|
|
29
29
|
*
|
|
30
|
-
* The in-chat version of `auth --logout
|
|
31
|
-
*
|
|
32
|
-
* server's (AuthRunner.cancel, which answers every
|
|
33
|
-
*
|
|
30
|
+
* The in-chat version of `auth --logout`. Both first cancel a background
|
|
31
|
+
* sign-in another app on this computer started (stopAutomaticSignIn); this
|
|
32
|
+
* one also cancels this server's own (AuthRunner.cancel, which answers every
|
|
33
|
+
* call waiting on it), which the command has no server to do. A user who missed the
|
|
34
34
|
* prompt otherwise waited out its 5-minute window for a new one. Then it
|
|
35
35
|
* deletes this computer's saved
|
|
36
36
|
* session for the account the server is configured with (clearSavedSession,
|
package/build/utils/commands.js
CHANGED
|
@@ -28,6 +28,21 @@ export const DOCTOR_COMMAND = `npx -y ${PACKAGE_NAME}@latest doctor`;
|
|
|
28
28
|
export const GLOBAL_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}@latest`;
|
|
29
29
|
/** Drop cached npx copies so the next `@latest` genuinely refetches. */
|
|
30
30
|
export const CLEAR_NPX_CACHE_COMMAND = "npx clear-npx-cache";
|
|
31
|
+
/** What to do when Brightspace rejects a pasted D2L_ACCESS_TOKEN. */
|
|
32
|
+
export const ENV_TOKEN_REJECTED = "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set.";
|
|
33
|
+
/** What to do when Brightspace rejects a pasted D2L_SESSION_COOKIE. */
|
|
34
|
+
export const ENV_COOKIE_REJECTED = "The pasted D2L_SESSION_COOKIE has expired. Copy a fresh d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser and update the environment variable; this server does not fall back to a browser login while D2L_SESSION_COOKIE is set.";
|
|
35
|
+
/**
|
|
36
|
+
* The fix for a rejected credential pasted into the environment, or undefined
|
|
37
|
+
* when neither is set. D2L_ACCESS_TOKEN wins, as it does for signing in.
|
|
38
|
+
*/
|
|
39
|
+
export function envCredentialRejectedMessage(envAccessToken, envSessionCookie) {
|
|
40
|
+
if (envAccessToken)
|
|
41
|
+
return ENV_TOKEN_REJECTED;
|
|
42
|
+
if (envSessionCookie)
|
|
43
|
+
return ENV_COOKIE_REJECTED;
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
31
46
|
/**
|
|
32
47
|
* The school's flag for a setup command, or `--url <origin>` for a school
|
|
33
48
|
* with no preset. An unreadable URL gives no school at all, which `setup`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.23",
|
|
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",
|