brightspace-mcp-server 3.9.4 → 3.9.6
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/README.md +2 -0
- package/build/index.js +5 -3
- package/build/tools/get-assignment-rubric.js +276 -0
- package/build/tools/get-my-groups.js +122 -0
- package/build/tools/get-roster.js +6 -2
- package/build/tools/index.js +2 -0
- package/build/tools/schemas.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -168,10 +168,12 @@ If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.j
|
|
|
168
168
|
| Assignments | "What's due in the next 48 hours?" · "Summarize every assignment I haven't turned in yet" · "Give me the link to submit HW 4" |
|
|
169
169
|
| Quizzes | "Which quizzes close this week?" · "Is Quiz 3 timed, and does it have a grace period?" |
|
|
170
170
|
| Assignment files | "What does the lab 4 spec actually ask for?" · "Summarize the rubric attached to the project" |
|
|
171
|
+
| Rubrics | "What does the rubric for Lab 4 actually want?" · "Why did I lose points on the analysis criterion?" — `get_assignment_rubric` returns the full criteria groups, levels, and point values for a dropbox assignment, plus your own graded outcome per criterion when the tenant exposes it |
|
|
171
172
|
| Exams | "Is there a midterm in the gradebook that isn't on my assignments list?" |
|
|
172
173
|
| Announcements | "Did any professor post something important today?" · "What did my CS prof announce this week?" · "Any announcements since last Monday?" · "Read the file attached to today's announcement" · "Save the rubric my prof attached to that announcement" |
|
|
173
174
|
| Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "What's new in this course since I last checked?" |
|
|
174
175
|
| Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
|
|
176
|
+
| Groups | "Who is in my project group?" · "List my lab group members for this course" |
|
|
175
177
|
| Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
|
|
176
178
|
| Video transcripts | "What did the professor say about pinch-off in Tuesday's lecture recording?" · "Summarize last week's BoilerCast video" — works for Kaltura and YouTube embeds; other platforms report that they aren't supported yet |
|
|
177
179
|
| Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" · "What timezone are my due dates being computed in?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, whether a credential is stored, the server's local timezone and UTC offset (`localTimezone`, `utcOffsetMinutes`), who the session is signed in as when known (`signedInAs: { uniqueName, displayName }`), what Microsoft remembered (`microsoftSession`: stay-signed-in and its expiry, plus whether "Don't ask again" was ticked, already on, not offered, or left off because `D2L_REMEMBER_MFA` isn't set), and `requests` (lightweight counters: responses by status class, network errors, cache hits/misses, coalesced in-flight joins, and token refreshes), without contacting Brightspace or revealing secrets |
|
package/build/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
|
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { dirname, resolve } from "node:path";
|
|
19
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
|
|
19
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
|
|
20
20
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
21
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
22
|
const __dirname = dirname(__filename);
|
|
@@ -122,17 +122,19 @@ else {
|
|
|
122
122
|
registerGetMyGrades(server, apiClient, config);
|
|
123
123
|
registerGetAnnouncements(server, apiClient, config);
|
|
124
124
|
registerGetAssignments(server, apiClient, config);
|
|
125
|
+
registerGetAssignmentRubric(server, apiClient);
|
|
125
126
|
registerGetAssignmentFiles(server, apiClient, config.baseUrl);
|
|
126
127
|
registerGetAnnouncementFiles(server, apiClient);
|
|
127
128
|
registerGetCourseContent(server, apiClient);
|
|
128
129
|
registerDownloadFile(server, apiClient);
|
|
129
130
|
registerGetClasslistEmails(server, apiClient);
|
|
130
131
|
registerGetRoster(server, apiClient);
|
|
132
|
+
registerGetMyGroups(server, apiClient);
|
|
131
133
|
registerGetSyllabus(server, apiClient);
|
|
132
134
|
registerGetDiscussions(server, apiClient);
|
|
133
135
|
registerGetVideoTranscript(server, apiClient);
|
|
134
136
|
registerGetServerInfo(server, config, PKG_VERSION, apiClient);
|
|
135
|
-
log("DEBUG", "MCP tools registered (
|
|
137
|
+
log("DEBUG", "MCP tools registered (18 tools)");
|
|
136
138
|
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
137
139
|
// picker, distinct from tools. Each one is a canned user message that
|
|
138
140
|
// names our own tools by name, so a client with no idea what to ask
|
|
@@ -145,7 +147,7 @@ else {
|
|
|
145
147
|
// Connect stdio transport
|
|
146
148
|
const transport = new StdioServerTransport();
|
|
147
149
|
await server.connect(transport);
|
|
148
|
-
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (
|
|
150
|
+
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (18 tools, 4 prompts registered)");
|
|
149
151
|
log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
|
|
150
152
|
}
|
|
151
153
|
catch (error) {
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purdue Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { GetAssignmentRubricSchema } from "./schemas.js";
|
|
8
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
|
+
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
10
|
+
import { log } from "../utils/logger.js";
|
|
11
|
+
// D2L's documented SCORING_M enumeration
|
|
12
|
+
// (https://docs.valence.desire2learn.com/res/assessment.html). Only values
|
|
13
|
+
// that source confirms are named; an unrecognized value is left as the raw
|
|
14
|
+
// number with no scoringMethodName.
|
|
15
|
+
const SCORING_METHOD_NAMES = {
|
|
16
|
+
0: "TextOnly",
|
|
17
|
+
1: "Points",
|
|
18
|
+
2: "TextAndNumeric",
|
|
19
|
+
3: "CustomPoints",
|
|
20
|
+
};
|
|
21
|
+
/** Rich text to plain markdown, or undefined when there is nothing to say. */
|
|
22
|
+
function textOf(rt) {
|
|
23
|
+
if (!rt)
|
|
24
|
+
return undefined;
|
|
25
|
+
const text = rt.Html ? convertHtmlToMarkdown(rt.Html).markdown : rt.Text ?? undefined;
|
|
26
|
+
return text && text.trim().length > 0 ? text : undefined;
|
|
27
|
+
}
|
|
28
|
+
/** The groups a rubric carries, normalizing the grouped and flattened shapes. */
|
|
29
|
+
function groupsOf(rubric) {
|
|
30
|
+
if (rubric.CriteriaGroups?.length)
|
|
31
|
+
return rubric.CriteriaGroups;
|
|
32
|
+
if (rubric.Criteria?.length)
|
|
33
|
+
return [{ Criteria: rubric.Criteria }];
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
/** A criterion's levels, joining the Cells/Levels split or reading them flat. */
|
|
37
|
+
function levelsOfCriterion(group, criterion) {
|
|
38
|
+
if (criterion.Cells?.length) {
|
|
39
|
+
const groupLevels = new Map((group.Levels ?? []).map((l) => [l.Id, l]));
|
|
40
|
+
return criterion.Cells.map((cell) => {
|
|
41
|
+
const groupLevel = groupLevels.get(cell.LevelId);
|
|
42
|
+
const points = cell.Points ?? groupLevel?.Points ?? undefined;
|
|
43
|
+
const out = { name: groupLevel?.Name ?? `Level ${cell.LevelId}` };
|
|
44
|
+
if (points !== null && points !== undefined)
|
|
45
|
+
out.points = points;
|
|
46
|
+
const description = textOf(cell.Description);
|
|
47
|
+
if (description)
|
|
48
|
+
out.description = description;
|
|
49
|
+
return out;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return (criterion.Levels ?? []).map((level) => {
|
|
53
|
+
const out = { name: level.Name };
|
|
54
|
+
if (level.Points !== null && level.Points !== undefined)
|
|
55
|
+
out.points = level.Points;
|
|
56
|
+
const description = textOf(level.Description);
|
|
57
|
+
if (description)
|
|
58
|
+
out.description = description;
|
|
59
|
+
return out;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
function criteriaGroupsOf(rubric) {
|
|
63
|
+
return groupsOf(rubric).map((group) => ({
|
|
64
|
+
name: group.Name ?? "",
|
|
65
|
+
criteria: (group.Criteria ?? []).map((criterion) => ({
|
|
66
|
+
name: criterion.Name,
|
|
67
|
+
levels: levelsOfCriterion(group, criterion),
|
|
68
|
+
})),
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Sum of each criterion's best achievable points. A criterion with no
|
|
73
|
+
* numeric level (a text-only rubric) contributes nothing; the whole field is
|
|
74
|
+
* left off the response only when every criterion is like that.
|
|
75
|
+
*/
|
|
76
|
+
function totalPointsOf(groups) {
|
|
77
|
+
let total;
|
|
78
|
+
for (const group of groups) {
|
|
79
|
+
for (const criterion of group.criteria) {
|
|
80
|
+
const points = criterion.levels
|
|
81
|
+
.map((l) => l.points)
|
|
82
|
+
.filter((p) => typeof p === "number");
|
|
83
|
+
if (points.length === 0)
|
|
84
|
+
continue;
|
|
85
|
+
total = (total ?? 0) + Math.max(...points);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return total;
|
|
89
|
+
}
|
|
90
|
+
/** Criterion id -> name, across either rubric shape. */
|
|
91
|
+
function criterionNamesOf(rubric) {
|
|
92
|
+
const map = new Map();
|
|
93
|
+
for (const group of groupsOf(rubric)) {
|
|
94
|
+
for (const criterion of group.Criteria ?? []) {
|
|
95
|
+
if (typeof criterion.Id === "number")
|
|
96
|
+
map.set(criterion.Id, criterion.Name);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return map;
|
|
100
|
+
}
|
|
101
|
+
/** Level id -> name, across either rubric shape. */
|
|
102
|
+
function levelNamesOf(rubric) {
|
|
103
|
+
const map = new Map();
|
|
104
|
+
for (const group of rubric.CriteriaGroups ?? []) {
|
|
105
|
+
for (const level of group.Levels ?? []) {
|
|
106
|
+
if (typeof level.Id === "number")
|
|
107
|
+
map.set(level.Id, level.Name);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
for (const criterion of rubric.Criteria ?? []) {
|
|
111
|
+
for (const level of criterion.Levels ?? []) {
|
|
112
|
+
const id = level.Id ?? level.LevelId;
|
|
113
|
+
if (typeof id === "number")
|
|
114
|
+
map.set(id, level.Name);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return map;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The student's own graded outcome on this rubric, resolved against the
|
|
121
|
+
* rubric's own criterion/level names rather than re-sent by the assessment.
|
|
122
|
+
*/
|
|
123
|
+
function toOutputAssessment(rubric, assessment) {
|
|
124
|
+
const criterionNames = criterionNamesOf(rubric);
|
|
125
|
+
const levelNames = levelNamesOf(rubric);
|
|
126
|
+
const out = { criteria: [] };
|
|
127
|
+
// Documented Dropbox RubricAssessment carries OverallScore/OverallLevel/
|
|
128
|
+
// OverallFeedback instead of a nested OverallOutcome; fall back to those
|
|
129
|
+
// when OverallOutcome itself is absent.
|
|
130
|
+
const overall = assessment.OverallOutcome ?? {
|
|
131
|
+
Score: assessment.OverallScore,
|
|
132
|
+
LevelId: assessment.OverallLevel,
|
|
133
|
+
Feedback: assessment.OverallFeedback,
|
|
134
|
+
};
|
|
135
|
+
if (overall) {
|
|
136
|
+
if (typeof overall.Score === "number")
|
|
137
|
+
out.score = overall.Score;
|
|
138
|
+
const levelName = overall.LevelId != null ? levelNames.get(overall.LevelId) : undefined;
|
|
139
|
+
if (levelName)
|
|
140
|
+
out.levelName = levelName;
|
|
141
|
+
const feedback = textOf(overall.Feedback);
|
|
142
|
+
if (feedback)
|
|
143
|
+
out.feedback = feedback;
|
|
144
|
+
}
|
|
145
|
+
out.criteria = (assessment.CriteriaOutcome ?? []).map((outcome) => {
|
|
146
|
+
const entry = {
|
|
147
|
+
criterionName: criterionNames.get(outcome.CriterionId) ?? `Criterion ${outcome.CriterionId}`,
|
|
148
|
+
};
|
|
149
|
+
const levelName = outcome.LevelId != null ? levelNames.get(outcome.LevelId) : undefined;
|
|
150
|
+
if (levelName)
|
|
151
|
+
entry.levelName = levelName;
|
|
152
|
+
if (typeof outcome.Score === "number")
|
|
153
|
+
entry.score = outcome.Score;
|
|
154
|
+
const feedback = textOf(outcome.Feedback);
|
|
155
|
+
if (feedback)
|
|
156
|
+
entry.feedback = feedback;
|
|
157
|
+
return entry;
|
|
158
|
+
});
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
function toOutputRubric(rubric, assessment) {
|
|
162
|
+
const criteriaGroups = criteriaGroupsOf(rubric);
|
|
163
|
+
const out = {
|
|
164
|
+
rubricId: rubric.RubricId,
|
|
165
|
+
name: rubric.Name,
|
|
166
|
+
criteriaGroups,
|
|
167
|
+
};
|
|
168
|
+
const description = textOf(rubric.Description);
|
|
169
|
+
if (description)
|
|
170
|
+
out.description = description;
|
|
171
|
+
if (typeof rubric.ScoringMethod === "number") {
|
|
172
|
+
out.scoringMethod = rubric.ScoringMethod;
|
|
173
|
+
const name = SCORING_METHOD_NAMES[rubric.ScoringMethod];
|
|
174
|
+
if (name)
|
|
175
|
+
out.scoringMethodName = name;
|
|
176
|
+
}
|
|
177
|
+
const totalPoints = totalPointsOf(criteriaGroups);
|
|
178
|
+
if (totalPoints !== undefined)
|
|
179
|
+
out.totalPoints = totalPoints;
|
|
180
|
+
if (assessment)
|
|
181
|
+
out.rubricAssessment = toOutputAssessment(rubric, assessment);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Fetch every rubric attached to a dropbox assignment, with the student's own
|
|
186
|
+
* graded outcome merged in where it is available.
|
|
187
|
+
*
|
|
188
|
+
* Rubrics normally ride along embedded in the folder's own
|
|
189
|
+
* `Assessment.Rubrics`; a tenant/version that omits them there is asked
|
|
190
|
+
* directly via the rubrics listing endpoint. The student's outcome comes from
|
|
191
|
+
* `myFeedback`'s `RubricAssessments` — the same route get-assignments.ts
|
|
192
|
+
* already calls for grade/feedback — never from the unstable rubric
|
|
193
|
+
* assessment route, which this tool does not call.
|
|
194
|
+
*/
|
|
195
|
+
export async function fetchAssignmentRubrics(apiClient, courseId, assignmentId) {
|
|
196
|
+
let folder;
|
|
197
|
+
try {
|
|
198
|
+
folder = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${assignmentId}`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
199
|
+
}
|
|
200
|
+
catch (error) {
|
|
201
|
+
if (error instanceof ApiError && error.status === 403) {
|
|
202
|
+
return {
|
|
203
|
+
rubrics: [],
|
|
204
|
+
note: "You do not have permission to view this assignment.",
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (error instanceof ApiError && error.status === 404) {
|
|
208
|
+
return {
|
|
209
|
+
rubrics: [],
|
|
210
|
+
note: "This assignment was not found. It may not exist or may have been removed.",
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
throw error;
|
|
214
|
+
}
|
|
215
|
+
let rubrics = folder.Assessment?.Rubrics ?? [];
|
|
216
|
+
if (rubrics.length === 0) {
|
|
217
|
+
try {
|
|
218
|
+
const raw = await apiClient.get(apiClient.le(courseId, `/rubrics?objectType=Dropbox&objectId=${assignmentId}`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
219
|
+
rubrics = Array.isArray(raw) ? raw : raw?.Objects ?? [];
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
|
|
223
|
+
rubrics = [];
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
log("DEBUG", `Failed to fetch rubrics for assignment ${assignmentId} via the rubrics endpoint`, error);
|
|
227
|
+
rubrics = [];
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (rubrics.length === 0) {
|
|
232
|
+
return { rubrics: [], note: "This assignment has no rubric attached." };
|
|
233
|
+
}
|
|
234
|
+
// Best-effort: the student's outcome is a bonus, not a requirement for the
|
|
235
|
+
// rubric table itself, and a course that hides feedback should not turn
|
|
236
|
+
// "here's how it's graded" into an error.
|
|
237
|
+
const assessmentsByRubricId = new Map();
|
|
238
|
+
try {
|
|
239
|
+
const feedback = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${assignmentId}/feedback/myFeedback/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
|
|
240
|
+
for (const assessment of feedback?.RubricAssessments ?? []) {
|
|
241
|
+
if (typeof assessment?.RubricId === "number") {
|
|
242
|
+
assessmentsByRubricId.set(assessment.RubricId, assessment);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
if (!(error instanceof ApiError && (error.status === 403 || error.status === 404))) {
|
|
248
|
+
log("DEBUG", `Failed to fetch rubric assessment feedback for assignment ${assignmentId}`, error);
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return {
|
|
253
|
+
rubrics: rubrics.map((r) => toOutputRubric(r, assessmentsByRubricId.get(r.RubricId))),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Register get_assignment_rubric tool
|
|
258
|
+
*/
|
|
259
|
+
export function registerGetAssignmentRubric(server, apiClient) {
|
|
260
|
+
server.registerTool("get_assignment_rubric", {
|
|
261
|
+
title: "Get Assignment Rubric",
|
|
262
|
+
description: "Fetch the full grading rubric for a dropbox assignment: every criteria group, criterion, and achievement level with its points and description, plus the student's own graded outcome per criterion once it has been released. Use this when the user asks what a rubric wants, how an assignment will be graded, or why they got a particular score on a criterion.",
|
|
263
|
+
inputSchema: GetAssignmentRubricSchema,
|
|
264
|
+
}, async (args) => {
|
|
265
|
+
try {
|
|
266
|
+
log("DEBUG", "get_assignment_rubric tool called", { args });
|
|
267
|
+
const { courseId, assignmentId } = GetAssignmentRubricSchema.parse(args);
|
|
268
|
+
const result = await fetchAssignmentRubrics(apiClient, courseId, assignmentId);
|
|
269
|
+
log("INFO", `get_assignment_rubric: Retrieved ${result.rubrics.length} rubric(s) for assignment ${assignmentId} in course ${courseId}`);
|
|
270
|
+
return toolResponse({ courseId, assignmentId, ...result });
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
return sanitizeError(error);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purdue Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { ApiError } from "../api/errors.js";
|
|
8
|
+
import { GetMyGroupsSchema } from "./schemas.js";
|
|
9
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
10
|
+
import { fetchClasslistUsers } from "./get-roster.js";
|
|
11
|
+
import { log } from "../utils/logger.js";
|
|
12
|
+
/** True when a group/category endpoint simply isn't there for this course or user. */
|
|
13
|
+
function isMissingOrForbidden(error) {
|
|
14
|
+
return error instanceof ApiError && (error.status === 403 || error.status === 404);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Register get_my_groups tool
|
|
18
|
+
*/
|
|
19
|
+
export function registerGetMyGroups(server, apiClient) {
|
|
20
|
+
server.registerTool("get_my_groups", {
|
|
21
|
+
title: "Get My Groups",
|
|
22
|
+
description: "List the current user's project/discussion groups in a course, with each group's members. " +
|
|
23
|
+
"Use this when a student asks who is in their project group, lab group, or discussion group.",
|
|
24
|
+
inputSchema: GetMyGroupsSchema,
|
|
25
|
+
}, async (args) => {
|
|
26
|
+
try {
|
|
27
|
+
log("DEBUG", "get_my_groups tool called", { args });
|
|
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
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (matches.length === 0) {
|
|
74
|
+
log("INFO", `get_my_groups: User is not a member of any group in course ${courseId}`);
|
|
75
|
+
return toolResponse({
|
|
76
|
+
courseId,
|
|
77
|
+
groups: [],
|
|
78
|
+
note: "Group categories exist for this course, but the current user is not a member of any group.",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
// Best-effort classlist for member names. A course where the current
|
|
82
|
+
// user lacks classlist access still returns groups, just with
|
|
83
|
+
// member names left null instead of failing the whole request.
|
|
84
|
+
// Identifier comes back from D2L as a string on real tenants, even
|
|
85
|
+
// though it looks numeric, so it must be coerced before it can be
|
|
86
|
+
// used as a key alongside the numeric Enrollments ids.
|
|
87
|
+
let classlistById = new Map();
|
|
88
|
+
try {
|
|
89
|
+
const classlist = await fetchClasslistUsers(apiClient, courseId);
|
|
90
|
+
classlistById = new Map(classlist.map((u) => [Number(u.Identifier), u]));
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
if (isMissingOrForbidden(error)) {
|
|
94
|
+
log("INFO", "get_my_groups: Classlist not accessible, member names will be null", { courseId });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const groups = matches.map(({ category, group }) => {
|
|
101
|
+
const enrollments = group.Enrollments ?? [];
|
|
102
|
+
const members = enrollments.map((userId) => ({
|
|
103
|
+
userId,
|
|
104
|
+
name: classlistById.get(userId)?.DisplayName ?? null,
|
|
105
|
+
}));
|
|
106
|
+
return {
|
|
107
|
+
categoryId: category.GroupCategoryId,
|
|
108
|
+
categoryName: category.Name,
|
|
109
|
+
groupId: group.GroupId,
|
|
110
|
+
groupName: group.Name,
|
|
111
|
+
...(group.Code ? { groupCode: group.Code } : {}),
|
|
112
|
+
members,
|
|
113
|
+
};
|
|
114
|
+
});
|
|
115
|
+
log("INFO", `get_my_groups: Found ${groups.length} groups for course ${courseId}`);
|
|
116
|
+
return toolResponse({ courseId, groups });
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
return sanitizeError(error);
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -14,9 +14,13 @@ import { log } from "../utils/logger.js";
|
|
|
14
14
|
const INSTRUCTOR_ROLE_ID = 109;
|
|
15
15
|
const TA_ROLE_ID = 135;
|
|
16
16
|
/**
|
|
17
|
-
* Fetch every classlist user matching the optional filters, across all pages
|
|
17
|
+
* Fetch every classlist user matching the optional filters, across all pages.
|
|
18
|
+
*
|
|
19
|
+
* Exported so other tools that need names for a course's enrolled users
|
|
20
|
+
* (e.g. get_my_groups resolving group membership) can reuse this instead of
|
|
21
|
+
* re-implementing the paged classlist fetch.
|
|
18
22
|
*/
|
|
19
|
-
async function fetchClasslistUsers(apiClient, courseId, options) {
|
|
23
|
+
export async function fetchClasslistUsers(apiClient, courseId, options) {
|
|
20
24
|
const params = new URLSearchParams();
|
|
21
25
|
if (options?.roleId !== undefined) {
|
|
22
26
|
params.append("roleId", options.roleId.toString());
|
package/build/tools/index.js
CHANGED
|
@@ -10,12 +10,14 @@ export { registerGetCalendarEvents } from "./get-calendar-events.js";
|
|
|
10
10
|
export { registerGetMyGrades } from "./get-my-grades.js";
|
|
11
11
|
export { registerGetAnnouncements } from "./get-announcements.js";
|
|
12
12
|
export { registerGetAssignments } from "./get-assignments.js";
|
|
13
|
+
export { registerGetAssignmentRubric } from "./get-assignment-rubric.js";
|
|
13
14
|
export { registerGetAssignmentFiles } from "./get-assignment-files.js";
|
|
14
15
|
export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
|
|
15
16
|
export { registerGetCourseContent } from "./get-course-content.js";
|
|
16
17
|
export { registerDownloadFile } from "./download-file.js";
|
|
17
18
|
export { registerGetClasslistEmails } from "./get-classlist-emails.js";
|
|
18
19
|
export { registerGetRoster } from "./get-roster.js";
|
|
20
|
+
export { registerGetMyGroups } from "./get-my-groups.js";
|
|
19
21
|
export { registerGetSyllabus } from "./get-syllabus.js";
|
|
20
22
|
export { registerGetDiscussions } from "./get-discussions.js";
|
|
21
23
|
export { registerGetVideoTranscript } from "./get-video-transcript.js";
|
package/build/tools/schemas.js
CHANGED
|
@@ -137,4 +137,14 @@ export const GetRosterSchema = z.object({
|
|
|
137
137
|
limit: z.coerce.number().int().positive().max(1000).default(100)
|
|
138
138
|
.describe("Maximum users to return. Default 100. The response reports the true total and whether it was truncated."),
|
|
139
139
|
});
|
|
140
|
+
export const GetAssignmentRubricSchema = z.object({
|
|
141
|
+
courseId: z.coerce.number().int().positive()
|
|
142
|
+
.describe("Course ID the assignment belongs to."),
|
|
143
|
+
assignmentId: z.coerce.number().int().positive()
|
|
144
|
+
.describe("Assignment (dropbox folder) ID, as returned by get_assignments."),
|
|
145
|
+
});
|
|
146
|
+
export const GetMyGroupsSchema = z.object({
|
|
147
|
+
courseId: z.coerce.number().int().positive()
|
|
148
|
+
.describe("Course ID to list the current user's project/discussion groups for."),
|
|
149
|
+
});
|
|
140
150
|
export const GetServerInfoSchema = z.object({});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.6",
|
|
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",
|