brightspace-mcp-server 5.1.32 → 5.1.35

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.
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetCourseContentSchema } from "./schemas.js";
8
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
8
+ import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
9
9
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
10
  import { matchesModifiedSince } from "../utils/modified-since.js";
11
11
  import { log } from "../utils/logger.js";
@@ -44,18 +44,37 @@ export async function fetchRootContent(apiClient, courseId) {
44
44
  return apiClient.get(apiClient.le(courseId, '/content/root/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
45
45
  }
46
46
  /**
47
- * Fetch a single module's structure, or null when the dedicated endpoint
48
- * fails (locked module, transient error) so the caller can fall back to
49
- * whatever structure the parent listing already embedded.
47
+ * Fetch a single module's structure, or say why it couldn't be read (locked
48
+ * module, transient error, an answer that is not a list) so the caller can
49
+ * fall back to whatever structure the parent listing already embedded. A
50
+ * pending or expired sign-in is rethrown: it fails every module the same way,
51
+ * and must fail the call so the user is told to sign in, not shown a course
52
+ * whose every module looks empty.
50
53
  */
51
54
  export async function fetchModuleStructure(apiClient, courseId, moduleId) {
55
+ let structure;
52
56
  try {
53
- return await apiClient.get(apiClient.le(courseId, `/content/modules/${moduleId}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
57
+ structure = await apiClient.get(apiClient.le(courseId, `/content/modules/${moduleId}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
54
58
  }
55
- catch (e) {
56
- log('DEBUG', `Failed to fetch children for module ${moduleId}: falling back to the embedded structure`);
57
- return null;
59
+ catch (error) {
60
+ if (isAuthUnavailable(error))
61
+ throw error;
62
+ log('DEBUG', `Failed to fetch children for module ${moduleId}: falling back to the embedded structure`, error);
63
+ const status = typeof error?.status === 'number' ? error.status : null;
64
+ return {
65
+ status,
66
+ reason: status === 403
67
+ ? "Brightspace refused this account's request for this module's contents. It may hold items; open the module in a browser to see what this account can read."
68
+ : `Couldn't read this module's contents${status !== null ? ` (Brightspace answered ${status})` : ''}. It may hold items; try again.`,
69
+ };
58
70
  }
71
+ if (Array.isArray(structure))
72
+ return structure;
73
+ log('DEBUG', `Module ${moduleId} structure was not a list: falling back to the embedded structure`);
74
+ return {
75
+ status: null,
76
+ reason: "Brightspace answered with something other than a list of this module's contents. It may hold items; try again.",
77
+ };
59
78
  }
60
79
  /**
61
80
  * How deep the tree builder will descend when the caller names no maxDepth.
@@ -92,6 +111,7 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
92
111
  if (item.Type === 0) {
93
112
  // Module: fetch children recursively (unless the depth limit is reached)
94
113
  let processedChildren = [];
114
+ let childrenUnavailable;
95
115
  if (currentDepth < depthLimit) {
96
116
  // The parent listing already embeds this module's immediate children
97
117
  // in Structure. The dedicated endpoint is still asked first because it
@@ -100,17 +120,30 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
100
120
  // what the parent already handed us. Falling through to an empty list
101
121
  // made a locked or erroring module look like an empty one, and under a
102
122
  // typeFilter it dropped the module from the tree with no trace.
103
- let children = await fetchModuleStructure(apiClient, courseId, item.Id);
104
- if (!Array.isArray(children)) {
105
- children = Array.isArray(item.Structure) ? item.Structure : [];
123
+ // When neither copy is readable, the module's children are reported
124
+ // as null with a reason, never as [], so it can't pass for an empty
125
+ // module, and the module is kept under any typeFilter because nothing
126
+ // says it holds no matching items.
127
+ const structure = await fetchModuleStructure(apiClient, courseId, item.Id);
128
+ let children = null;
129
+ if (Array.isArray(structure)) {
130
+ children = structure;
131
+ }
132
+ else if (Array.isArray(item.Structure)) {
133
+ children = item.Structure;
134
+ }
135
+ else {
136
+ childrenUnavailable = structure;
137
+ }
138
+ if (children) {
139
+ processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1, availability, now);
106
140
  }
107
- processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1, availability, now);
108
141
  }
109
142
  else if (currentDepth >= MAX_CONTENT_DEPTH) {
110
143
  log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
111
144
  }
112
145
  // Only include module if it has matching children (or filter is 'all')
113
- if (typeFilter === 'all' || processedChildren.length > 0) {
146
+ if (typeFilter === 'all' || processedChildren.length > 0 || childrenUnavailable) {
114
147
  const courseFiles = item.Description?.Html ? extractCourseFilePaths(item.Description.Html, courseId, apiClient.origin) : [];
115
148
  tree.push({
116
149
  type: 'module',
@@ -125,7 +158,8 @@ export async function buildContentTree(apiClient, courseId, modules, progressMap
125
158
  ...(item.IsLocked ? { isLocked: item.IsLocked } : {}),
126
159
  ...availabilityFields(availability),
127
160
  lastModified: item.LastModifiedDate ?? null,
128
- children: processedChildren,
161
+ ...(childrenUnavailable ? { childrenUnavailable } : {}),
162
+ children: childrenUnavailable ? null : processedChildren,
129
163
  });
130
164
  }
131
165
  }
@@ -186,6 +220,12 @@ function filterTreeByModifiedSince(tree, cutoff) {
186
220
  }
187
221
  }
188
222
  else if (item.type === 'module') {
223
+ // A module whose children couldn't be read stays in, children null:
224
+ // nothing says none of them changed since the cutoff.
225
+ if (item.childrenUnavailable) {
226
+ result.push(item);
227
+ continue;
228
+ }
189
229
  const filteredChildren = filterTreeByModifiedSince(item.children ?? [], cutoff);
190
230
  const moduleMatches = matchesModifiedSince(item.lastModified, cutoff);
191
231
  if (moduleMatches || filteredChildren.length > 0) {
@@ -225,6 +265,21 @@ function countModules(tree) {
225
265
  }
226
266
  return count;
227
267
  }
268
+ /**
269
+ * Count modules whose children couldn't be read (for the notice).
270
+ */
271
+ export function countUnreadableModules(tree) {
272
+ let count = 0;
273
+ for (const item of tree) {
274
+ if (item.type === 'module') {
275
+ if (item.childrenUnavailable)
276
+ count++;
277
+ if (item.children)
278
+ count += countUnreadableModules(item.children);
279
+ }
280
+ }
281
+ return count;
282
+ }
228
283
  /**
229
284
  * Register get_course_content tool
230
285
  */
@@ -276,7 +331,8 @@ export function registerGetCourseContent(server, apiClient) {
276
331
  }
277
332
  const topicCount = countTopics(contentTree);
278
333
  const moduleCount = countModules(contentTree);
279
- log("INFO", `get_course_content: Retrieved ${moduleCount} modules and ${topicCount} topics for course ${courseId} (filter: ${typeFilter})`);
334
+ const unreadable = countUnreadableModules(contentTree);
335
+ log("INFO", `get_course_content: Retrieved ${moduleCount} modules and ${topicCount} topics for course ${courseId} (filter: ${typeFilter})${unreadable > 0 ? `, ${unreadable} module(s) unreadable` : ""}`);
280
336
  return toolResponse({
281
337
  courseId,
282
338
  typeFilter,
@@ -284,6 +340,13 @@ export function registerGetCourseContent(server, apiClient) {
284
340
  topicCount,
285
341
  moduleCount,
286
342
  ...(modifiedSince ? { modifiedSince, returned: topicCount, filteredOut } : {}),
343
+ ...(unreadable > 0
344
+ ? {
345
+ notice: `The contents of ${unreadable} module(s) couldn't be read; they have children null and a ` +
346
+ "childrenUnavailable reason. topicCount leaves out whatever they hold. Do not report " +
347
+ "those modules as empty.",
348
+ }
349
+ : {}),
287
350
  });
288
351
  }
289
352
  catch (error) {
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetDiscussionsSchema } from "./schemas.js";
8
- import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
8
+ import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable } from "./tool-helpers.js";
9
9
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { discussionUrl } from "../utils/deep-links.js";
@@ -62,6 +62,21 @@ export function registerGetDiscussions(server, apiClient, baseUrl) {
62
62
  }
63
63
  });
64
64
  }
65
+ /**
66
+ * Describe a failed per-item read, or rethrow it when the session itself is
67
+ * unusable. A pending or expired sign-in fails every item the same way, and
68
+ * must fail the call so the user is told to sign in, not shown a course
69
+ * whose every forum looks empty.
70
+ */
71
+ function subListUnavailable(error, operation, what) {
72
+ if (isAuthUnavailable(error))
73
+ throw error;
74
+ const status = typeof error?.status === "number" ? error.status : null;
75
+ const reason = status === 403
76
+ ? `Brightspace refused this account's request for the ${what}. They may exist; open the discussion in a browser to see what this account can read.`
77
+ : `Couldn't read the ${what}${status !== null ? ` (Brightspace answered ${status})` : ""}. They may exist; try again.`;
78
+ return { operation, status, reason };
79
+ }
65
80
  /**
66
81
  * Get all forums for a course with their topics (no posts).
67
82
  */
@@ -69,18 +84,17 @@ async function getForumsOverview(apiClient, courseId) {
69
84
  const forums = await fetchForums(apiClient, courseId);
70
85
  const result = [];
71
86
  for (const forum of forums) {
72
- // Fetch topics for each forum
73
- let topics = [];
87
+ // Fetch topics for each forum. A failed read is reported as null with a
88
+ // reason, never as [], so it can't pass for a forum with no topics.
89
+ let topics;
90
+ let topicsUnavailable;
74
91
  try {
75
92
  topics = await fetchForumTopics(apiClient, courseId, forum.ForumId);
76
93
  }
77
94
  catch (error) {
78
- if (error?.status === 403) {
79
- log("DEBUG", `No access to topics for forum ${forum.ForumId}, skipping`);
80
- }
81
- else {
82
- log("DEBUG", `Failed to fetch topics for forum ${forum.ForumId}`, error);
83
- }
95
+ topicsUnavailable = subListUnavailable(error, "listTopics", "topics");
96
+ log("INFO", `get_discussions: Topics for forum ${forum.ForumId} unavailable`, error);
97
+ topics = null;
84
98
  }
85
99
  result.push({
86
100
  forumId: forum.ForumId,
@@ -88,7 +102,8 @@ async function getForumsOverview(apiClient, courseId) {
88
102
  description: forum.Description?.Text ?? null,
89
103
  isLocked: forum.IsLocked,
90
104
  isHidden: forum.IsHidden,
91
- topics: topics.map((t) => ({
105
+ ...(topicsUnavailable ? { topicsUnavailable } : {}),
106
+ topics: topics?.map((t) => ({
92
107
  topicId: t.TopicId,
93
108
  forumId: t.ForumId,
94
109
  name: t.Name,
@@ -98,7 +113,7 @@ async function getForumsOverview(apiClient, courseId) {
98
113
  isHidden: t.IsHidden,
99
114
  mustPostToParticipate: t.MustPostToParticipate,
100
115
  scoreOutOf: t.ScoreOutOf,
101
- })),
116
+ })) ?? null,
102
117
  });
103
118
  }
104
119
  log("INFO", `get_discussions: Retrieved ${forums.length} forums for course ${courseId}`);
@@ -125,22 +140,21 @@ async function getForumDetail(apiClient, courseId, forumId) {
125
140
  // Fetch posts for each topic
126
141
  const topicsWithPosts = [];
127
142
  for (const topic of topics) {
128
- let posts = [];
143
+ // A failed posts read is reported as null with a reason, never as an
144
+ // empty thread, matching the single-topic view.
145
+ let formatted;
146
+ let postsUnavailable;
129
147
  try {
130
148
  const postsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topic.TopicId}/posts/`);
131
- posts = await apiClient.get(postsPath, {
149
+ formatted = formatPosts(await apiClient.get(postsPath, {
132
150
  ttl: DEFAULT_CACHE_TTLS.announcements,
133
- });
151
+ }));
134
152
  }
135
153
  catch (error) {
136
- if (error?.status === 403) {
137
- log("DEBUG", `No access to posts for topic ${topic.TopicId}, skipping`);
138
- }
139
- else {
140
- log("DEBUG", `Failed to fetch posts for topic ${topic.TopicId}`, error);
141
- }
154
+ postsUnavailable = subListUnavailable(error, "listPosts", "posts");
155
+ log("INFO", `get_discussions: Posts for topic ${topic.TopicId} unavailable`, error);
156
+ formatted = null;
142
157
  }
143
- const formatted = formatPosts(posts);
144
158
  topicsWithPosts.push({
145
159
  topicId: topic.TopicId,
146
160
  name: topic.Name,
@@ -151,8 +165,9 @@ async function getForumDetail(apiClient, courseId, forumId) {
151
165
  isLocked: topic.IsLocked,
152
166
  mustPostToParticipate: topic.MustPostToParticipate,
153
167
  scoreOutOf: topic.ScoreOutOf,
154
- postCount: formatted.length,
168
+ postCount: formatted?.length ?? null,
155
169
  posts: formatted,
170
+ ...(postsUnavailable ? { postsUnavailable } : {}),
156
171
  });
157
172
  }
158
173
  log("INFO", `get_discussions: Retrieved forum ${forumId} with ${topics.length} topics for course ${courseId}`);
@@ -21,6 +21,15 @@ const TEACHING_ROLE_NAME = /\b(instructor|professor|lecturer|teaching assistant|
21
21
  function isTeachingRoleByName(user) {
22
22
  return TEACHING_ROLE_NAME.test(user.ClasslistRoleDisplayName ?? "");
23
23
  }
24
+ /**
25
+ * Whether a failed role route must be reported rather than skipped. A 403
26
+ * means the classlist is restricted for that role: nothing was missed.
27
+ * Anything else (a 404, a 5xx, a dropped connection) leaves a hole the caller
28
+ * would read as "this course has no TAs".
29
+ */
30
+ function isReportableFailure(error) {
31
+ return error?.status !== 403;
32
+ }
24
33
  /**
25
34
  * Fetch every classlist user matching the optional filters, across all pages.
26
35
  *
@@ -60,6 +69,7 @@ export function registerGetRoster(server, apiClient) {
60
69
  const allUsers = [];
61
70
  let authFailure;
62
71
  let roleFallbackUsed = false;
72
+ let unavailable = [];
63
73
  if (!includeStudents) {
64
74
  // Fetch instructors and TAs in parallel
65
75
  const [instructorResult, taResult] = await Promise.allSettled([
@@ -87,6 +97,10 @@ export function registerGetRoster(server, apiClient) {
87
97
  allUsers.push(...instructorResult.value);
88
98
  }
89
99
  else {
100
+ if (!isAuthUnavailable(instructorResult.reason) &&
101
+ isReportableFailure(instructorResult.reason)) {
102
+ unavailable.push("instructors");
103
+ }
90
104
  log("WARN", "get_roster: Failed to fetch instructors", {
91
105
  error: instructorResult.reason,
92
106
  });
@@ -95,6 +109,9 @@ export function registerGetRoster(server, apiClient) {
95
109
  allUsers.push(...taResult.value);
96
110
  }
97
111
  else {
112
+ if (!isAuthUnavailable(taResult.reason) && isReportableFailure(taResult.reason)) {
113
+ unavailable.push("TAs");
114
+ }
98
115
  log("WARN", "get_roster: Failed to fetch TAs", {
99
116
  error: taResult.reason,
100
117
  });
@@ -112,6 +129,9 @@ export function registerGetRoster(server, apiClient) {
112
129
  });
113
130
  allUsers.push(...everyone.filter(isTeachingRoleByName));
114
131
  roleFallbackUsed = true;
132
+ // The unfiltered fetch covered every role, so nothing the
133
+ // failed role routes would have returned is missing any more.
134
+ unavailable = [];
115
135
  }
116
136
  }
117
137
  else {
@@ -139,6 +159,17 @@ export function registerGetRoster(server, apiClient) {
139
159
  }));
140
160
  log("INFO", `get_roster: Retrieved ${users.length} users for course ${courseId}` +
141
161
  (authFailure ? " (sign-in pending for one route)" : ""));
162
+ const notices = [];
163
+ if (unavailable.length > 0) {
164
+ notices.push(`The ${unavailable.join(" and ")} for this course could not be read from ` +
165
+ "Brightspace, so they may be missing from this list. Do not report this " +
166
+ `course as having no ${unavailable.join(" or ")}; call get_roster again to retry.`);
167
+ }
168
+ if (authFailure) {
169
+ notices.push("Sign-in to Brightspace is still in progress, so part of the roster for this " +
170
+ `course could not be fetched yet. ${authPendingNotice(authFailure.reason)} Call ` +
171
+ "get_roster again once sign-in finishes.");
172
+ }
142
173
  return toolResponse({
143
174
  courseId,
144
175
  total,
@@ -153,14 +184,9 @@ export function registerGetRoster(server, apiClient) {
153
184
  }
154
185
  : {}),
155
186
  users,
156
- ...(authFailure
157
- ? {
158
- authPending: true,
159
- notice: "Sign-in to Brightspace is still in progress, so part of the roster for this " +
160
- `course could not be fetched yet. ${authPendingNotice(authFailure.reason)} Call ` +
161
- "get_roster again once sign-in finishes.",
162
- }
163
- : {}),
187
+ ...(unavailable.length > 0 ? { unavailable } : {}),
188
+ ...(authFailure ? { authPending: true } : {}),
189
+ ...(notices.length > 0 ? { notice: notices.join(" ") } : {}),
164
190
  });
165
191
  }
166
192
  catch (error) {
@@ -7,7 +7,7 @@ import { ApiError } from "../api/index.js";
7
7
  import { SearchCourseSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
- import { fetchRootContent, buildContentTree } from "./get-course-content.js";
10
+ import { fetchRootContent, buildContentTree, countUnreadableModules } from "./get-course-content.js";
11
11
  import { fetchCourseNews, isPublishedNewsItem, mapNewsItem } from "./get-announcements.js";
12
12
  import { fetchForums, fetchForumTopics } from "./get-discussions.js";
13
13
  /** A query term matched in the title outranks the same term only in the body. */
@@ -186,6 +186,10 @@ export function registerSearchCourse(server, apiClient) {
186
186
  const rootModules = await fetchRootContent(apiClient, courseId);
187
187
  const tree = await buildContentTree(apiClient, courseId, rootModules, new Map(), "all");
188
188
  entries.push(...flattenContentTree(tree));
189
+ const unreadable = countUnreadableModules(tree);
190
+ if (unreadable > 0) {
191
+ notes.push(`content inside ${unreadable} module(s) whose contents couldn't be read`);
192
+ }
189
193
  }
190
194
  catch (error) {
191
195
  // A pending or refused sign-in is not a source that failed: the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.32",
3
+ "version": "5.1.35",
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",