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 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. Asking your AI app also cancels a sign-in still waiting for you to approve it; the `auth --logout` command instead refuses and deletes nothing while a sign-in is in progress. 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.
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
 
@@ -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 can cancel a
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 reports the sign-in
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 (!/auth-cli\.js\b/.test(command) || !command.includes("--automatic"))
278
+ if (!isAutomaticSignInCommand(command))
270
279
  return false;
271
- log("WARN", `Cancelling another app's background sign-in (pid ${pid}) for sign_out`);
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
- return true;
289
+ catch (error) {
290
+ if (error.code === "ESRCH")
291
+ return true;
280
292
  }
281
293
  await new Promise((resolve) => setTimeout(resolve, 50));
282
294
  }
@@ -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 deletes the files that let the next tool
7
- * call skip the browser, so that call does a full sign-in. It does not log out
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 is in
100
- * progress (the same code `auth` uses for that), 1 for anything else.
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
- const { removed } = await clearSavedSession(sessionDir);
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. Nothing was deleted.");
123
- print("Wait for it to finish (or cancel it), then run this command again.");
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 { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
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 === 401 || error.status === 403;
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 },
@@ -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
  }
@@ -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`, plus one thing the command does not
31
- * do: it first cancels a background sign-in still waiting on MFA, this
32
- * server's (AuthRunner.cancel, which answers every call waiting on it) or
33
- * another app's on this computer (stopAutomaticSignIn). A user who missed the
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,
@@ -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.21",
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",