brightspace-mcp-server 3.11.7 → 3.11.8

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.
@@ -10,6 +10,18 @@ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable,
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { matchesModifiedSince } from "../utils/modified-since.js";
13
+ function pageInfo(offset, count, returned, total, unavailableCourseIds = []) {
14
+ const nextOffset = total !== null && offset + returned < total ? offset + returned : null;
15
+ return { offset, count, returned, total, nextOffset, truncated: nextOffset !== null,
16
+ coverageComplete: total !== null && unavailableCourseIds.length === 0, unavailableCourseIds };
17
+ }
18
+ // Preserve the original array in content[0] and existing auth notices, while
19
+ // exposing machine-readable continuation metadata in a separate text block.
20
+ function arrayPage(items, pagination, notice) {
21
+ const result = notice ? toolResponseWithNotice(items, notice) : toolResponse(items);
22
+ result.content.push({ type: "text", text: JSON.stringify({ pagination }) });
23
+ return result;
24
+ }
13
25
  /**
14
26
  * A date the runtime can actually order, or null. Unreadable and absent are the
15
27
  * same answer, so a caller can fall through to the next best.
@@ -87,13 +99,13 @@ export function mapNewsItem(item) {
87
99
  export function registerGetAnnouncements(server, apiClient, config) {
88
100
  server.registerTool("get_announcements", {
89
101
  title: "Get Announcements",
90
- description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
102
+ description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
91
103
  inputSchema: GetAnnouncementsSchema,
92
104
  }, async (args) => {
93
105
  try {
94
106
  log("DEBUG", "get_announcements tool called", { args });
95
107
  // Parse and validate input
96
- const { courseId, count, modifiedSince } = GetAnnouncementsSchema.parse(args);
108
+ const { courseId, count, offset, modifiedSince } = GetAnnouncementsSchema.parse(args);
97
109
  const cutoff = modifiedSince ? new Date(modifiedSince) : null;
98
110
  // Single course case
99
111
  if (courseId) {
@@ -104,16 +116,18 @@ export function registerGetAnnouncements(server, apiClient, config) {
104
116
  const matched = cutoff
105
117
  ? published.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
106
118
  : published;
107
- const announcements = matched.sort(newestFirst).slice(0, count);
119
+ const announcements = matched.sort(newestFirst).slice(offset, offset + count);
120
+ const pagination = pageInfo(offset, count, announcements.length, matched.length);
108
121
  log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
109
- return toolResponse(modifiedSince
110
- ? {
122
+ return modifiedSince
123
+ ? toolResponse({
111
124
  announcements,
125
+ pagination,
112
126
  modifiedSince,
113
127
  returned: announcements.length,
114
128
  filteredOut: published.length - matched.length,
115
- }
116
- : announcements);
129
+ })
130
+ : arrayPage(announcements, pagination);
117
131
  }
118
132
  catch (error) {
119
133
  // A pending sign-in is not an empty course: the route never
@@ -137,10 +151,11 @@ export function registerGetAnnouncements(server, apiClient, config) {
137
151
  returned: 0,
138
152
  filteredOut: 0,
139
153
  authPending: true,
154
+ pagination: pageInfo(offset, count, 0, null, [courseId]),
140
155
  notice,
141
156
  });
142
157
  }
143
- return toolResponseWithNotice([], notice);
158
+ return arrayPage([], pageInfo(offset, count, 0, null, [courseId]), notice);
144
159
  }
145
160
  throw error;
146
161
  }
@@ -181,7 +196,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
181
196
  // 403 means no access (past course, etc) - log and skip
182
197
  if (error?.status === 403) {
183
198
  log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
184
- return [];
199
+ return { unavailable: true, courseId: item.OrgUnit.Id };
185
200
  }
186
201
  // A pending sign-in only means this course's route never
187
202
  // answered — it says nothing about the other courses, whose
@@ -201,13 +216,16 @@ export function registerGetAnnouncements(server, apiClient, config) {
201
216
  .map((r) => r.value);
202
217
  const pending = settled.filter((v) => !Array.isArray(v) && v?.authPending === true);
203
218
  const allAnnouncements = settled.filter((v) => Array.isArray(v)).flat();
219
+ const unavailableCourseIds = results.flatMap((result, index) => result.status === "rejected" || (!Array.isArray(result.value) && result.value?.courseId)
220
+ ? [filteredEnrollments[index].OrgUnit.Id] : []);
204
221
  const allMatched = cutoff
205
222
  ? allAnnouncements.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
206
223
  : allAnnouncements;
207
224
  // Sort by the scheduled date and slice to count
208
225
  const announcements = allMatched
209
226
  .sort(newestFirst)
210
- .slice(0, count);
227
+ .slice(offset, offset + count);
228
+ const pagination = pageInfo(offset, count, announcements.length, allMatched.length, unavailableCourseIds);
211
229
  log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total ` +
212
230
  `across ${enrollmentItems.length} courses${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
213
231
  // With modifiedSince the response is already an object (the shape
@@ -218,14 +236,15 @@ export function registerGetAnnouncements(server, apiClient, config) {
218
236
  // the notice instead of changing content[0].
219
237
  if (!modifiedSince) {
220
238
  if (pending.length === 0)
221
- return toolResponse(announcements);
222
- return toolResponseWithNotice(announcements, "Sign-in to Brightspace is still in progress, so announcements for " +
239
+ return arrayPage(announcements, pagination);
240
+ return arrayPage(announcements, pagination, "Sign-in to Brightspace is still in progress, so announcements for " +
223
241
  `${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
224
242
  `fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
225
243
  "sign-in finishes.");
226
244
  }
227
245
  const response = {
228
246
  announcements,
247
+ pagination,
229
248
  modifiedSince,
230
249
  returned: announcements.length,
231
250
  filteredOut: allAnnouncements.length - allMatched.length,
@@ -37,7 +37,8 @@ export const GetMyGradesSchema = z.object({
37
37
  });
38
38
  export const GetAnnouncementsSchema = z.object({
39
39
  courseId: z.coerce.number().int().positive().optional().describe("Course ID to get announcements for. If omitted, returns recent announcements across all courses."),
40
- count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return, newest first (1-50, default 10). There is no pagination or offset: when more announcements exist than count, only the newest are returned. To get fuller coverage, call once per course with courseId so courses do not compete for the same slots, and use modifiedSince to return only announcements changed since a cutoff."),
40
+ count: z.coerce.number().int().min(1).max(50).default(10).describe("Announcements per page: 1-50 (default 10)."),
41
+ offset: z.coerce.number().int().min(0).default(0).describe("Announcement offset after filtering and sorting. Continue with pagination.nextOffset; restart at 0 after a failed course becomes available."),
41
42
  modifiedSince: z.string().datetime({ offset: true, message: "modifiedSince must be an ISO 8601 datetime, e.g. 2026-01-15T00:00:00Z" }).optional()
42
43
  .describe("Only return announcements last modified at or after this ISO 8601 datetime (e.g. 2026-01-15T00:00:00Z). Announcements with no modified timestamp are always included. When set, the response reports how many announcements were filtered out."),
43
44
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.11.7",
3
+ "version": "3.11.8",
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",