brightspace-mcp-server 5.2.4 → 5.2.6

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.
@@ -414,6 +414,19 @@ function feedbackPageListsSubmission(html) {
414
414
  }
415
415
  return false;
416
416
  }
417
+ function assignmentListCoverage(reason) {
418
+ const status = typeof reason?.status === "number" ? reason.status : null;
419
+ if (status === 404)
420
+ return {};
421
+ return {
422
+ assignmentListUnavailable: { status },
423
+ assignmentListNote: (status === 403
424
+ ? "Brightspace refused this course's assignment (dropbox) list (403), "
425
+ : `Brightspace did not return this course's assignment (dropbox) list${status !== null ? ` (it answered ${status})` : ""}, `) +
426
+ "so its assignments are missing from this answer. Do not tell the user this course has no " +
427
+ "assignments due; call get_assignments again for it, or tell them to check Assignments in Brightspace.",
428
+ };
429
+ }
417
430
  /** A course's assignments and quizzes, with whether its quiz list is complete. */
418
431
  async function fetchCourseInventory(apiClient, courseId, baseUrl) {
419
432
  const assignments = [];
@@ -633,8 +646,9 @@ async function fetchCourseInventory(apiClient, courseId, baseUrl) {
633
646
  }
634
647
  }
635
648
  else {
636
- // Log dropbox fetch failure but don't throw
637
- log("DEBUG", `Failed to fetch dropbox folders for course ${courseId}`, dropboxResult.reason);
649
+ // Don't throw: the quizzes and gradebook may still have answered. The
650
+ // failure is reported through assignmentListCoverage below.
651
+ log("INFO", `Failed to fetch dropbox folders for course ${courseId}`, dropboxResult.reason);
638
652
  }
639
653
  // Process quizzes. Content discovery remains useful when the list route is
640
654
  // forbidden or unavailable, so a failed list starts from an empty set.
@@ -809,6 +823,7 @@ async function fetchCourseInventory(apiClient, courseId, baseUrl) {
809
823
  gradebook: gradebookResult.status === "fulfilled",
810
824
  contentRecovery,
811
825
  }),
826
+ ...(dropboxResult.status === "rejected" ? assignmentListCoverage(dropboxResult.reason) : {}),
812
827
  };
813
828
  }
814
829
  /**
@@ -877,7 +892,9 @@ export function registerGetAssignments(server, apiClient, config) {
877
892
  "availability (notYetOpen, open, closed) is worked out from the quiz's own start and end dates and is left out when it has none. exempt: true means Brightspace marks the user exempt from that quiz's topic; the quiz is still listed. " +
878
893
  "Each course also carries quizCoverage. \"complete\" means every quiz source answered, not that every quiz is guaranteed to be listed; \"incomplete\" means a quiz source could not be read, so the course may have quizzes not listed. " +
879
894
  "Never conclude that a quiz was missed or not taken because it is absent, and never present the listed quizzes as all of them. " +
880
- "A gradeOnly row named like a quiz is a quiz whose details could not be read.",
895
+ "A gradeOnly row named like a quiz is a quiz whose details could not be read. " +
896
+ "A course with assignmentListUnavailable had its assignment list unread: its assignments are missing, not absent, so never say it has none due. " +
897
+ "Without courseId, courses listed in unreadCourses could not be read at all and are not in courses; never report them as having nothing due.",
881
898
  inputSchema: GetAssignmentsSchema,
882
899
  }, async (args) => {
883
900
  try {
@@ -944,11 +961,6 @@ export function registerGetAssignments(server, apiClient, config) {
944
961
  };
945
962
  }
946
963
  catch (error) {
947
- // 403 means no access (past course, etc) - log and skip
948
- if (error?.status === 403) {
949
- log("DEBUG", `get_assignments: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
950
- return null;
951
- }
952
964
  // A pending sign-in only means this course's routes never
953
965
  // answered — it says nothing about the other courses, whose
954
966
  // requests may already have gone out independently. Mark this one
@@ -963,13 +975,26 @@ export function registerGetAssignments(server, apiClient, config) {
963
975
  authError: error,
964
976
  };
965
977
  }
966
- throw error; // Re-throw other errors
978
+ throw error;
967
979
  }
968
980
  });
969
981
  const results = await Promise.allSettled(assignmentPromises);
970
982
  const settled = results
971
- .filter((r) => r.status === "fulfilled" && r.value !== null)
983
+ .filter((r) => r.status === "fulfilled")
972
984
  .map((r) => r.value);
985
+ // A course whose inventory failed (a 403 on a past course, a 5xx, a
986
+ // malformed payload) is not a course with nothing due. It stays out
987
+ // of `courses`, as before, and is named here instead.
988
+ // **Why:** these courses used to vanish with no trace, so "what's
989
+ // due?" silently skipped a class (#447).
990
+ const unreadCourses = results.flatMap((result, index) => {
991
+ if (result.status !== "rejected")
992
+ return [];
993
+ const item = filteredEnrollments[index];
994
+ const status = typeof result.reason?.status === "number" ? result.reason.status : null;
995
+ log("INFO", `get_assignments: could not read course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`, result.reason);
996
+ return [{ courseId: item.OrgUnit.Id, courseName: item.OrgUnit.Name, status }];
997
+ });
973
998
  const pending = settled.filter((c) => c.authPending);
974
999
  const courses = settled
975
1000
  .filter((c) => !c.authPending)
@@ -990,16 +1015,25 @@ export function registerGetAssignments(server, apiClient, config) {
990
1015
  })),
991
1016
  ],
992
1017
  };
1018
+ const notices = [];
993
1019
  if (pending.length > 0) {
994
1020
  throwIfCredentialRejected(pending[0].authError);
995
1021
  response.authPending = true;
996
1022
  response.unavailableCourseIds = pending.map((c) => c.courseId);
997
- response.notice =
998
- "Sign-in to Brightspace is still in progress, so assignments for " +
999
- `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
1000
- `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_assignments again once ` +
1001
- "sign-in finishes.";
1023
+ notices.push("Sign-in to Brightspace is still in progress, so assignments for " +
1024
+ `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
1025
+ `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_assignments again once ` +
1026
+ "sign-in finishes.");
1027
+ }
1028
+ if (unreadCourses.length > 0) {
1029
+ response.unreadCourses = unreadCourses;
1030
+ notices.push(`Assignments for ${unreadCourses.length} course(s) could not be read from Brightspace ` +
1031
+ `(${unreadCourses.map((c) => `"${c.courseName}" ${c.courseId}`).join(", ")}), so they are ` +
1032
+ "missing from this list. Do not tell the user those courses have nothing due: call " +
1033
+ "get_assignments again with that courseId, or tell them to check Brightspace.");
1002
1034
  }
1035
+ if (notices.length > 0)
1036
+ response.notice = notices.join(" ");
1003
1037
  return toolResponse(response);
1004
1038
  }
1005
1039
  catch (error) {
@@ -14,6 +14,8 @@ import { log } from "../utils/logger.js";
14
14
  const SELF_ENROLLMENT_STYLES = new Set([3, 4, 5]);
15
15
  /** Without a courseId, courses that started longer ago than this are skipped unless includeOlder is set. */
16
16
  const RECENT_COURSE_MS = 365 * 24 * 60 * 60 * 1000;
17
+ /** Why a category's groups couldn't be read, in words an agent can relay. */
18
+ const unreadReason = (status) => status === 403 ? "Brightspace refused access (403)" : "Brightspace found no group list (404)";
17
19
  const inaccessible = (error) => error instanceof ApiError && (error.status === 403 || error.status === 404);
18
20
  const text = (value) => value?.Text?.trim() || undefined;
19
21
  /**
@@ -27,7 +29,7 @@ function signUpOpen(category, now) {
27
29
  const end = category.SelfEnrollmentExpiryDate ? Date.parse(category.SelfEnrollmentExpiryDate) : Infinity;
28
30
  return start <= now && now <= end;
29
31
  }
30
- async function fetchCourseGroups(apiClient, course, myId, now) {
32
+ async function fetchCourseGroups(apiClient, course, myId, now, unreadOut) {
31
33
  const result = { courseId: course.id, courseName: course.name, categories: [], alreadyInGroup: [] };
32
34
  let categories;
33
35
  try {
@@ -47,6 +49,7 @@ async function fetchCourseGroups(apiClient, course, myId, now) {
47
49
  catch (error) {
48
50
  if (inaccessible(error)) {
49
51
  unread.push(category.Name);
52
+ unreadOut.push({ courseId: course.id, name: category.Name, status: error.status });
50
53
  continue;
51
54
  }
52
55
  throw error;
@@ -115,7 +118,8 @@ export function registerGetAvailableGroups(server, apiClient, config) {
115
118
  return Number.isFinite(start) && start >= now - RECENT_COURSE_MS;
116
119
  });
117
120
  const olderSkipped = enrolled.length - courses.length;
118
- const results = await Promise.allSettled(courses.map((course) => fetchCourseGroups(apiClient, course, myId, now)));
121
+ const unreadCategories = [];
122
+ const results = await Promise.allSettled(courses.map((course) => fetchCourseGroups(apiClient, course, myId, now, unreadCategories)));
119
123
  const pendingCourseIds = [];
120
124
  const failedCourseIds = [];
121
125
  let firstAuthError = null;
@@ -149,6 +153,16 @@ export function registerGetAvailableGroups(server, apiClient, config) {
149
153
  if (failedCourseIds.length > 0) {
150
154
  notes.push(`Couldn't read groups for course(s) ${failedCourseIds.join(", ")}, so open sign-ups there aren't listed.`);
151
155
  }
156
+ // Without a reason attached, a bare unreadCategories list reads like a
157
+ // category with nothing open, so name each one and say why it's there (#433).
158
+ const shownCourseIds = new Set(withChoices.map((c) => c.courseId));
159
+ const unread = unreadCategories.filter((u) => shownCourseIds.has(u.courseId));
160
+ if (unread.length > 0) {
161
+ const where = unread.map((u) => `"${u.name}" in course ${u.courseId}: ${unreadReason(u.status)}`).join("; ");
162
+ notes.push(`Couldn't read the groups in these open sign-ups, so any open groups there aren't listed ` +
163
+ `(see unreadCategories): ${where}. Don't report them as full or closed; ` +
164
+ "check Groups > Available Groups in Brightspace.");
165
+ }
152
166
  const data = {
153
167
  coursesChecked: courses.length,
154
168
  courses: withChoices,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.2.4",
3
+ "version": "5.2.6",
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",