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.
- 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/utils/commands.js +15 -0
- package/package.json +1 -1
|
@@ -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/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.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",
|