brightspace-mcp-server 3.11.7 → 3.11.9

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.
@@ -0,0 +1,43 @@
1
+ import { assignmentUrl } from "../utils/deep-links.js";
2
+ import { fetchMyGroupMemberships } from "./group-membership.js";
3
+ import { isAuthUnavailable } from "./tool-helpers.js";
4
+ /** Resolve only the folder's category, once per category per tool call. */
5
+ export function assignmentLinkResolver(apiClient, baseUrl, courseId) {
6
+ const groups = new Map();
7
+ return async (folder) => {
8
+ if (!baseUrl)
9
+ return { url: null };
10
+ // D2L defines both missing and null GroupTypeId as individual folders:
11
+ // https://docs.valence.desire2learn.com/res/dropbox.html#Dropbox.DropboxFolder
12
+ if (folder.GroupTypeId === null || folder.GroupTypeId === undefined) {
13
+ return { url: assignmentUrl(baseUrl, courseId, folder.Id), urlKind: "assignment" };
14
+ }
15
+ const categoryId = folder.GroupTypeId;
16
+ let groupId = null;
17
+ if (categoryId !== undefined && Number.isSafeInteger(categoryId) && categoryId > 0) {
18
+ if (!groups.has(categoryId)) {
19
+ groups.set(categoryId, (async () => {
20
+ try {
21
+ const { matches } = await fetchMyGroupMemberships(apiClient, courseId, categoryId);
22
+ const ids = [...new Set(matches.map(({ group }) => group.GroupId))]
23
+ .filter((id) => Number.isSafeInteger(id) && id > 0);
24
+ return ids.length === 1 ? ids[0] : null;
25
+ }
26
+ catch (error) {
27
+ if (isAuthUnavailable(error))
28
+ throw error;
29
+ return null;
30
+ }
31
+ })());
32
+ }
33
+ groupId = await groups.get(categoryId);
34
+ }
35
+ if (groupId !== null)
36
+ return { url: assignmentUrl(baseUrl, courseId, folder.Id, groupId), urlKind: "assignment" };
37
+ return {
38
+ url: `${baseUrl.replace(/\/+$/, "")}/d2l/home/${courseId}`,
39
+ urlKind: "course-home",
40
+ urlNote: "Brightspace did not identify a unique group for this assignment. Open Assignments from the course home to select the folder.",
41
+ };
42
+ };
43
+ }
@@ -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,
@@ -7,7 +7,7 @@ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetAssignmentFilesSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
9
  import { describeAttachment, readAttachment, } from "./attachment-reader.js";
10
- import { assignmentUrl } from "../utils/deep-links.js";
10
+ import { assignmentLinkResolver } from "./assignment-links.js";
11
11
  import { log } from "../utils/logger.js";
12
12
  export { fileKind } from "./attachment-reader.js";
13
13
  /** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
@@ -33,6 +33,7 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
33
33
  log("DEBUG", "get_assignment_files tool called", { args });
34
34
  const { courseId, folderId, fileId, extractText, maxChars } = GetAssignmentFilesSchema.parse(args);
35
35
  const folders = await listFolders(apiClient, courseId, folderId);
36
+ const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, courseId);
36
37
  if (folderId !== undefined && folders.length === 0) {
37
38
  return toolResponse({
38
39
  courseId,
@@ -65,23 +66,23 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
65
66
  courseId,
66
67
  folderId,
67
68
  folderName: folder.Name,
68
- url: baseUrl ? assignmentUrl(baseUrl, courseId, folderId) : null,
69
+ ...await assignmentLink(folder),
69
70
  file,
70
71
  });
71
72
  }
72
73
  // Discovery: which assignments have files, without downloading any.
73
- const withFiles = folders
74
+ const withFiles = await Promise.all(folders
74
75
  .filter((folder) => (folder.Attachments ?? []).length > 0)
75
- .map((folder) => ({
76
+ .map(async (folder) => ({
76
77
  folderId: folder.Id,
77
78
  folderName: folder.Name,
78
79
  dueDate: folder.DueDate,
79
- url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
80
+ ...await assignmentLink(folder),
80
81
  attachments: (folder.Attachments ?? []).map((attachment) => ({
81
82
  ...describeAttachment(attachment),
82
83
  downloadArgs: { courseId, folderId: folder.Id, fileId: attachment.FileId, source: "assignmentAttachment" },
83
84
  })),
84
- }));
85
+ })));
85
86
  log("INFO", `get_assignment_files: ${withFiles.length} assignments with attachments in course ${courseId}`);
86
87
  return toolResponse({
87
88
  courseId,
@@ -10,7 +10,8 @@ import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } fro
10
10
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
11
11
  import { log } from "../utils/logger.js";
12
12
  import { applyCourseFilter } from "../utils/course-filter.js";
13
- import { assignmentUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
13
+ import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
14
+ import { assignmentLinkResolver } from "./assignment-links.js";
14
15
  import { dueIn } from "../utils/due-in.js";
15
16
  /** The display HTML of a quiz rich-text field, nested shape or flat. */
16
17
  function richTextHtml(field) {
@@ -100,6 +101,7 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
100
101
  */
101
102
  export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
102
103
  const assignments = [];
104
+ const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, courseId);
103
105
  // Fetch dropbox folders, quizzes, and the gradebook in parallel. The
104
106
  // gradebook is fetched alongside them but read last: a heads-up row is a
105
107
  // column that nothing the other two returned matched, so the comparison
@@ -178,14 +180,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
178
180
  type: "assignment",
179
181
  id: folder.Id,
180
182
  name: folder.Name,
181
- url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
183
+ ...await assignmentLink(folder),
182
184
  instructions: folder.CustomInstructions?.Html
183
185
  ? convertHtmlToMarkdown(folder.CustomInstructions.Html).markdown
184
186
  : "",
185
187
  dueDate: folder.DueDate,
186
188
  dueIn: dueIn(folder.DueDate),
187
189
  points: folder.Assessment?.ScoreDenominator ?? null,
188
- isGroup: folder.GroupTypeId !== null,
190
+ isGroup: folder.GroupTypeId !== null && folder.GroupTypeId !== undefined,
189
191
  ...(linkAttachments.length > 0 ? { linkAttachments } : {}),
190
192
  rubric: folder.Assessment?.Rubrics?.map((r) => ({
191
193
  name: r.Name,
@@ -3,12 +3,12 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
- import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
6
  import { ApiError } from "../api/errors.js";
8
7
  import { GetMyGroupsSchema } from "./schemas.js";
9
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
10
9
  import { fetchClasslistUsers } from "./get-roster.js";
11
10
  import { log } from "../utils/logger.js";
11
+ import { fetchMyGroupMemberships } from "./group-membership.js";
12
12
  /** True when a group/category endpoint simply isn't there for this course or user. */
13
13
  function isMissingOrForbidden(error) {
14
14
  return error instanceof ApiError && (error.status === 403 || error.status === 404);
@@ -26,49 +26,13 @@ export function registerGetMyGroups(server, apiClient) {
26
26
  try {
27
27
  log("DEBUG", "get_my_groups tool called", { args });
28
28
  const { courseId } = GetMyGroupsSchema.parse(args);
29
- const me = await apiClient.get(apiClient.lp("/users/whoami"));
30
- const myId = Number(me.Identifier);
31
- let categories;
32
- try {
33
- categories = await apiClient.get(apiClient.lp(`/${courseId}/groupcategories/`), { ttl: DEFAULT_CACHE_TTLS.roster });
34
- }
35
- catch (error) {
36
- if (isMissingOrForbidden(error)) {
37
- log("INFO", "get_my_groups: No group categories visible for course", { courseId });
38
- return toolResponse({
39
- courseId,
40
- groups: [],
41
- note: "No groups are visible for this course (it may not use groups, or group info isn't accessible).",
42
- });
43
- }
44
- throw error;
45
- }
46
- // Membership is determined first, from GroupDto.Enrollments alone —
47
- // no classlist needed for that. The classlist (for member names) is
48
- // fetched lazily, only once we know the user is actually in at least
49
- // one group, so a user in no groups never pays for a roster fetch.
50
- const matches = [];
51
- for (const category of categories) {
52
- let categoryGroups;
53
- try {
54
- categoryGroups = await apiClient.get(apiClient.lp(`/${courseId}/groupcategories/${category.GroupCategoryId}/groups/`), { ttl: DEFAULT_CACHE_TTLS.roster });
55
- }
56
- catch (error) {
57
- if (isMissingOrForbidden(error)) {
58
- log("WARN", "get_my_groups: Groups not accessible for category, skipping", {
59
- courseId,
60
- categoryId: category.GroupCategoryId,
61
- });
62
- continue;
63
- }
64
- throw error;
65
- }
66
- for (const group of categoryGroups) {
67
- const enrollments = group.Enrollments ?? [];
68
- if (!enrollments.includes(myId))
69
- continue;
70
- matches.push({ category, group });
71
- }
29
+ const { matches, categoriesUnavailable } = await fetchMyGroupMemberships(apiClient, courseId);
30
+ if (categoriesUnavailable) {
31
+ return toolResponse({
32
+ courseId,
33
+ groups: [],
34
+ note: "No groups are visible for this course (it may not use groups, or group info isn't accessible).",
35
+ });
72
36
  }
73
37
  if (matches.length === 0) {
74
38
  log("INFO", `get_my_groups: User is not a member of any group in course ${courseId}`);
@@ -7,7 +7,8 @@ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetUpcomingDueDatesSchema, } from "./schemas.js";
8
8
  import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
- import { assignmentUrl, quizUrl, discussionUrl } from "../utils/deep-links.js";
10
+ import { quizUrl, discussionUrl } from "../utils/deep-links.js";
11
+ import { assignmentLinkResolver } from "./assignment-links.js";
11
12
  import { dueIn } from "../utils/due-in.js";
12
13
  import { resolveCourses } from "./resolve-courses.js";
13
14
  import { fetchCourseCalendarEvents } from "./calendar-events.js";
@@ -88,12 +89,14 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
88
89
  throw result.reason;
89
90
  }
90
91
  const items = [];
92
+ const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, course.id);
91
93
  if (dropboxResult.status === "fulfilled") {
92
94
  for (const folder of unwrapList(dropboxResult.value)) {
93
95
  if (folder.IsHidden === true)
94
96
  continue;
95
97
  if (!folder.DueDate)
96
98
  continue;
99
+ const link = await assignmentLink(folder);
97
100
  items.push({
98
101
  type: "assignment",
99
102
  id: folder.Id,
@@ -104,7 +107,8 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
104
107
  dueIn: dueIn(folder.DueDate),
105
108
  startDate: null,
106
109
  endDate: null,
107
- url: assignmentUrl(baseUrl, course.id, folder.Id),
110
+ ...link,
111
+ url: link.url,
108
112
  });
109
113
  }
110
114
  }
@@ -0,0 +1,39 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { ApiError } from "../api/errors.js";
3
+ const inaccessible = (error) => error instanceof ApiError
4
+ && (error.status === 403 || error.status === 404);
5
+ /** Shared membership lookup. API cache/coalescing bounds repeat reads; no classlist is needed. */
6
+ export async function fetchMyGroupMemberships(apiClient, courseId, categoryId) {
7
+ const me = await apiClient.get(apiClient.lp("/users/whoami"), { ttl: DEFAULT_CACHE_TTLS.roster });
8
+ const myId = Number(me.Identifier);
9
+ if (!Number.isSafeInteger(myId) || myId <= 0)
10
+ throw new Error("Brightspace returned an invalid current user identifier.");
11
+ let categories;
12
+ try {
13
+ categories = categoryId === undefined
14
+ ? await apiClient.get(apiClient.lp(`/${courseId}/groupcategories/`), { ttl: DEFAULT_CACHE_TTLS.roster })
15
+ : [{ GroupCategoryId: categoryId, Name: "" }];
16
+ }
17
+ catch (error) {
18
+ if (inaccessible(error))
19
+ return { matches: [], categoriesUnavailable: true };
20
+ throw error;
21
+ }
22
+ const matches = [];
23
+ for (const category of categories) {
24
+ let groups;
25
+ try {
26
+ groups = await apiClient.get(apiClient.lp(`/${courseId}/groupcategories/${category.GroupCategoryId}/groups/`), { ttl: DEFAULT_CACHE_TTLS.roster });
27
+ }
28
+ catch (error) {
29
+ if (inaccessible(error))
30
+ continue;
31
+ throw error;
32
+ }
33
+ for (const group of groups) {
34
+ if (group.Enrollments?.some((id) => Number(id) === myId))
35
+ matches.push({ category, group });
36
+ }
37
+ }
38
+ return { matches, categoriesUnavailable: false };
39
+ }
@@ -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
  });
@@ -22,8 +22,8 @@ function trimBaseUrl(baseUrl) {
22
22
  return baseUrl.replace(/\/+$/, "");
23
23
  }
24
24
  /** Link to the submit-files page of a dropbox folder. */
25
- export function assignmentUrl(baseUrl, courseId, folderId) {
26
- return `${trimBaseUrl(baseUrl)}/d2l/lms/dropbox/user/folder_submit_files.d2l?db=${folderId}&grpid=0&ou=${courseId}`;
25
+ export function assignmentUrl(baseUrl, courseId, folderId, groupId = 0) {
26
+ return `${trimBaseUrl(baseUrl)}/d2l/lms/dropbox/user/folder_submit_files.d2l?db=${folderId}&grpid=${groupId}&ou=${courseId}`;
27
27
  }
28
28
  /** Link to the summary page of a quiz. */
29
29
  export function quizUrl(baseUrl, courseId, quizId) {
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.9",
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",