brightspace-mcp-server 5.1.21 → 5.1.22

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.
@@ -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
  }
@@ -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.22",
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",