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
- for (const topic of discussionResult.value) {
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
- return result.value;
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 course
240
- // instead adds a second content block carrying the notice, which
241
- // names which course ids are unavailable.
242
- if (pendingCourseIds.length === 0) {
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
- throwIfCredentialRejected(firstAuthError);
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, or a ZIP64 archive all return null, because the caller's fallback
20
- * is simply to report the file without its text.
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
- function readEntryData(buffer, entry) {
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
- return inflateRawSync(raw);
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
- const read = (name) => xmlToText(extractZipEntry(buffer, name)?.toString("utf-8") ?? "");
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.26",
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",