brightspace-mcp-server 3.8.4 → 3.8.9
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 +2 -0
- package/build/tools/get-assignments.js +87 -7
- package/build/tools/tool-helpers.js +34 -0
- package/build/utils/update-checker.js +22 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -103,6 +103,8 @@ Claude Desktop uses a separate configuration, which the setup wizard can update
|
|
|
103
103
|
|
|
104
104
|
</details>
|
|
105
105
|
|
|
106
|
+
**Running from a source checkout or fork?** When a newer release is published, the server tells a source checkout to `git pull` and `npm run build` rather than to install the npm package. For a fork you maintain, set `D2L_NO_UPDATE_CHECK=1` in the server's environment to turn off upstream update notices.
|
|
107
|
+
|
|
106
108
|
## Session Expired?
|
|
107
109
|
|
|
108
110
|
There's no login step — asking a question signs you in.
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllItems } from "../api/paginate.js";
|
|
8
8
|
import { GetAssignmentsSchema } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
10
10
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
11
11
|
import { log } from "../utils/logger.js";
|
|
12
12
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
@@ -66,6 +66,8 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
68
|
catch (error) {
|
|
69
|
+
if (isAuthUnavailable(error))
|
|
70
|
+
throw error;
|
|
69
71
|
log("DEBUG", `Failed to fetch content-linked quiz ${quizId}: using content metadata`, error);
|
|
70
72
|
}
|
|
71
73
|
let detail = topic;
|
|
@@ -73,6 +75,8 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
|
73
75
|
detail = await apiClient.get(apiClient.le(courseId, `/content/topics/${topic.TopicId}`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
74
76
|
}
|
|
75
77
|
catch (error) {
|
|
78
|
+
if (isAuthUnavailable(error))
|
|
79
|
+
throw error;
|
|
76
80
|
log("DEBUG", `Failed to fetch content topic ${topic.TopicId}: using table-of-contents metadata`, error);
|
|
77
81
|
}
|
|
78
82
|
return {
|
|
@@ -109,6 +113,12 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
109
113
|
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
110
114
|
}),
|
|
111
115
|
]);
|
|
116
|
+
// Each route may fail on its own without costing the others, but a failed
|
|
117
|
+
// sign-in means none of them answered: report that, not an empty course.
|
|
118
|
+
for (const result of [dropboxResult, quizResult, gradebookResult, contentResult]) {
|
|
119
|
+
if (result.status === "rejected" && isAuthUnavailable(result.reason))
|
|
120
|
+
throw result.reason;
|
|
121
|
+
}
|
|
112
122
|
// Process Dropbox folders
|
|
113
123
|
if (dropboxResult.status === "fulfilled") {
|
|
114
124
|
// D2L dropbox endpoint may return paged { Objects: [...] } or flat array
|
|
@@ -125,6 +135,8 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
125
135
|
submissions = Array.isArray(submissionsRaw) ? submissionsRaw : submissionsRaw.Objects ?? [];
|
|
126
136
|
}
|
|
127
137
|
catch (error) {
|
|
138
|
+
if (isAuthUnavailable(error))
|
|
139
|
+
throw error;
|
|
128
140
|
// 404 means no submissions yet - that's fine
|
|
129
141
|
if (error?.status !== 404) {
|
|
130
142
|
log("DEBUG", `Failed to fetch submissions for folder ${folder.Id}`, error);
|
|
@@ -136,6 +148,8 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
136
148
|
feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
137
149
|
}
|
|
138
150
|
catch (error) {
|
|
151
|
+
if (isAuthUnavailable(error))
|
|
152
|
+
throw error;
|
|
139
153
|
// 404/403 means no feedback available (or no access) - that's fine
|
|
140
154
|
if (error?.status !== 404 && error?.status !== 403) {
|
|
141
155
|
log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
|
|
@@ -229,6 +243,8 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
229
243
|
attempts = Array.isArray(attemptsRaw) ? attemptsRaw : attemptsRaw.Objects ?? [];
|
|
230
244
|
}
|
|
231
245
|
catch (error) {
|
|
246
|
+
if (isAuthUnavailable(error))
|
|
247
|
+
throw error;
|
|
232
248
|
if (error?.status === 404) {
|
|
233
249
|
// 404 means no attempts yet, which is a measurement of zero
|
|
234
250
|
attempts = [];
|
|
@@ -353,9 +369,31 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
353
369
|
const { courseId } = GetAssignmentsSchema.parse(args);
|
|
354
370
|
// Single course case
|
|
355
371
|
if (courseId) {
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
372
|
+
try {
|
|
373
|
+
const assignments = await fetchCourseAssignments(apiClient, courseId, config.baseUrl);
|
|
374
|
+
log("INFO", `get_assignments: Retrieved ${assignments.length} assignments for course ${courseId}`);
|
|
375
|
+
return toolResponse({ courseId, assignments });
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
// A pending sign-in is not an empty course: the routes never
|
|
379
|
+
// answered, so the result says so instead of reporting zero
|
|
380
|
+
// assignments as if that were a real measurement. The envelope
|
|
381
|
+
// stays a success — existing callers that only read `assignments`
|
|
382
|
+
// keep working — with authPending/notice added for callers that
|
|
383
|
+
// want to tell "no assignments" apart from "couldn't check".
|
|
384
|
+
if (isAuthUnavailable(error)) {
|
|
385
|
+
log("DEBUG", `get_assignments: sign-in pending for course ${courseId}`, error);
|
|
386
|
+
return toolResponse({
|
|
387
|
+
courseId,
|
|
388
|
+
assignments: [],
|
|
389
|
+
authPending: true,
|
|
390
|
+
notice: "Sign-in to Brightspace is still in progress, so assignments for this course " +
|
|
391
|
+
`could not be fetched yet. ${authPendingNotice(error)} Call get_assignments again ` +
|
|
392
|
+
"once sign-in finishes.",
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
throw error;
|
|
396
|
+
}
|
|
359
397
|
}
|
|
360
398
|
// All courses case
|
|
361
399
|
// First, fetch enrolled courses. isActive=true is the configured
|
|
@@ -391,15 +429,57 @@ export function registerGetAssignments(server, apiClient, config) {
|
|
|
391
429
|
log("DEBUG", `get_assignments: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
|
|
392
430
|
return null;
|
|
393
431
|
}
|
|
432
|
+
// A pending sign-in only means this course's routes never
|
|
433
|
+
// answered — it says nothing about the other courses, whose
|
|
434
|
+
// requests may already have gone out independently. Mark this one
|
|
435
|
+
// rather than failing the whole call and losing every course that
|
|
436
|
+
// *did* answer.
|
|
437
|
+
if (isAuthUnavailable(error)) {
|
|
438
|
+
log("DEBUG", `get_assignments: sign-in pending for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`);
|
|
439
|
+
return {
|
|
440
|
+
courseId: item.OrgUnit.Id,
|
|
441
|
+
courseName: item.OrgUnit.Name,
|
|
442
|
+
authPending: true,
|
|
443
|
+
authError: error,
|
|
444
|
+
};
|
|
445
|
+
}
|
|
394
446
|
throw error; // Re-throw other errors
|
|
395
447
|
}
|
|
396
448
|
});
|
|
397
449
|
const results = await Promise.allSettled(assignmentPromises);
|
|
398
|
-
const
|
|
450
|
+
const settled = results
|
|
399
451
|
.filter((r) => r.status === "fulfilled" && r.value !== null)
|
|
400
452
|
.map((r) => r.value);
|
|
401
|
-
|
|
402
|
-
|
|
453
|
+
const pending = settled.filter((c) => c.authPending);
|
|
454
|
+
const courses = settled
|
|
455
|
+
.filter((c) => !c.authPending)
|
|
456
|
+
.map(({ courseId, courseName, assignments }) => ({ courseId, courseName, assignments }));
|
|
457
|
+
log("INFO", `get_assignments: Retrieved assignments for ${courses.length} courses ` +
|
|
458
|
+
`(out of ${enrollmentItems.length} enrolled${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
|
|
459
|
+
// Pending courses keep `assignments: []` so callers that read
|
|
460
|
+
// `courses[i].assignments` still get an array; authPending marks it
|
|
461
|
+
// as unchecked rather than empty.
|
|
462
|
+
const response = {
|
|
463
|
+
courses: [
|
|
464
|
+
...courses,
|
|
465
|
+
...pending.map(({ courseId, courseName }) => ({
|
|
466
|
+
courseId,
|
|
467
|
+
courseName,
|
|
468
|
+
assignments: [],
|
|
469
|
+
authPending: true,
|
|
470
|
+
})),
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
if (pending.length > 0) {
|
|
474
|
+
response.authPending = true;
|
|
475
|
+
response.unavailableCourseIds = pending.map((c) => c.courseId);
|
|
476
|
+
response.notice =
|
|
477
|
+
"Sign-in to Brightspace is still in progress, so assignments for " +
|
|
478
|
+
`${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
|
|
479
|
+
`fetched yet. ${authPendingNotice(pending[0].authError)} Call get_assignments again once ` +
|
|
480
|
+
"sign-in finishes.";
|
|
481
|
+
}
|
|
482
|
+
return toolResponse(response);
|
|
403
483
|
}
|
|
404
484
|
catch (error) {
|
|
405
485
|
// Temporary: log full error details to stderr for debugging
|
|
@@ -108,6 +108,40 @@ const DOWNLOAD_FAILURE_GUIDANCE = {
|
|
|
108
108
|
pathTraversal: "The name Brightspace gave this file pointed outside the download " +
|
|
109
109
|
"directory and was refused. Pass customFilename to choose one yourself.",
|
|
110
110
|
};
|
|
111
|
+
/**
|
|
112
|
+
* True when an error means the session itself is unusable, not that one
|
|
113
|
+
* resource refused: a sign-in that failed or is still waiting on MFA, a 401
|
|
114
|
+
* left over after re-authentication, or a token that could not be renewed.
|
|
115
|
+
* Tools that tolerate per-route failures must not tolerate these, or a pending
|
|
116
|
+
* sign-in reads as a successful empty result.
|
|
117
|
+
*/
|
|
118
|
+
export function isAuthUnavailable(error) {
|
|
119
|
+
return error instanceof AuthProcessError ||
|
|
120
|
+
error instanceof TokenRefreshError ||
|
|
121
|
+
(error instanceof ApiError && error.status === 401);
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Guidance for a tool that returned a *successful* envelope anyway, because
|
|
125
|
+
* the sign-in blocking one or more of its routes is still being worked on in
|
|
126
|
+
* the background (see isAuthUnavailable). Reuses the same per-kind wording
|
|
127
|
+
* sanitizeError gives an AuthProcessError — including the dynamic MFA number,
|
|
128
|
+
* when one is known — so a caller sees identical instructions whether the
|
|
129
|
+
* tool reported isError or not; only the framing differs. Only call this once
|
|
130
|
+
* isAuthUnavailable(error) is true; it has no fallback for other errors.
|
|
131
|
+
*/
|
|
132
|
+
export function authPendingNotice(error) {
|
|
133
|
+
if (error instanceof AuthProcessError) {
|
|
134
|
+
return authFailureMessage(error);
|
|
135
|
+
}
|
|
136
|
+
if (error instanceof TokenRefreshError) {
|
|
137
|
+
return "Brightspace could not renew your session right now. Your saved login was kept. " +
|
|
138
|
+
"Try again in a few minutes.";
|
|
139
|
+
}
|
|
140
|
+
// The remaining isAuthUnavailable case: a 401 ApiError left over after
|
|
141
|
+
// automatic re-authentication was attempted and failed.
|
|
142
|
+
return "Authentication expired. Auto-reauthentication was attempted but failed. " +
|
|
143
|
+
`Please run \`${AUTH_COMMAND}\` in your terminal, then try again.`;
|
|
144
|
+
}
|
|
111
145
|
/**
|
|
112
146
|
* Sanitize errors for user-friendly messages
|
|
113
147
|
*
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* caller happens to read it first. A one-shot notice is invisible in practice:
|
|
19
19
|
* it gets swallowed by a single background tool call and never seen again.
|
|
20
20
|
*
|
|
21
|
+
* A source checkout gets different advice: installing the npm package would
|
|
22
|
+
* update some other copy, and for a fork it would mean leaving its changes
|
|
23
|
+
* behind. Such a checkout is told to pull and rebuild, or to opt out.
|
|
24
|
+
*
|
|
21
25
|
* Set D2L_NO_UPDATE_CHECK to any value to switch the check off entirely.
|
|
22
26
|
*/
|
|
23
27
|
import { readFileSync } from "node:fs";
|
|
@@ -54,8 +58,17 @@ export function ownNpxCacheDir(root = projectRoot) {
|
|
|
54
58
|
const match = new RegExp(`^(.*/_npx/[^/]+)/node_modules/${PACKAGE_NAME}`).exec(normalized);
|
|
55
59
|
return match ? resolve(match[1]) : null;
|
|
56
60
|
}
|
|
57
|
-
|
|
58
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Classify the package directory this process runs from. npm only ever puts
|
|
63
|
+
* the package under a `node_modules` directory, so anything else is a checkout
|
|
64
|
+
* someone built by hand, possibly a fork.
|
|
65
|
+
*/
|
|
66
|
+
export function installKindOf(root = projectRoot) {
|
|
67
|
+
if (ownNpxCacheDir(root) !== null)
|
|
68
|
+
return "npx-cache";
|
|
69
|
+
return root.split(sep).join("/").split("/").includes("node_modules")
|
|
70
|
+
? "npm-install"
|
|
71
|
+
: "source-checkout";
|
|
59
72
|
}
|
|
60
73
|
/**
|
|
61
74
|
* Remove npx cache entries holding a copy of this package, so the next
|
|
@@ -155,7 +168,7 @@ export async function fetchLatestVersion(fetchImpl = fetch) {
|
|
|
155
168
|
* swallowed, because a version check must not affect the server.
|
|
156
169
|
*/
|
|
157
170
|
export async function initUpdateChecker(deps = {}) {
|
|
158
|
-
const { fetchImpl = fetch, env = process.env, installedVersion = getInstalledVersion(),
|
|
171
|
+
const { fetchImpl = fetch, env = process.env, installedVersion = getInstalledVersion(), installKind = installKindOf(), clearCaches = () => clearAllNpxCaches(), } = deps;
|
|
159
172
|
if (env.D2L_NO_UPDATE_CHECK)
|
|
160
173
|
return;
|
|
161
174
|
try {
|
|
@@ -168,7 +181,7 @@ export async function initUpdateChecker(deps = {}) {
|
|
|
168
181
|
const to = safeVersionLabel(latest);
|
|
169
182
|
const headline = `Update available: v${from} to v${to}.`;
|
|
170
183
|
let text;
|
|
171
|
-
if (
|
|
184
|
+
if (installKind === "npx-cache") {
|
|
172
185
|
const count = await clearCaches();
|
|
173
186
|
const cleanup = count > 0
|
|
174
187
|
? `Cleared ${count} stale npx cache director${count === 1 ? "y" : "ies"} for ${PACKAGE_NAME} ` +
|
|
@@ -176,6 +189,11 @@ export async function initUpdateChecker(deps = {}) {
|
|
|
176
189
|
: "";
|
|
177
190
|
text = `${headline} ${cleanup}Restart your MCP client to pick up v${to}.`;
|
|
178
191
|
}
|
|
192
|
+
else if (installKind === "source-checkout") {
|
|
193
|
+
text = `${headline} This server runs from a source checkout, so update that checkout ` +
|
|
194
|
+
`(git pull, then npm run build) and restart your MCP client. ` +
|
|
195
|
+
`If it is a fork you maintain, set D2L_NO_UPDATE_CHECK=1 to turn off upstream update notices.`;
|
|
196
|
+
}
|
|
179
197
|
else {
|
|
180
198
|
text = `${headline} Run: ${GLOBAL_INSTALL_COMMAND}` +
|
|
181
199
|
`, then ${CLEAR_NPX_CACHE_COMMAND} and restart your MCP client.`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.9",
|
|
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",
|