brightspace-mcp-server 3.11.8 → 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
+ }
@@ -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
+ }
@@ -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.8",
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",