brightspace-mcp-server 3.9.3 → 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,9 +172,10 @@ 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
- | Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, whether a credential is stored, 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 |
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 |
178
179
  | Calendar | "When is my midterm?" · "What's on my calendar this week?" · "Is lab cancelled on Thursday?" — reads exams, labs, review sessions, and deadlines instructors put only on the course calendar |
179
180
  | Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, graded discussion topics (any topic with a due date), and course calendar events such as exams and labs |
180
181
 
@@ -147,7 +147,13 @@ export class BrowserAuth {
147
147
  if (material.cookieHeader && material.csrfToken) {
148
148
  const minted = await mintAccessToken({ baseUrl: this.config.baseUrl, cookieHeader: material.cookieHeader, csrfToken: material.csrfToken });
149
149
  if (minted.ok) {
150
- token = { accessToken: minted.accessToken, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser" };
150
+ // The mint endpoint itself carries no identity. Reuse the same
151
+ // whoami read the other extraction strategies already validate
152
+ // against so a mint-path login reports signedInAs too; a failure
153
+ // here (offline, slow tenant) must not fail a login that already
154
+ // has a good token, so it degrades to "no identity" instead.
155
+ const identity = await this.validateToken(minted.accessToken).catch(() => null);
156
+ token = { accessToken: minted.accessToken, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser", ...identity };
151
157
  }
152
158
  else if (minted.reason === "transport") {
153
159
  throw new BrowserAuthTransportError(`Token mint temporarily failed: ${minted.detail ?? "network unavailable"}. Saved sign-in state is preserved.`);
@@ -157,8 +163,11 @@ export class BrowserAuth {
157
163
  }
158
164
  }
159
165
  token ??= await this.tryExtractToken(page, context);
160
- if (!token && captured && await this.validateToken(captured)) {
161
- token = { accessToken: captured, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser" };
166
+ if (!token && captured) {
167
+ const identity = await this.validateToken(captured);
168
+ if (identity) {
169
+ token = { accessToken: captured, capturedAt: Date.now(), expiresAt: Date.now() + this.config.tokenTtl * 1000, source: "browser", ...identity };
170
+ }
162
171
  }
163
172
  if (!token)
164
173
  throw new BrowserAuthError("Brightspace did not provide a usable API token. Saved SSO cookies have been preserved.", "token_extraction");
@@ -211,31 +220,37 @@ export class BrowserAuth {
211
220
  * Returns null if every strategy fails.
212
221
  */
213
222
  async tryExtractToken(page, context) {
214
- const build = (token) => {
223
+ const build = (token, identity) => {
215
224
  const now = Date.now();
216
225
  return {
217
226
  accessToken: token,
218
227
  capturedAt: now,
219
228
  expiresAt: now + this.config.tokenTtl * 1000,
220
229
  source: "browser",
230
+ ...identity,
221
231
  };
222
232
  };
223
233
  // Strategy 0: localStorage (D2L.Fetch.Tokens) : fastest
224
234
  const lsToken = await this.extractLocalStorageToken(page);
225
- if (lsToken && (await this.validateToken(lsToken))) {
226
- log("INFO", "Extracted valid Bearer token from localStorage");
227
- return build(lsToken);
228
- }
229
- if (lsToken)
235
+ if (lsToken) {
236
+ const identity = await this.validateToken(lsToken);
237
+ if (identity) {
238
+ log("INFO", "Extracted valid Bearer token from localStorage");
239
+ return build(lsToken, identity);
240
+ }
230
241
  log("WARN", "localStorage Bearer token failed validation, trying next strategy");
242
+ }
231
243
  // Strategy 1: Force a Bearer fetch by hitting the API, then re-check localStorage
232
244
  try {
233
245
  log("DEBUG", "Navigating to API endpoint to trigger token capture");
234
246
  await page.goto(`${this.config.baseUrl}/d2l/api/lp/1.57/users/whoami`, { waitUntil: "load", timeout: 15000 });
235
247
  const lsToken2 = await this.extractLocalStorageToken(page);
236
- if (lsToken2 && (await this.validateToken(lsToken2))) {
237
- log("INFO", "Extracted valid Bearer token from localStorage after API nudge");
238
- return build(lsToken2);
248
+ if (lsToken2) {
249
+ const identity = await this.validateToken(lsToken2);
250
+ if (identity) {
251
+ log("INFO", "Extracted valid Bearer token from localStorage after API nudge");
252
+ return build(lsToken2, identity);
253
+ }
239
254
  }
240
255
  }
241
256
  catch (error) {
@@ -245,25 +260,31 @@ export class BrowserAuth {
245
260
  }
246
261
  // Strategy 2: XSRF / page JS context
247
262
  const xsrfToken = await this.extractXsrfToken(page);
248
- if (xsrfToken && (await this.validateToken(xsrfToken))) {
249
- log("INFO", "Extracted valid XSRF token from page context");
250
- return build(xsrfToken);
251
- }
252
- if (xsrfToken)
263
+ if (xsrfToken) {
264
+ const identity = await this.validateToken(xsrfToken);
265
+ if (identity) {
266
+ log("INFO", "Extracted valid XSRF token from page context");
267
+ return build(xsrfToken, identity);
268
+ }
253
269
  log("WARN", "XSRF token failed validation, trying next strategy");
270
+ }
254
271
  // Strategy 3: Cookie-based auth
255
272
  const cookieToken = await this.extractCookieToken(context);
256
- if (cookieToken && (await this.validateToken(cookieToken))) {
257
- log("INFO", "Extracted valid session cookie for API auth");
258
- return build(cookieToken);
259
- }
260
- if (cookieToken)
273
+ if (cookieToken) {
274
+ const identity = await this.validateToken(cookieToken);
275
+ if (identity) {
276
+ log("INFO", "Extracted valid session cookie for API auth");
277
+ return build(cookieToken, identity);
278
+ }
261
279
  log("WARN", "Cookie token failed validation");
280
+ }
262
281
  return null;
263
282
  }
264
283
  /**
265
284
  * Validate a token by making a test API call to /users/whoami.
266
- * Returns true if the token is accepted by D2L, false otherwise.
285
+ * Returns the non-secret identity whoami reported when the token is
286
+ * accepted by D2L (an empty object if the response carried no usable
287
+ * name fields), or null when the token is rejected.
267
288
  */
268
289
  async validateToken(token) {
269
290
  let response;
@@ -279,7 +300,7 @@ export class BrowserAuth {
279
300
  if (response.ok) {
280
301
  const contentType = response.headers.get("content-type") ?? "";
281
302
  if (!contentType.includes("json"))
282
- return false;
303
+ return null;
283
304
  let user;
284
305
  try {
285
306
  user = await response.json();
@@ -287,11 +308,18 @@ export class BrowserAuth {
287
308
  catch (error) {
288
309
  throw new BrowserAuthTransportError("Token validation returned unreadable JSON.", { cause: error });
289
310
  }
290
- const identifier = user?.Identifier;
291
- return (typeof identifier === "string" && identifier.length > 0) || (typeof identifier === "number" && Number.isFinite(identifier));
311
+ const whoami = user;
312
+ const identifier = whoami?.Identifier;
313
+ const valid = (typeof identifier === "string" && identifier.length > 0) || (typeof identifier === "number" && Number.isFinite(identifier));
314
+ if (!valid)
315
+ return null;
316
+ return {
317
+ ...(typeof whoami?.UniqueName === "string" && whoami.UniqueName ? { uniqueName: whoami.UniqueName } : {}),
318
+ ...(typeof whoami?.DisplayName === "string" && whoami.DisplayName ? { displayName: whoami.DisplayName } : {}),
319
+ };
292
320
  }
293
321
  if (response.status === 401 || response.status === 403)
294
- return false;
322
+ return null;
295
323
  throw new BrowserAuthTransportError(`Token validation temporarily failed with HTTP ${response.status}.`);
296
324
  }
297
325
  /** Restore first, and only attempt credentials after a real sign-in is needed. */
@@ -31,6 +31,8 @@ function validToken(value) {
31
31
  && (token.source === "browser" || token.source === "cache")
32
32
  && (token.cookieHeader === undefined || typeof token.cookieHeader === "string")
33
33
  && (token.csrfToken === undefined || typeof token.csrfToken === "string")
34
+ && (token.uniqueName === undefined || typeof token.uniqueName === "string")
35
+ && (token.displayName === undefined || typeof token.displayName === "string")
34
36
  && validTenantOrigin(token.tenantOrigin);
35
37
  }
36
38
  function equalToken(a, b) {
@@ -174,6 +176,28 @@ export class SessionStore {
174
176
  this.storeError("load", error);
175
177
  }
176
178
  }
179
+ /**
180
+ * Read-only sibling of load(): same decode (legacy-upgrade validation and
181
+ * native-key decrypt included), but never takes the write lock and never
182
+ * writes back a migrated version 1 record. load() upgrades a legacy file
183
+ * in place as a side effect of reading it; callers that just want to know
184
+ * "what identity/token is on disk right now" (get_server_info's
185
+ * signedInAs, say) should not silently rewrite session.json or contend
186
+ * with an in-flight sign-in for the write lock just to answer that.
187
+ */
188
+ async peek() {
189
+ try {
190
+ const record = await this.readFile();
191
+ if (!record)
192
+ return null;
193
+ if (record.version === 1)
194
+ await this.assertLegacyUpgradePending();
195
+ return await this.decode(record);
196
+ }
197
+ catch (error) {
198
+ this.storeError("load", error);
199
+ }
200
+ }
177
201
  async clear() {
178
202
  try {
179
203
  await this.withWriteLock(async () => {
@@ -171,6 +171,11 @@ export class TokenManager {
171
171
  source: "browser",
172
172
  cookieHeader: stale.cookieHeader,
173
173
  csrfToken: stale.csrfToken,
174
+ // The cookie mint only returns a JWT, not an identity; carry the
175
+ // uniqueName/displayName the original browser login captured
176
+ // forward so an hourly HTTP re-mint doesn't erase signedInAs.
177
+ uniqueName: stale.uniqueName,
178
+ displayName: stale.displayName,
174
179
  };
175
180
  if (!await this.sessionStore.saveIfCurrent(token, stale))
176
181
  return this.afterConcurrentChange();
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) {
@@ -11,6 +11,7 @@ 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
13
  import { assignmentUrl, gradebookUrl, quizUrl } from "../utils/deep-links.js";
14
+ import { dueIn } from "../utils/due-in.js";
14
15
  /** The display HTML of a quiz rich-text field, nested shape or flat. */
15
16
  function richTextHtml(field) {
16
17
  if (!field)
@@ -165,6 +166,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
165
166
  ? convertHtmlToMarkdown(folder.CustomInstructions.Html).markdown
166
167
  : "",
167
168
  dueDate: folder.DueDate,
169
+ dueIn: dueIn(folder.DueDate),
168
170
  points: folder.Assessment?.ScoreDenominator ?? null,
169
171
  isGroup: folder.GroupTypeId !== null,
170
172
  rubric: folder.Assessment?.Rubrics?.map((r) => ({
@@ -288,6 +290,7 @@ export async function fetchCourseAssignments(apiClient, courseId, baseUrl) {
288
290
  ? convertHtmlToMarkdown(descriptionHtml).markdown
289
291
  : "",
290
292
  dueDate: quiz.DueDate,
293
+ dueIn: dueIn(quiz.DueDate),
291
294
  startDate: quiz.StartDate,
292
295
  endDate: quiz.EndDate,
293
296
  timeLimit: timeLimit?.IsEnforced ? timeLimit.TimeLimitValue : null,
@@ -349,6 +352,7 @@ function gradebookHeadsUp(raw, fetched, courseId, baseUrl) {
349
352
  // Always null: a grade column carries no due date of its own, and
350
353
  // inventing one from the column name would be a guess.
351
354
  dueDate: null,
355
+ dueIn: null,
352
356
  url: baseUrl ? gradebookUrl(baseUrl, courseId) : null,
353
357
  });
354
358
  }
@@ -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());
@@ -4,13 +4,43 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { readMicrosoftSession } from "../auth/microsoft-session.js";
7
+ import { SessionStore } from "../auth/session-store.js";
7
8
  import { getConfigStorePath } from "../utils/config-store.js";
8
9
  import { GetServerInfoSchema } from "./schemas.js";
9
10
  import { toolResponse } from "./tool-helpers.js";
11
+ /**
12
+ * Read whatever non-secret identity the persisted session carries, straight
13
+ * off disk. This is local file + native keyring access only (no network),
14
+ * and never throws: a locked keyring or a corrupted/absent session file just
15
+ * means "don't report an identity", not a failed tool call. Identity is
16
+ * reported regardless of whether the underlying token itself is still valid
17
+ * — it answers "who last signed in", not "is that session still usable".
18
+ *
19
+ * Uses SessionStore.peek() rather than load(): load() silently upgrades a
20
+ * legacy version-1 session file in place (a write) the first time anything
21
+ * reads it, and this tool is called just to report status, often repeatedly
22
+ * and never as part of establishing a session. A read-only status check
23
+ * should not be the thing that migrates on-disk session state.
24
+ */
25
+ async function defaultReadSignedInIdentity(sessionDir) {
26
+ try {
27
+ const token = await new SessionStore(sessionDir).peek();
28
+ if (!token || (!token.uniqueName && !token.displayName))
29
+ return null;
30
+ return {
31
+ ...(token.uniqueName ? { uniqueName: token.uniqueName } : {}),
32
+ ...(token.displayName ? { displayName: token.displayName } : {}),
33
+ };
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ }
10
39
  /**
11
40
  * Register get_server_info tool.
12
41
  *
13
- * Answers from the startup config alone: no network call, so it can never
42
+ * Answers from the startup config alone, plus a local (non-network) read of
43
+ * the persisted session for signedInAs: no network call, so it can never
14
44
  * trigger a sign-in. hasStoredCredential reflects the keychain lookup made at
15
45
  * startup, which is the credential this process actually holds.
16
46
  * microsoftSession comes from the plain summary saved beside the browser
@@ -18,14 +48,21 @@ import { toolResponse } from "./tool-helpers.js";
18
48
  * client's own lightweight counters (apiClient.stats()) — a snapshot of
19
49
  * what has happened so far this process, not a network call of its own.
20
50
  * Nothing secret — password, username, token, cookie — is ever included.
51
+ *
52
+ * readSignedInIdentity is an injection seam: production leaves it to read
53
+ * the real encrypted session store (native keyring and all), tests supply a
54
+ * fake so they never have to touch native credential storage.
21
55
  */
22
- export function registerGetServerInfo(server, config, version, apiClient) {
56
+ export function registerGetServerInfo(server, config, version, apiClient, readSignedInIdentity = () => defaultReadSignedInIdentity(config.sessionDir)) {
23
57
  server.registerTool("get_server_info", {
24
58
  title: "Get Server Info",
25
- description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting or when the user asks which version they have. Never contacts Brightspace and never returns secrets.",
59
+ description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, the server's local timezone and UTC offset, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, (when signed in) the account's uniqueName/displayName as signedInAs, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting, when the user asks which version they have, or what timezone/account dates are being computed against. Never contacts Brightspace and never returns secrets.",
26
60
  inputSchema: GetServerInfoSchema,
27
61
  }, async () => {
28
- const microsoftSession = await readMicrosoftSession(config.sessionDir);
62
+ const [microsoftSession, signedInAs] = await Promise.all([
63
+ readMicrosoftSession(config.sessionDir),
64
+ readSignedInIdentity(),
65
+ ]);
29
66
  return toolResponse({
30
67
  version,
31
68
  node: process.version,
@@ -35,7 +72,15 @@ export function registerGetServerInfo(server, config, version, apiClient) {
35
72
  sessionStatePath: config.sessionDir,
36
73
  schoolUrl: config.baseUrl,
37
74
  hasStoredCredential: config.password !== undefined,
75
+ localTimezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
76
+ // Minutes the local clock is ahead of UTC (the inverse sign of
77
+ // Date#getTimezoneOffset, which is "minutes behind UTC"). On a UTC
78
+ // runner getTimezoneOffset() is 0, so negating it yields -0; `|| 0`
79
+ // normalizes that back to 0 so Object.is-based equality (toBe(0) in
80
+ // tests, and any JSON consumer that cares) sees a plain zero.
81
+ utcOffsetMinutes: -new Date().getTimezoneOffset() || 0,
38
82
  ...(microsoftSession && { microsoftSession }),
83
+ ...(signedInAs && { signedInAs }),
39
84
  requests: apiClient.stats(),
40
85
  });
41
86
  });
@@ -8,6 +8,7 @@ import { GetUpcomingDueDatesSchema, } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
10
  import { assignmentUrl, quizUrl, discussionUrl } from "../utils/deep-links.js";
11
+ import { dueIn } from "../utils/due-in.js";
11
12
  import { resolveCourses } from "./resolve-courses.js";
12
13
  import { fetchCourseCalendarEvents } from "./calendar-events.js";
13
14
  /** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
@@ -55,6 +56,7 @@ function calendarItems(events, items) {
55
56
  courseId: event.courseId,
56
57
  courseName: event.courseName,
57
58
  dueDate: event.start,
59
+ dueIn: dueIn(event.start),
58
60
  startDate: null,
59
61
  endDate: event.end ?? null,
60
62
  ...(event.location ? { location: event.location } : {}),
@@ -91,6 +93,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
91
93
  courseId: course.id,
92
94
  courseName: course.name,
93
95
  dueDate: folder.DueDate,
96
+ dueIn: dueIn(folder.DueDate),
94
97
  startDate: null,
95
98
  endDate: null,
96
99
  url: assignmentUrl(baseUrl, course.id, folder.Id),
@@ -116,6 +119,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
116
119
  courseId: course.id,
117
120
  courseName: course.name,
118
121
  dueDate,
122
+ dueIn: dueIn(dueDate),
119
123
  startDate: quiz.StartDate ?? null,
120
124
  endDate: quiz.EndDate ?? null,
121
125
  url: quizUrl(baseUrl, course.id, quiz.QuizId),
@@ -138,6 +142,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
138
142
  courseId: course.id,
139
143
  courseName: course.name,
140
144
  dueDate: topic.DueDate,
145
+ dueIn: dueIn(topic.DueDate),
141
146
  startDate: null,
142
147
  endDate: null,
143
148
  url: discussionUrl(baseUrl, course.id, topic.TopicId),
@@ -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({});
@@ -0,0 +1,41 @@
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
+ const MINUTE_MS = 60 * 1000;
7
+ const HOUR_MS = 60 * MINUTE_MS;
8
+ const DAY_MS = 24 * HOUR_MS;
9
+ const WEEK_MS = 7 * DAY_MS;
10
+ const relativeTimeFormat = new Intl.RelativeTimeFormat("en", { numeric: "auto" });
11
+ /**
12
+ * Render a due date as a short relative phrase ("in 3 days", "yesterday",
13
+ * "in 2 hours") so a caller doesn't have to do date math on a raw ISO
14
+ * timestamp itself. Unit scales with magnitude: minutes under an hour, hours
15
+ * under a day, days under a week, weeks beyond that.
16
+ *
17
+ * Returns `null` for a missing or unparseable date rather than throwing —
18
+ * D2L-supplied date strings aren't guaranteed well-formed (same guard as
19
+ * JhostinAleck/brightspace-mcp's `shared-kernel/date/parseValidDate.ts`, MIT).
20
+ *
21
+ * `now` defaults to `Date.now()`; tests pin it with fake timers.
22
+ */
23
+ export function dueIn(dueDate, now = Date.now()) {
24
+ if (!dueDate)
25
+ return null;
26
+ const due = new Date(dueDate).getTime();
27
+ if (!Number.isFinite(due))
28
+ return null;
29
+ const diffMs = due - now;
30
+ const absMs = Math.abs(diffMs);
31
+ if (absMs < HOUR_MS) {
32
+ return relativeTimeFormat.format(Math.round(diffMs / MINUTE_MS), "minute");
33
+ }
34
+ if (absMs < DAY_MS) {
35
+ return relativeTimeFormat.format(Math.round(diffMs / HOUR_MS), "hour");
36
+ }
37
+ if (absMs < WEEK_MS) {
38
+ return relativeTimeFormat.format(Math.round(diffMs / DAY_MS), "day");
39
+ }
40
+ return relativeTimeFormat.format(Math.round(diffMs / WEEK_MS), "week");
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.3",
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",