brightspace-mcp-server 5.2.3 → 5.2.5

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.
@@ -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,
@@ -45,14 +45,23 @@ export function registerGetMyGroups(server, apiClient) {
45
45
  try {
46
46
  log("DEBUG", "get_my_groups tool called", { args });
47
47
  const { courseId } = GetMyGroupsSchema.parse(args);
48
- const { matches, categoriesUnavailable, unreadCategories } = await fetchMyGroupMemberships(apiClient, courseId);
48
+ const { matches, categoriesUnavailable, categoriesStatus, unreadCategories } = await fetchMyGroupMemberships(apiClient, courseId);
49
+ // An empty `groups` here means "not read", not "none": the field lets a
50
+ // caller tell the two apart without parsing the note (#442).
49
51
  if (categoriesUnavailable) {
50
52
  return toolResponse({
51
53
  courseId,
52
54
  groups: [],
53
- note: "No groups are visible for this course (it may not use groups, or group info isn't accessible).",
55
+ groupsUnavailable: { status: categoriesStatus ?? null },
56
+ note: categoriesStatus === 403
57
+ ? "Brightspace refused this course's group list (403), so whether the current user is in a group " +
58
+ "is unknown. Don't tell them they have no group; they can check Groups in Brightspace."
59
+ : "No groups are visible for this course (it may not use groups, or group info isn't accessible), " +
60
+ "so whether the current user is in a group is unknown.",
54
61
  });
55
62
  }
63
+ const unreadNames = unreadCategories.map((c) => c.Name || String(c.GroupCategoryId));
64
+ const unreadField = unreadNames.length > 0 ? { unreadCategories: unreadNames } : {};
56
65
  const unreadNote = unreadCategories.length === 0 ? undefined
57
66
  : `Couldn't read the groups in ${unreadCategories.map((c) => `"${c.Name || c.GroupCategoryId}"`).join(", ")}, ` +
58
67
  "so any group the current user has there is not listed.";
@@ -63,7 +72,7 @@ export function registerGetMyGroups(server, apiClient) {
63
72
  });
64
73
  }
65
74
  if (matches.length === 0 && unreadNote) {
66
- return toolResponse({ courseId, groups: [], note: unreadNote });
75
+ return toolResponse({ courseId, groups: [], ...unreadField, note: unreadNote });
67
76
  }
68
77
  if (matches.length === 0) {
69
78
  log("INFO", `get_my_groups: User is not a member of any group in course ${courseId}`);
@@ -140,7 +149,7 @@ export function registerGetMyGroups(server, apiClient) {
140
149
  : "Couldn't read this course's classlist, so member names are null; only user ids are listed" +
141
150
  (pageNames.size > 0 ? ", except where memberNames lists a group's members from its member page." : ".");
142
151
  const note = [unreadNote, namesNote].filter(Boolean).join(" ") || undefined;
143
- return toolResponse({ courseId, groups, ...(note ? { note } : {}) });
152
+ return toolResponse({ courseId, groups, ...unreadField, ...(note ? { note } : {}) });
144
153
  }
145
154
  catch (error) {
146
155
  return sanitizeError(error);
@@ -21,7 +21,12 @@ export async function fetchMyGroupMemberships(apiClient, courseId, categoryId) {
21
21
  }
22
22
  catch (error) {
23
23
  if (inaccessible(error)) {
24
- return { matches: [], categoriesUnavailable: true, unreadCategories: [] };
24
+ return {
25
+ matches: [],
26
+ categoriesUnavailable: true,
27
+ categoriesStatus: error.status,
28
+ unreadCategories: [],
29
+ };
25
30
  }
26
31
  throw error;
27
32
  }
@@ -45,5 +50,5 @@ export async function fetchMyGroupMemberships(apiClient, courseId, categoryId) {
45
50
  matches.push({ category, group });
46
51
  }
47
52
  }
48
- return { matches, categoriesUnavailable: false, unreadCategories };
53
+ return { matches, categoriesUnavailable: false, categoriesStatus: undefined, unreadCategories };
49
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.2.3",
3
+ "version": "5.2.5",
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",