brightspace-mcp-server 5.1.35 → 5.1.37

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.
@@ -225,7 +225,7 @@ function descendantPids(parentPid) {
225
225
  function forceStop(pid) {
226
226
  if (process.platform === "win32") {
227
227
  try {
228
- execFileSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5000 });
228
+ execFileSync("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", timeout: 5000, windowsHide: true });
229
229
  }
230
230
  catch { /* Already exited. */ }
231
231
  return;
@@ -900,7 +900,7 @@ export class AuthRunner {
900
900
  try {
901
901
  if (child.pid && process.platform === "win32") {
902
902
  execFileSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], {
903
- stdio: "ignore", timeout: 5000,
903
+ stdio: "ignore", timeout: 5000, windowsHide: true,
904
904
  });
905
905
  }
906
906
  else if (child.pid) {
@@ -10,7 +10,7 @@ import * as os from "node:os";
10
10
  import { SessionStoreError } from "../utils/errors.js";
11
11
  import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
12
12
  import { hasSessionEncryptionKey, NativeCredentialStoreError } from "./credential-store.js";
13
- import { decrypt, readEncryptedRecord, saveEncryptedRecord, trashFile } from "./encrypted-store.js";
13
+ import { decrypt, readEncryptedRecord, saveEncryptedRecord } from "./encrypted-store.js";
14
14
  const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".d2l-session");
15
15
  /** The saved access token, relative to its session directory. */
16
16
  export const SESSION_FILE = "session.json";
@@ -223,6 +223,11 @@ export class SessionStore {
223
223
  this.storeError("load", error);
224
224
  }
225
225
  }
226
+ /**
227
+ * Delete the saved session once it reads back. Not moved to Trash: nothing
228
+ * restores a session, and on Windows Trash's console helper opens a terminal
229
+ * window from the shared runtime, which has no console to run it in.
230
+ */
226
231
  async clear() {
227
232
  try {
228
233
  await this.withWriteLock(async () => {
@@ -230,7 +235,7 @@ export class SessionStore {
230
235
  if (!current)
231
236
  return;
232
237
  await this.decode(current);
233
- await trashFile(this.sessionFilePath, this.options);
238
+ await fs.rm(this.sessionFilePath, { force: true });
234
239
  });
235
240
  }
236
241
  catch (error) {
@@ -281,7 +286,7 @@ export class SessionStore {
281
286
  return await this.withWriteLock(async () => {
282
287
  if (!equalToken(await this.loadUnlocked(), expected))
283
288
  return false;
284
- await trashFile(this.sessionFilePath, this.options);
289
+ await fs.rm(this.sessionFilePath, { force: true });
285
290
  return true;
286
291
  });
287
292
  }
package/build/index.js CHANGED
@@ -138,7 +138,7 @@ else {
138
138
  const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
139
139
  const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
140
140
  stopServing = running.close;
141
- log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (26 tools, 4 prompts registered)`);
141
+ log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (28 tools, 4 prompts registered)`);
142
142
  log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
143
143
  }
144
144
  catch (error) {
package/build/server.js CHANGED
@@ -9,7 +9,7 @@ import { D2LApiClient } from "./api/index.js";
9
9
  import { trackToolActivity } from "./utils/dev-activity.js";
10
10
  import { log } from "./utils/logger.js";
11
11
  import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
12
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
12
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
13
13
  import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
14
14
  /**
15
15
  * Build the shared runtime. Nothing here reaches Brightspace: API versions
@@ -88,6 +88,7 @@ export function createMcpServer(runtime) {
88
88
  registerGetClasslistEmails(server, apiClient);
89
89
  registerGetRoster(server, apiClient);
90
90
  registerGetMyGroups(server, apiClient);
91
+ registerGetAvailableGroups(server, apiClient, config);
91
92
  registerGetSyllabus(server, apiClient);
92
93
  registerGetDiscussions(server, apiClient, config.baseUrl);
93
94
  registerGetVideoTranscript(server, apiClient);
@@ -102,7 +103,7 @@ export function createMcpServer(runtime) {
102
103
  registerGetDropboxFeedback(server, apiClient);
103
104
  registerGetRubricsForObject(server, apiClient);
104
105
  registerDownloadDropboxSubmissionFile(server, apiClient);
105
- log("DEBUG", "MCP tools registered (26 tools)");
106
+ log("DEBUG", "MCP tools registered (28 tools)");
106
107
  // Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
107
108
  // picker, distinct from tools. Each one is a canned user message that
108
109
  // names our own tools by name, so a client with no idea what to ask
@@ -0,0 +1,166 @@
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 { GetAvailableGroupsSchema } from "./schemas.js";
9
+ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
10
+ import { fetchCurrentUserId } from "./group-membership.js";
11
+ import { resolveCourses } from "./resolve-courses.js";
12
+ import { log } from "../utils/logger.js";
13
+ /** D2L's GRPENROLL_T values whose groups students pick themselves. */
14
+ const SELF_ENROLLMENT_STYLES = new Set([3, 4, 5]);
15
+ /** Without a courseId, courses that started longer ago than this are skipped unless includeOlder is set. */
16
+ const RECENT_COURSE_MS = 365 * 24 * 60 * 60 * 1000;
17
+ const inaccessible = (error) => error instanceof ApiError && (error.status === 403 || error.status === 404);
18
+ const text = (value) => value?.Text?.trim() || undefined;
19
+ /**
20
+ * Brightspace shows a category on the "Available Groups" page while its
21
+ * sign-up window is open; a missing start or expiry date leaves that side open.
22
+ */
23
+ function signUpOpen(category, now) {
24
+ if (!SELF_ENROLLMENT_STYLES.has(category.EnrollmentStyle))
25
+ return false;
26
+ const start = category.SelfEnrollmentStartDate ? Date.parse(category.SelfEnrollmentStartDate) : -Infinity;
27
+ const end = category.SelfEnrollmentExpiryDate ? Date.parse(category.SelfEnrollmentExpiryDate) : Infinity;
28
+ return start <= now && now <= end;
29
+ }
30
+ async function fetchCourseGroups(apiClient, course, myId, now) {
31
+ const result = { courseId: course.id, courseName: course.name, categories: [], alreadyInGroup: [] };
32
+ let categories;
33
+ try {
34
+ categories = await apiClient.get(apiClient.lp(`/${course.id}/groupcategories/`), { ttl: DEFAULT_CACHE_TTLS.roster });
35
+ }
36
+ catch (error) {
37
+ if (inaccessible(error))
38
+ return result;
39
+ throw error;
40
+ }
41
+ const unread = [];
42
+ for (const category of categories.filter((c) => signUpOpen(c, now))) {
43
+ let groups;
44
+ try {
45
+ groups = await apiClient.get(apiClient.lp(`/${course.id}/groupcategories/${category.GroupCategoryId}/groups/`), { ttl: DEFAULT_CACHE_TTLS.roster });
46
+ }
47
+ catch (error) {
48
+ if (inaccessible(error)) {
49
+ unread.push(category.Name);
50
+ continue;
51
+ }
52
+ throw error;
53
+ }
54
+ // Self-enrollment allows one group per category, as on the Available Groups page.
55
+ const mine = groups.find((g) => g.Enrollments?.some((id) => Number(id) === myId));
56
+ if (mine) {
57
+ result.alreadyInGroup.push({ categoryId: category.GroupCategoryId, name: category.Name, groupName: mine.Name });
58
+ continue;
59
+ }
60
+ const max = category.MaxUsersPerGroup ?? null;
61
+ const showDescriptions = category.DescriptionsVisibleToEnrolees !== false;
62
+ const open = groups
63
+ .map((g) => {
64
+ const members = g.Enrollments?.length ?? 0;
65
+ const description = showDescriptions ? text(g.Description) : undefined;
66
+ return {
67
+ groupId: g.GroupId,
68
+ name: g.Name,
69
+ members,
70
+ spotsLeft: max === null ? null : Math.max(0, max - members),
71
+ ...(description ? { description } : {}),
72
+ };
73
+ })
74
+ .filter((g) => g.spotsLeft === null || g.spotsLeft > 0);
75
+ if (open.length === 0)
76
+ continue;
77
+ const description = text(category.Description);
78
+ result.categories.push({
79
+ categoryId: category.GroupCategoryId,
80
+ name: category.Name,
81
+ ...(description ? { description } : {}),
82
+ signUpCloses: category.SelfEnrollmentExpiryDate ?? null,
83
+ maxPerGroup: max,
84
+ groups: open,
85
+ });
86
+ }
87
+ if (unread.length > 0)
88
+ result.unreadCategories = unread;
89
+ return result;
90
+ }
91
+ /**
92
+ * Register get_available_groups tool
93
+ */
94
+ export function registerGetAvailableGroups(server, apiClient, config) {
95
+ server.registerTool("get_available_groups", {
96
+ title: "Get Available Groups",
97
+ annotations: { readOnlyHint: true },
98
+ description: "List the groups the current user can still join themselves (self-enrollment), with spots left " +
99
+ "and when sign-up closes. Use this when a student asks which project or lab groups are open. " +
100
+ "This tool only reads; joining happens in Brightspace under Groups > Available Groups.",
101
+ inputSchema: GetAvailableGroupsSchema,
102
+ }, async (args) => {
103
+ try {
104
+ log("DEBUG", "get_available_groups tool called", { args });
105
+ const { courseId, includeOlder } = GetAvailableGroupsSchema.parse(args);
106
+ const now = Date.now();
107
+ const myId = await fetchCurrentUserId(apiClient);
108
+ const enrolled = await resolveCourses(apiClient, config, courseId);
109
+ // Some schools give courses a start date but never an end date and never
110
+ // close them, so every past course still counts as active and its old
111
+ // sign-ups without an expiry still look open. The start date is the only
112
+ // signal of which courses are current.
113
+ const courses = courseId || includeOlder ? enrolled : enrolled.filter((course) => {
114
+ const start = course.startDate ? Date.parse(course.startDate) : NaN;
115
+ return Number.isFinite(start) && start >= now - RECENT_COURSE_MS;
116
+ });
117
+ const olderSkipped = enrolled.length - courses.length;
118
+ const results = await Promise.allSettled(courses.map((course) => fetchCourseGroups(apiClient, course, myId, now)));
119
+ const pendingCourseIds = [];
120
+ const failedCourseIds = [];
121
+ let firstAuthError = null;
122
+ const found = results.flatMap((result, i) => {
123
+ if (result.status === "fulfilled")
124
+ return [result.value];
125
+ if (isAuthUnavailable(result.reason)) {
126
+ if (courseId)
127
+ throw result.reason;
128
+ pendingCourseIds.push(courses[i].id);
129
+ firstAuthError ??= result.reason;
130
+ }
131
+ else {
132
+ if (courseId)
133
+ throw result.reason;
134
+ failedCourseIds.push(courses[i].id);
135
+ log("DEBUG", "get_available_groups: skipping course after fetch failure", result.reason);
136
+ }
137
+ return [];
138
+ });
139
+ // A course with nothing open is left out so the list stays about choices.
140
+ const withChoices = found.filter((c) => c.categories.length > 0 || c.alreadyInGroup.length > 0 || c.unreadCategories);
141
+ const openGroups = withChoices.reduce((n, c) => n + c.categories.reduce((m, cat) => m + cat.groups.length, 0), 0);
142
+ log("INFO", `get_available_groups: ${openGroups} open groups across ${courses.length} course(s)`);
143
+ const notes = [];
144
+ if (withChoices.length === 0)
145
+ notes.push("No group sign-ups are open in these courses right now.");
146
+ if (olderSkipped > 0) {
147
+ notes.push(`Checked the courses that started in the last 12 months; pass includeOlder: true to also check the other ${olderSkipped}.`);
148
+ }
149
+ if (failedCourseIds.length > 0) {
150
+ notes.push(`Couldn't read groups for course(s) ${failedCourseIds.join(", ")}, so open sign-ups there aren't listed.`);
151
+ }
152
+ const data = {
153
+ coursesChecked: courses.length,
154
+ courses: withChoices,
155
+ ...(notes.length > 0 ? { note: notes.join(" ") } : {}),
156
+ ...(pendingCourseIds.length > 0 ? { authPending: true, unavailableCourseIds: pendingCourseIds } : {}),
157
+ };
158
+ return pendingCourseIds.length > 0
159
+ ? toolResponseWithNotice(data, authPendingNotice(firstAuthError))
160
+ : toolResponse(data);
161
+ }
162
+ catch (error) {
163
+ return sanitizeError(error);
164
+ }
165
+ });
166
+ }
@@ -6,7 +6,7 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { fetchAllObjects } from "../api/paginate.js";
8
8
  import { GetClasslistEmailsSchema } from "./schemas.js";
9
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { toolResponse, toolResponseWithNotice, sanitizeError } from "./tool-helpers.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  /**
12
12
  * Register get_classlist_emails tool
@@ -37,6 +37,16 @@ export function registerGetClasslistEmails(server, apiClient) {
37
37
  role: user.ClasslistRoleDisplayName,
38
38
  }));
39
39
  log("INFO", `get_classlist_emails: ${emails.length} emails from ${users.length} users in course ${courseId}`);
40
+ // People whose email Brightspace hides are left out of the list. Say
41
+ // so, or a partial list reads as the whole class and an all-hidden
42
+ // course reads as an empty one.
43
+ const hidden = users.length - emails.length;
44
+ if (hidden > 0) {
45
+ const who = hidden === 1 ? "person" : "people";
46
+ const has = hidden === 1 ? "has" : "have";
47
+ return toolResponseWithNotice(emails, `${hidden} of ${users.length} ${who} in this course ${has} no email visible to you ` +
48
+ `(Brightspace hides it), so they are not listed.`);
49
+ }
40
50
  return toolResponse(emails);
41
51
  }
42
52
  catch (error) {
@@ -5,10 +5,28 @@
5
5
  */
6
6
  import { ApiError } from "../api/errors.js";
7
7
  import { GetMyGroupsSchema } from "./schemas.js";
8
- import { toolResponse, sanitizeError } from "./tool-helpers.js";
8
+ import { toolResponse, sanitizeError, isAuthUnavailable } from "./tool-helpers.js";
9
9
  import { fetchClasslistUsers } from "./get-roster.js";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { fetchMyGroupMemberships } from "./group-membership.js";
12
+ import { decodeEntities } from "../utils/html-attributes.js";
13
+ /**
14
+ * Names on a group's "View Members" page (group_member_list.d2l), or null when
15
+ * the page doesn't have the member table. Each data row holds one name in a
16
+ * single cell; the header row is the only one with a class.
17
+ */
18
+ export function memberNamesFromPage(html) {
19
+ const table = /<table\b[^>]*\bd2l-grid\b[^>]*>([\s\S]*?)<\/table>/i.exec(html);
20
+ if (!table)
21
+ return null;
22
+ const names = [];
23
+ for (const row of table[1].matchAll(/<tr>\s*<td\b[^>]*>([\s\S]*?)<\/td>\s*<\/tr>/gi)) {
24
+ const name = decodeEntities(row[1].replace(/<[^>]+>/g, "")).replace(/\s+/g, " ").trim();
25
+ if (name)
26
+ names.push(name);
27
+ }
28
+ return names;
29
+ }
12
30
  /** True when a group/category endpoint simply isn't there for this course or user. */
13
31
  function isMissingOrForbidden(error) {
14
32
  return error instanceof ApiError && (error.status === 403 || error.status === 404);
@@ -76,6 +94,25 @@ export function registerGetMyGroups(server, apiClient) {
76
94
  throw error;
77
95
  }
78
96
  }
97
+ // Students who can't read the classlist can still open each of their
98
+ // groups' "View Members" page, so read the names from there. The page
99
+ // carries no user ids, so the names stay a separate list.
100
+ const pageNames = new Map();
101
+ if (namesUnavailable) {
102
+ for (const { group } of matches) {
103
+ try {
104
+ const html = await apiClient.getPage(`/d2l/lms/group/group_member_list.d2l?ou=${courseId}&groupId=${group.GroupId}`);
105
+ const names = html === null ? null : memberNamesFromPage(html);
106
+ if (names)
107
+ pageNames.set(group.GroupId, names);
108
+ }
109
+ catch (error) {
110
+ if (isAuthUnavailable(error))
111
+ throw error;
112
+ log("DEBUG", `get_my_groups: Couldn't read the member page for group ${group.GroupId}`, error);
113
+ }
114
+ }
115
+ }
79
116
  const groups = matches.map(({ category, group }) => {
80
117
  const enrollments = group.Enrollments ?? [];
81
118
  const members = enrollments.map((userId) => ({
@@ -89,15 +126,19 @@ export function registerGetMyGroups(server, apiClient) {
89
126
  groupName: group.Name,
90
127
  ...(group.Code ? { groupCode: group.Code } : {}),
91
128
  members,
129
+ ...(pageNames.has(group.GroupId) ? { memberNames: pageNames.get(group.GroupId) } : {}),
92
130
  };
93
131
  });
94
132
  log("INFO", `get_my_groups: Found ${groups.length} groups for course ${courseId}`);
95
133
  // Null names are only meaningful with a reason attached: without one,
96
134
  // a caller can't tell "the classlist couldn't be read" from "these
97
135
  // members have no names", and may report the group as anonymous.
98
- const namesNote = namesUnavailable
99
- ? "Couldn't read this course's classlist, so member names are null; only user ids are listed."
100
- : undefined;
136
+ const namesNote = !namesUnavailable ? undefined
137
+ : pageNames.size === matches.length
138
+ ? "Couldn't read this course's classlist, so member names are null; memberNames lists each group's " +
139
+ "members from its member page, which doesn't say which name belongs to which user id."
140
+ : "Couldn't read this course's classlist, so member names are null; only user ids are listed" +
141
+ (pageNames.size > 0 ? ", except where memberNames lists a group's members from its member page." : ".");
101
142
  const note = [unreadNote, namesNote].filter(Boolean).join(" ") || undefined;
102
143
  return toolResponse({ courseId, groups, ...(note ? { note } : {}) });
103
144
  }
@@ -18,6 +18,7 @@ export { registerDownloadFile, COURSE_FILE_NEEDS_COOKIE } from "./download-file.
18
18
  export { registerGetClasslistEmails } from "./get-classlist-emails.js";
19
19
  export { registerGetRoster } from "./get-roster.js";
20
20
  export { registerGetMyGroups } from "./get-my-groups.js";
21
+ export { registerGetAvailableGroups } from "./get-available-groups.js";
21
22
  export { registerGetSyllabus } from "./get-syllabus.js";
22
23
  export { registerGetDiscussions } from "./get-discussions.js";
23
24
  export { registerGetVideoTranscript } from "./get-video-transcript.js";
@@ -33,7 +33,7 @@ export async function resolveCourses(apiClient, config, courseId) {
33
33
  }
34
34
  if (courseId) {
35
35
  const match = items.find((item) => item.OrgUnit.Id === courseId);
36
- return [{ id: courseId, name: match?.OrgUnit.Name ?? null }];
36
+ return [{ id: courseId, name: match?.OrgUnit.Name ?? null, startDate: match?.Access.StartDate ?? null }];
37
37
  }
38
38
  const filtered = applyCourseFilter(items.map((item) => ({
39
39
  id: item.OrgUnit.Id,
@@ -44,5 +44,5 @@ export async function resolveCourses(apiClient, config, courseId) {
44
44
  startDate: item.Access.StartDate,
45
45
  endDate: item.Access.EndDate,
46
46
  })), config.courseFilter);
47
- return filtered.map((course) => ({ id: course.id, name: course.name }));
47
+ return filtered.map((course) => ({ id: course.id, name: course.name, startDate: course.startDate }));
48
48
  }
@@ -160,6 +160,12 @@ export const GetMyGroupsSchema = z.object({
160
160
  courseId: z.coerce.number().int().positive()
161
161
  .describe("Course ID to list the current user's project/discussion groups for."),
162
162
  });
163
+ export const GetAvailableGroupsSchema = z.object({
164
+ courseId: z.coerce.number().int().positive().optional()
165
+ .describe("Course ID to list joinable groups for. If omitted, checks courses that started in the last 12 months."),
166
+ includeOlder: z.boolean().default(false)
167
+ .describe("Without courseId, also check older and undated courses. Schools that never close a course keep years of finished courses whose sign-ups still look open."),
168
+ });
163
169
  export const GetServerInfoSchema = z.object({});
164
170
  export const SignOutSchema = z.object({});
165
171
  export const SignInSchema = z.object({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.35",
3
+ "version": "5.1.37",
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",