brightspace-mcp-server 3.11.8 → 3.11.10
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-assignment-files.js +7 -6
- package/build/tools/get-assignment-rubric.js +3 -6
- package/build/tools/get-assignments.js +28 -7
- package/build/tools/get-my-grades.js +9 -7
- 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/utils/deep-links.js +2 -2
- package/build/utils/feedback.js +12 -0
- 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
|
+
}
|
|
@@ -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,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { GetAssignmentRubricSchema } from "./schemas.js";
|
|
8
8
|
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
|
-
import {
|
|
9
|
+
import { feedbackText } from "../utils/feedback.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
// D2L's documented SCORING_M enumeration
|
|
12
12
|
// (https://docs.valence.desire2learn.com/res/assessment.html). Only values
|
|
@@ -20,10 +20,7 @@ const SCORING_METHOD_NAMES = {
|
|
|
20
20
|
};
|
|
21
21
|
/** Rich text to plain markdown, or undefined when there is nothing to say. */
|
|
22
22
|
function textOf(rt) {
|
|
23
|
-
|
|
24
|
-
return undefined;
|
|
25
|
-
const text = rt.Html ? convertHtmlToMarkdown(rt.Html).markdown : rt.Text ?? undefined;
|
|
26
|
-
return text && text.trim().length > 0 ? text : undefined;
|
|
23
|
+
return feedbackText(rt) ?? undefined;
|
|
27
24
|
}
|
|
28
25
|
/** The groups a rubric carries, normalizing the grouped and flattened shapes. */
|
|
29
26
|
function groupsOf(rubric) {
|
|
@@ -120,7 +117,7 @@ function levelNamesOf(rubric) {
|
|
|
120
117
|
* The student's own graded outcome on this rubric, resolved against the
|
|
121
118
|
* rubric's own criterion/level names rather than re-sent by the assessment.
|
|
122
119
|
*/
|
|
123
|
-
function toOutputAssessment(rubric, assessment) {
|
|
120
|
+
export function toOutputAssessment(rubric, assessment) {
|
|
124
121
|
const criterionNames = criterionNamesOf(rubric);
|
|
125
122
|
const levelNames = levelNamesOf(rubric);
|
|
126
123
|
const out = { criteria: [] };
|
|
@@ -10,8 +10,11 @@ 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";
|
|
16
|
+
import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
|
|
17
|
+
import { toOutputAssessment } from "./get-assignment-rubric.js";
|
|
15
18
|
/** The display HTML of a quiz rich-text field, nested shape or flat. */
|
|
16
19
|
function richTextHtml(field) {
|
|
17
20
|
if (!field)
|
|
@@ -100,6 +103,7 @@ async function recoverContentQuizzes(apiClient, courseId, toc, listedIds) {
|
|
|
100
103
|
*/
|
|
101
104
|
export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
102
105
|
const assignments = [];
|
|
106
|
+
const assignmentLink = assignmentLinkResolver(apiClient, baseUrl, courseId);
|
|
103
107
|
// Fetch dropbox folders, quizzes, and the gradebook in parallel. The
|
|
104
108
|
// gradebook is fetched alongside them but read last: a heads-up row is a
|
|
105
109
|
// column that nothing the other two returned matched, so the comparison
|
|
@@ -152,13 +156,18 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
152
156
|
}
|
|
153
157
|
// Fetch feedback independently of submissions
|
|
154
158
|
let feedback = null;
|
|
159
|
+
let feedbackStatus = "unavailable";
|
|
155
160
|
try {
|
|
156
161
|
feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
162
|
+
feedbackStatus = feedback ? "retrieved" : "unavailable";
|
|
157
163
|
}
|
|
158
164
|
catch (error) {
|
|
159
165
|
if (isAuthUnavailable(error))
|
|
160
166
|
throw error;
|
|
161
|
-
//
|
|
167
|
+
// Permission failures, missing API records, and transport failures
|
|
168
|
+
// do not establish whether published feedback exists in the UI.
|
|
169
|
+
feedbackStatus = error?.status === 403 ? "restricted"
|
|
170
|
+
: error?.status === 404 ? "unavailable" : "error";
|
|
162
171
|
if (error?.status !== 404 && error?.status !== 403) {
|
|
163
172
|
log("DEBUG", `Failed to fetch feedback for folder ${folder.Id}`, error);
|
|
164
173
|
}
|
|
@@ -174,18 +183,27 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
174
183
|
.filter((l) => Boolean(l.Href))
|
|
175
184
|
.map((l) => ({ name: l.LinkName ?? l.Title ?? l.Href ?? null, url: l.Href }));
|
|
176
185
|
// Build assignment object
|
|
186
|
+
const overallFeedback = feedbackText(feedback?.Feedback);
|
|
187
|
+
const rubricAssessments = (feedback?.RubricAssessments ?? []).map((assessment) => {
|
|
188
|
+
const rubric = folder.Assessment?.Rubrics?.find((r) => r.RubricId === assessment.RubricId);
|
|
189
|
+
return {
|
|
190
|
+
rubricId: assessment.RubricId,
|
|
191
|
+
...toOutputAssessment((rubric ?? { RubricId: assessment.RubricId, Name: "" }), assessment),
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
const commentsRetrieved = Boolean(overallFeedback || rubricAssessments.some((rubric) => rubric.feedback || rubric.criteria.some((criterion) => criterion.feedback)));
|
|
177
195
|
const assignment = {
|
|
178
196
|
type: "assignment",
|
|
179
197
|
id: folder.Id,
|
|
180
198
|
name: folder.Name,
|
|
181
|
-
|
|
199
|
+
...await assignmentLink(folder),
|
|
182
200
|
instructions: folder.CustomInstructions?.Html
|
|
183
201
|
? convertHtmlToMarkdown(folder.CustomInstructions.Html).markdown
|
|
184
202
|
: "",
|
|
185
203
|
dueDate: folder.DueDate,
|
|
186
204
|
dueIn: dueIn(folder.DueDate),
|
|
187
205
|
points: folder.Assessment?.ScoreDenominator ?? null,
|
|
188
|
-
isGroup: folder.GroupTypeId !== null,
|
|
206
|
+
isGroup: folder.GroupTypeId !== null && folder.GroupTypeId !== undefined,
|
|
189
207
|
...(linkAttachments.length > 0 ? { linkAttachments } : {}),
|
|
190
208
|
rubric: folder.Assessment?.Rubrics?.map((r) => ({
|
|
191
209
|
name: r.Name,
|
|
@@ -225,11 +243,14 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
|
|
|
225
243
|
feedback: feedback
|
|
226
244
|
? {
|
|
227
245
|
score: feedback.Score,
|
|
228
|
-
feedback:
|
|
229
|
-
|
|
230
|
-
: null,
|
|
246
|
+
feedback: overallFeedback,
|
|
247
|
+
rubricAssessments,
|
|
231
248
|
}
|
|
232
249
|
: null,
|
|
250
|
+
feedbackStatus,
|
|
251
|
+
feedbackCommentsStatus: commentsRetrieved ? "retrieved" : "unavailable",
|
|
252
|
+
feedbackUrl: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
|
|
253
|
+
...(commentsRetrieved ? {} : { feedbackStatusNote: FEEDBACK_UNAVAILABLE_NOTE }),
|
|
233
254
|
};
|
|
234
255
|
assignments.push(assignment);
|
|
235
256
|
}
|
|
@@ -10,6 +10,7 @@ import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPend
|
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { gradebookUrl, quizUrl } from "../utils/deep-links.js";
|
|
13
|
+
import { feedbackText, FEEDBACK_UNAVAILABLE_NOTE } from "../utils/feedback.js";
|
|
13
14
|
/**
|
|
14
15
|
* The quiz a grade row is scored from can hold feedback the gradebook does
|
|
15
16
|
* not, and the student API cannot read it: quiz attempts are refused to
|
|
@@ -35,15 +36,15 @@ async function fetchQuizzes(apiClient, courseId) {
|
|
|
35
36
|
}
|
|
36
37
|
}
|
|
37
38
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
39
|
+
* Normalize public comments and report their retrieval status. Quiz-linked
|
|
40
|
+
* rows keep the quiz source URL and note; other rows link to the gradebook.
|
|
40
41
|
*/
|
|
41
42
|
function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
|
|
42
43
|
const quizByGradeItem = new Map(quizzes
|
|
43
44
|
.filter((q) => q.GradeItemId != null)
|
|
44
45
|
.map((q) => [String(q.GradeItemId), q.QuizId]));
|
|
45
46
|
return gradeValues.map((gv) => {
|
|
46
|
-
const comments = gv.Comments
|
|
47
|
+
const comments = feedbackText(gv.Comments);
|
|
47
48
|
const quizId = quizByGradeItem.get(String(gv.GradeObjectIdentifier));
|
|
48
49
|
return {
|
|
49
50
|
name: gv.GradeObjectName,
|
|
@@ -52,12 +53,13 @@ function toGradeItems(gradeValues, quizzes, baseUrl, courseId) {
|
|
|
52
53
|
pointsDenominator: gv.PointsDenominator,
|
|
53
54
|
weightedNumerator: gv.WeightedNumerator,
|
|
54
55
|
weightedDenominator: gv.WeightedDenominator,
|
|
56
|
+
gradeObjectId: gv.GradeObjectIdentifier,
|
|
55
57
|
comments,
|
|
58
|
+
feedbackStatus: comments ? "retrieved" : "unavailable",
|
|
59
|
+
feedbackUrl: quizId === undefined ? gradebookUrl(baseUrl, courseId) : quizUrl(baseUrl, courseId, quizId),
|
|
60
|
+
...(comments ? {} : { feedbackStatusNote: FEEDBACK_UNAVAILABLE_NOTE }),
|
|
56
61
|
lastModified: gv.LastModified,
|
|
57
|
-
...(quizId
|
|
58
|
-
feedbackUrl: quizUrl(baseUrl, courseId, quizId),
|
|
59
|
-
...(comments ? {} : { feedbackNote: QUIZ_FEEDBACK_NOTE }),
|
|
60
|
-
}),
|
|
62
|
+
...(quizId !== undefined && !comments ? { feedbackNote: QUIZ_FEEDBACK_NOTE } : {}),
|
|
61
63
|
};
|
|
62
64
|
});
|
|
63
65
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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) {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Public instructor feedback only; never substitute private comments. */
|
|
2
|
+
import { convertHtmlToMarkdown } from "./html-converter.js";
|
|
3
|
+
export function feedbackText(value) {
|
|
4
|
+
if (!value)
|
|
5
|
+
return null;
|
|
6
|
+
const markdown = value.Html ? convertHtmlToMarkdown(value.Html).markdown : null;
|
|
7
|
+
if (markdown?.trim())
|
|
8
|
+
return markdown;
|
|
9
|
+
return value.Text?.trim() ? value.Text : null;
|
|
10
|
+
}
|
|
11
|
+
export const FEEDBACK_UNAVAILABLE_NOTE = "Brightspace did not return instructor comments through this API. Published feedback may still " +
|
|
12
|
+
"be available in Brightspace; do not interpret null as no feedback. Check the source URL.";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.10",
|
|
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",
|