brightspace-mcp-server 5.1.26 → 5.1.28
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.
|
@@ -13,6 +13,15 @@ import { assignmentLinkResolver } from "./assignment-links.js";
|
|
|
13
13
|
import { dueIn } from "../utils/due-in.js";
|
|
14
14
|
import { resolveCourses } from "./resolve-courses.js";
|
|
15
15
|
import { fetchCourseCalendarEvents, eventEndsAt } from "./calendar-events.js";
|
|
16
|
+
/**
|
|
17
|
+
* Whether a failed source must be reported rather than skipped. A 403 means
|
|
18
|
+
* the tool is restricted or switched off in that course (a past course, a
|
|
19
|
+
* course with no quizzes): nothing was missed. Anything else (a 404, a 5xx, a
|
|
20
|
+
* dropped connection) leaves a hole the caller would read as "nothing due".
|
|
21
|
+
*/
|
|
22
|
+
function isReportableFailure(error) {
|
|
23
|
+
return error?.status !== 403;
|
|
24
|
+
}
|
|
16
25
|
/** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
|
|
17
26
|
function unwrapList(raw) {
|
|
18
27
|
return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
|
|
@@ -24,11 +33,13 @@ function unwrapList(raw) {
|
|
|
24
33
|
* hides everything inside it, however visible its topics claim to be, so it is
|
|
25
34
|
* skipped without asking for its topics at all. A forum whose topics fail to
|
|
26
35
|
* load (e.g. no access) is skipped rather than failing the whole course,
|
|
27
|
-
* matching `getForumsOverview` in get-discussions.ts
|
|
36
|
+
* matching `getForumsOverview` in get-discussions.ts; `incomplete` marks a
|
|
37
|
+
* skip that was not a 403, so the caller can say topics may be missing.
|
|
28
38
|
*/
|
|
29
39
|
async function fetchDiscussionDueTopics(apiClient, courseId) {
|
|
30
40
|
const forums = await apiClient.get(apiClient.le(courseId, "/discussions/forums/"), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
31
41
|
const topics = [];
|
|
42
|
+
let incomplete = false;
|
|
32
43
|
for (const forum of unwrapList(forums)) {
|
|
33
44
|
if (forum.IsHidden === true)
|
|
34
45
|
continue;
|
|
@@ -39,10 +50,12 @@ async function fetchDiscussionDueTopics(apiClient, courseId) {
|
|
|
39
50
|
catch (error) {
|
|
40
51
|
if (isAuthUnavailable(error))
|
|
41
52
|
throw error;
|
|
53
|
+
if (isReportableFailure(error))
|
|
54
|
+
incomplete = true;
|
|
42
55
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch topics for forum ${forum.ForumId} in course ${courseId}`, error);
|
|
43
56
|
}
|
|
44
57
|
}
|
|
45
|
-
return topics;
|
|
58
|
+
return { topics, incomplete };
|
|
46
59
|
}
|
|
47
60
|
/**
|
|
48
61
|
* Calendar events as upcoming items, minus the ones Brightspace generated from
|
|
@@ -94,6 +107,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
94
107
|
throw result.reason;
|
|
95
108
|
}
|
|
96
109
|
const items = [];
|
|
110
|
+
const unavailable = [];
|
|
97
111
|
const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, course.id);
|
|
98
112
|
if (dropboxResult.status === "fulfilled") {
|
|
99
113
|
for (const folder of unwrapList(dropboxResult.value)) {
|
|
@@ -125,6 +139,8 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
125
139
|
}
|
|
126
140
|
else {
|
|
127
141
|
// 403 for past courses and similar: log and keep the other courses.
|
|
142
|
+
if (isReportableFailure(dropboxResult.reason))
|
|
143
|
+
unavailable.push("assignments");
|
|
128
144
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch dropbox folders for course ${course.id}`, dropboxResult.reason);
|
|
129
145
|
}
|
|
130
146
|
if (quizResult.status === "fulfilled") {
|
|
@@ -150,10 +166,14 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
150
166
|
}
|
|
151
167
|
}
|
|
152
168
|
else {
|
|
169
|
+
if (isReportableFailure(quizResult.reason))
|
|
170
|
+
unavailable.push("quizzes");
|
|
153
171
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch quizzes for course ${course.id}`, quizResult.reason);
|
|
154
172
|
}
|
|
155
173
|
if (discussionResult.status === "fulfilled") {
|
|
156
|
-
|
|
174
|
+
if (discussionResult.value.incomplete)
|
|
175
|
+
unavailable.push("discussions");
|
|
176
|
+
for (const topic of discussionResult.value.topics) {
|
|
157
177
|
if (topic.IsHidden === true)
|
|
158
178
|
continue;
|
|
159
179
|
if (!topic.DueDate)
|
|
@@ -173,15 +193,19 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
173
193
|
}
|
|
174
194
|
}
|
|
175
195
|
else {
|
|
196
|
+
if (isReportableFailure(discussionResult.reason))
|
|
197
|
+
unavailable.push("discussions");
|
|
176
198
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch discussions for course ${course.id}`, discussionResult.reason);
|
|
177
199
|
}
|
|
178
200
|
if (calendarResult.status === "fulfilled") {
|
|
179
201
|
items.push(...calendarItems(calendarResult.value, items));
|
|
180
202
|
}
|
|
181
203
|
else {
|
|
204
|
+
if (isReportableFailure(calendarResult.reason))
|
|
205
|
+
unavailable.push("calendar");
|
|
182
206
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch calendar events for course ${course.id}`, calendarResult.reason);
|
|
183
207
|
}
|
|
184
|
-
return items;
|
|
208
|
+
return { items, unavailable };
|
|
185
209
|
}
|
|
186
210
|
/**
|
|
187
211
|
* Register get_upcoming_due_dates tool
|
|
@@ -209,16 +233,28 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
|
209
233
|
// have gone out independently. Collect it rather than failing the
|
|
210
234
|
// whole call and losing every course that *did* answer.
|
|
211
235
|
const pendingCourseIds = [];
|
|
236
|
+
// Anything else that failed still must not make items vanish: an
|
|
237
|
+
// assignment missing from a list that looks complete reads as
|
|
238
|
+
// "nothing due" (#383).
|
|
239
|
+
const failedCourses = [];
|
|
212
240
|
let firstAuthError = null;
|
|
213
241
|
const items = results.flatMap((result, i) => {
|
|
214
|
-
if (result.status === "fulfilled")
|
|
215
|
-
|
|
242
|
+
if (result.status === "fulfilled") {
|
|
243
|
+
if (result.value.unavailable.length > 0) {
|
|
244
|
+
failedCourses.push({ courseId: courses[i].id, sources: result.value.unavailable });
|
|
245
|
+
}
|
|
246
|
+
return result.value.items;
|
|
247
|
+
}
|
|
216
248
|
if (isAuthUnavailable(result.reason)) {
|
|
217
249
|
pendingCourseIds.push(courses[i].id);
|
|
218
250
|
firstAuthError ??= result.reason;
|
|
219
251
|
log("DEBUG", `get_upcoming_due_dates: sign-in pending for course ${courses[i].id}`, result.reason);
|
|
220
252
|
return [];
|
|
221
253
|
}
|
|
254
|
+
failedCourses.push({
|
|
255
|
+
courseId: courses[i].id,
|
|
256
|
+
sources: ["assignments", "quizzes", "discussions", "calendar"],
|
|
257
|
+
});
|
|
222
258
|
log("DEBUG", "get_upcoming_due_dates: skipping course after fetch failure", result.reason);
|
|
223
259
|
return [];
|
|
224
260
|
});
|
|
@@ -234,18 +270,30 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
|
234
270
|
})
|
|
235
271
|
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
|
|
236
272
|
log("INFO", `get_upcoming_due_dates: Retrieved ${upcoming.length} items across ${courses.length} courses` +
|
|
237
|
-
(pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : "")
|
|
273
|
+
(pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : "") +
|
|
274
|
+
(failedCourses.length > 0 ? ` (${failedCourses.length} incomplete)` : ""));
|
|
238
275
|
// The response is always a bare array, pending sign-in or not — a
|
|
239
|
-
// shape change would break every existing caller. A pending
|
|
240
|
-
// instead adds a second content block carrying the
|
|
241
|
-
// names which course ids are unavailable.
|
|
242
|
-
|
|
276
|
+
// shape change would break every existing caller. A pending or
|
|
277
|
+
// incomplete course instead adds a second content block carrying the
|
|
278
|
+
// notice, which names which course ids are unavailable.
|
|
279
|
+
const notices = [];
|
|
280
|
+
if (pendingCourseIds.length > 0) {
|
|
281
|
+
throwIfCredentialRejected(firstAuthError);
|
|
282
|
+
notices.push("Sign-in to Brightspace is still in progress, so upcoming due dates for " +
|
|
283
|
+
`${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
|
|
284
|
+
`yet. ${authPendingNotice(firstAuthError)} Call get_upcoming_due_dates again once sign-in finishes.`);
|
|
285
|
+
}
|
|
286
|
+
if (failedCourses.length > 0) {
|
|
287
|
+
const listed = failedCourses
|
|
288
|
+
.map(({ courseId, sources }) => `${courseId} (${sources.join(", ")})`)
|
|
289
|
+
.join("; ");
|
|
290
|
+
notices.push(`Some upcoming items could not be read from Brightspace, so they may be missing from this list: ${listed}. ` +
|
|
291
|
+
"Do not report these as having nothing due: call get_upcoming_due_dates again with that courseId.");
|
|
292
|
+
}
|
|
293
|
+
if (notices.length === 0) {
|
|
243
294
|
return toolResponse(upcoming);
|
|
244
295
|
}
|
|
245
|
-
|
|
246
|
-
return toolResponseWithNotice(upcoming, "Sign-in to Brightspace is still in progress, so upcoming due dates for " +
|
|
247
|
-
`${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
|
|
248
|
-
`yet. ${authPendingNotice(firstAuthError)} Call get_upcoming_due_dates again once sign-in finishes.`);
|
|
296
|
+
return toolResponseWithNotice(upcoming, notices.join(" "));
|
|
249
297
|
}
|
|
250
298
|
catch (error) {
|
|
251
299
|
return sanitizeError(error);
|
|
@@ -16,8 +16,9 @@ import { inflateRawSync } from "node:zlib";
|
|
|
16
16
|
* parsing a header that does not exist.
|
|
17
17
|
*
|
|
18
18
|
* Nothing here throws. A truncated download, an unsupported compression
|
|
19
|
-
* method,
|
|
20
|
-
* is simply to report the file
|
|
19
|
+
* method, a ZIP64 archive, or an entry that inflates past the size budget all
|
|
20
|
+
* return null, because the caller's fallback is simply to report the file
|
|
21
|
+
* without its text.
|
|
21
22
|
*/
|
|
22
23
|
const EOCD_SIGNATURE = 0x06054b50;
|
|
23
24
|
const CENTRAL_FILE_SIGNATURE = 0x02014b50;
|
|
@@ -26,6 +27,13 @@ const EOCD_MAX_COMMENT = 0xffff;
|
|
|
26
27
|
const ZIP64_MARKER = 0xffffffff;
|
|
27
28
|
const METHOD_STORED = 0;
|
|
28
29
|
const METHOD_DEFLATE = 8;
|
|
30
|
+
/**
|
|
31
|
+
* Most bytes one document may inflate to, across every entry read from it.
|
|
32
|
+
* DEFLATE reaches ~1000:1, so a 50 MB download could otherwise inflate to
|
|
33
|
+
* tens of gigabytes and take the server down (issue #380). Real Office XML
|
|
34
|
+
* sits far below this.
|
|
35
|
+
*/
|
|
36
|
+
export const MAX_INFLATED_BYTES = 64 * 1024 * 1024;
|
|
29
37
|
/** Find the End of Central Directory record by scanning backwards. */
|
|
30
38
|
function findEocd(buffer) {
|
|
31
39
|
const earliest = Math.max(0, buffer.length - EOCD_MIN_SIZE - EOCD_MAX_COMMENT);
|
|
@@ -70,7 +78,13 @@ function readCentralDirectory(buffer) {
|
|
|
70
78
|
}
|
|
71
79
|
return entries;
|
|
72
80
|
}
|
|
73
|
-
|
|
81
|
+
/**
|
|
82
|
+
* An entry's bytes, or null when it would exceed `limit`. The declared size
|
|
83
|
+
* is checked first, but a header can lie, so inflation is capped as well.
|
|
84
|
+
*/
|
|
85
|
+
function readEntryData(buffer, entry, limit) {
|
|
86
|
+
if (entry.uncompressedSize > limit)
|
|
87
|
+
return null;
|
|
74
88
|
const header = entry.localHeaderOffset;
|
|
75
89
|
if (header + 30 > buffer.length)
|
|
76
90
|
return null;
|
|
@@ -84,18 +98,19 @@ function readEntryData(buffer, entry) {
|
|
|
84
98
|
return null;
|
|
85
99
|
const raw = buffer.subarray(start, end);
|
|
86
100
|
if (entry.method === METHOD_STORED)
|
|
87
|
-
return Buffer.from(raw);
|
|
101
|
+
return raw.length > limit ? null : Buffer.from(raw);
|
|
88
102
|
if (entry.method !== METHOD_DEFLATE)
|
|
89
103
|
return null;
|
|
90
104
|
try {
|
|
91
|
-
|
|
105
|
+
// Throws a RangeError past maxOutputLength.
|
|
106
|
+
return inflateRawSync(raw, { maxOutputLength: Math.max(1, limit) });
|
|
92
107
|
}
|
|
93
108
|
catch {
|
|
94
109
|
return null;
|
|
95
110
|
}
|
|
96
111
|
}
|
|
97
112
|
/** One entry from a ZIP archive by exact name, or null. Never throws. */
|
|
98
|
-
export function extractZipEntry(buffer, entryName) {
|
|
113
|
+
export function extractZipEntry(buffer, entryName, limit = MAX_INFLATED_BYTES) {
|
|
99
114
|
try {
|
|
100
115
|
const entries = readCentralDirectory(buffer);
|
|
101
116
|
if (!entries)
|
|
@@ -103,7 +118,7 @@ export function extractZipEntry(buffer, entryName) {
|
|
|
103
118
|
const entry = entries.find((e) => e.name === entryName);
|
|
104
119
|
if (!entry)
|
|
105
120
|
return null;
|
|
106
|
-
return readEntryData(buffer, entry);
|
|
121
|
+
return readEntryData(buffer, entry, limit);
|
|
107
122
|
}
|
|
108
123
|
catch {
|
|
109
124
|
return null;
|
|
@@ -145,7 +160,22 @@ export function officeDocumentText(buffer) {
|
|
|
145
160
|
const names = listZipEntries(buffer);
|
|
146
161
|
if (!names)
|
|
147
162
|
return null;
|
|
148
|
-
|
|
163
|
+
// One budget for the whole document, so a PPTX can't spread a zip bomb
|
|
164
|
+
// across many slides that each fit on their own. An entry that fails to
|
|
165
|
+
// read (oversize included) ends the walk: retrying the rest would re-inflate
|
|
166
|
+
// up to the budget once per entry.
|
|
167
|
+
let budget = MAX_INFLATED_BYTES;
|
|
168
|
+
const read = (name) => {
|
|
169
|
+
if (budget <= 0)
|
|
170
|
+
return "";
|
|
171
|
+
const data = extractZipEntry(buffer, name, budget);
|
|
172
|
+
if (!data) {
|
|
173
|
+
budget = 0;
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
budget -= data.length;
|
|
177
|
+
return xmlToText(data.toString("utf-8"));
|
|
178
|
+
};
|
|
149
179
|
let text = "";
|
|
150
180
|
if (names.includes("word/document.xml")) {
|
|
151
181
|
text = read("word/document.xml");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.28",
|
|
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",
|