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.
- package/build/tools/assignment-links.js +43 -0
- package/build/tools/get-announcements.js +31 -12
- package/build/tools/get-assignment-files.js +7 -6
- package/build/tools/get-assignments.js +5 -3
- package/build/tools/get-my-groups.js +8 -44
- package/build/tools/get-upcoming-due-dates.js +6 -2
- package/build/tools/group-membership.js +39 -0
- package/build/tools/schemas.js +2 -1
- package/build/utils/deep-links.js +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
222
|
-
return
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/build/tools/schemas.js
CHANGED
|
@@ -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("
|
|
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
|
|
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.
|
|
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",
|