brightspace-mcp-server 3.9.4 → 3.9.5

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 CHANGED
@@ -172,6 +172,7 @@ If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.j
172
172
  | 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
173
  | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "What's new in this course since I last checked?" |
174
174
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
175
+ | Groups | "Who is in my project group?" · "List my lab group members for this course" |
175
176
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
176
177
  | 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
178
  | 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, 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);
@@ -128,11 +128,12 @@ else {
128
128
  registerDownloadFile(server, apiClient);
129
129
  registerGetClasslistEmails(server, apiClient);
130
130
  registerGetRoster(server, apiClient);
131
+ registerGetMyGroups(server, apiClient);
131
132
  registerGetSyllabus(server, apiClient);
132
133
  registerGetDiscussions(server, apiClient);
133
134
  registerGetVideoTranscript(server, apiClient);
134
135
  registerGetServerInfo(server, config, PKG_VERSION, apiClient);
135
- log("DEBUG", "MCP tools registered (16 tools)");
136
+ log("DEBUG", "MCP tools registered (17 tools)");
136
137
  // Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
137
138
  // picker, distinct from tools. Each one is a canned user message that
138
139
  // names our own tools by name, so a client with no idea what to ask
@@ -145,7 +146,7 @@ else {
145
146
  // Connect stdio transport
146
147
  const transport = new StdioServerTransport();
147
148
  await server.connect(transport);
148
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (16 tools, 4 prompts registered)");
149
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (17 tools, 4 prompts registered)");
149
150
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
150
151
  }
151
152
  catch (error) {
@@ -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());
@@ -16,6 +16,7 @@ export { registerGetCourseContent } from "./get-course-content.js";
16
16
  export { registerDownloadFile } from "./download-file.js";
17
17
  export { registerGetClasslistEmails } from "./get-classlist-emails.js";
18
18
  export { registerGetRoster } from "./get-roster.js";
19
+ export { registerGetMyGroups } from "./get-my-groups.js";
19
20
  export { registerGetSyllabus } from "./get-syllabus.js";
20
21
  export { registerGetDiscussions } from "./get-discussions.js";
21
22
  export { registerGetVideoTranscript } from "./get-video-transcript.js";
@@ -137,4 +137,8 @@ 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 GetMyGroupsSchema = z.object({
141
+ courseId: z.coerce.number().int().positive()
142
+ .describe("Course ID to list the current user's project/discussion groups for."),
143
+ });
140
144
  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.4",
3
+ "version": "3.9.5",
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",