brightspace-mcp-server 3.8.14 → 3.9.0

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
@@ -143,7 +143,7 @@ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or
143
143
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
144
144
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
145
145
  | 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 |
146
- | 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, and 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), without contacting Brightspace or revealing secrets |
146
+ | 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 |
147
147
  | 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 |
148
148
  | 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 |
149
149
 
@@ -80,6 +80,27 @@ export class D2LApiClient {
80
80
  versions = null;
81
81
  /** Single in-flight discovery, so concurrent first requests share one fetch. */
82
82
  versionsInFlight = null;
83
+ /**
84
+ * GETs keyed by the unresolved template path + query (the same string
85
+ * get() caches under), so a second caller asking for the same resource
86
+ * while the first is still in flight joins that promise instead of
87
+ * issuing its own request. Claude Desktop fans out tool calls in
88
+ * parallel and our tools fan out per course, so identical paths are
89
+ * often requested within milliseconds of each other. One client, one
90
+ * token manager, so every GET it issues already shares one auth
91
+ * identity — nothing further to key on.
92
+ *
93
+ * Adapted from JhostinAleck/brightspace-mcp (MIT) — RequestCoalescer.ts.
94
+ */
95
+ pendingGets = new Map();
96
+ statsData = {
97
+ statusClasses: { "2xx": 0, "401": 0, "403": 0, "404": 0, "429": 0, "5xx": 0, other: 0 },
98
+ networkErrors: 0,
99
+ cacheHits: 0,
100
+ cacheMisses: 0,
101
+ coalescedJoins: 0,
102
+ tokenRefreshes: 0,
103
+ };
83
104
  constructor(options) {
84
105
  // HTTPS-only enforcement, on a parsed URL rather than a string prefix so
85
106
  // a malformed base cannot slip through as "not http".
@@ -196,16 +217,34 @@ export class D2LApiClient {
196
217
  const age = this.cache.ageOf(path);
197
218
  if (age !== undefined && age <= options.ttl) {
198
219
  log("DEBUG", `Cache hit: ${path}`);
220
+ this.statsData.cacheHits++;
199
221
  return this.cache.get(path);
200
222
  }
223
+ this.statsData.cacheMisses++;
201
224
  }
202
- const resolved = await this.resolvePath(path);
203
- const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
204
- if (options?.ttl) {
205
- this.cache.set(path, data, options.ttl);
206
- log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
225
+ // Join an identical GET already in flight rather than issuing a second
226
+ // one. Keyed on the same unresolved path the cache above uses, so the
227
+ // join happens before version discovery or a token is even touched.
228
+ const pending = this.pendingGets.get(path);
229
+ if (pending) {
230
+ this.statsData.coalescedJoins++;
231
+ log("DEBUG", `Coalescing GET onto an in-flight request: ${path}`);
232
+ return pending;
207
233
  }
208
- return data;
234
+ const inFlight = (async () => {
235
+ const resolved = await this.resolvePath(path);
236
+ const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
237
+ if (options?.ttl) {
238
+ this.cache.set(path, data, options.ttl);
239
+ log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
240
+ }
241
+ return data;
242
+ })().finally(() => {
243
+ if (this.pendingGets.get(path) === inFlight)
244
+ this.pendingGets.delete(path);
245
+ });
246
+ this.pendingGets.set(path, inFlight);
247
+ return inFlight;
209
248
  }
210
249
  /**
211
250
  * Make a GET request to the D2L API and return raw Response object.
@@ -242,8 +281,15 @@ export class D2LApiClient {
242
281
  // A rejected JWT does not prove its underlying cookie is expired. Read a
243
282
  // token written by another process or mint over HTTP before opening login.
244
283
  // TokenRefreshError propagates here, so a temporary outage never starts MFA.
284
+ // One 401-recovery cascade counts as one refresh even when it takes both a
285
+ // mint attempt and a browser login to land a token the server accepts --
286
+ // `refreshed` tracks whether this cascade has already been counted so the
287
+ // fallback to tryAutoReauth below doesn't count it a second time.
245
288
  const fresh = await this.tokenManager.getToken(token.accessToken);
289
+ let refreshed = false;
246
290
  if (fresh && fresh.accessToken !== token.accessToken) {
291
+ this.statsData.tokenRefreshes++;
292
+ refreshed = true;
247
293
  try {
248
294
  return await send(fresh);
249
295
  }
@@ -253,8 +299,28 @@ export class D2LApiClient {
253
299
  }
254
300
  }
255
301
  const loggedIn = await this.tryAutoReauth(path, fresh?.accessToken ?? token.accessToken);
302
+ if (!refreshed)
303
+ this.statsData.tokenRefreshes++;
256
304
  return send(loggedIn);
257
305
  }
306
+ /** Buckets one observed HTTP status into statsData.statusClasses. */
307
+ recordStatus(status) {
308
+ const classes = this.statsData.statusClasses;
309
+ if (status >= 200 && status < 300)
310
+ classes["2xx"]++;
311
+ else if (status === 401)
312
+ classes["401"]++;
313
+ else if (status === 403)
314
+ classes["403"]++;
315
+ else if (status === 404)
316
+ classes["404"]++;
317
+ else if (status === 429)
318
+ classes["429"]++;
319
+ else if (status >= 500)
320
+ classes["5xx"]++;
321
+ else
322
+ classes.other++;
323
+ }
258
324
  /** One rate limiter token per attempt. */
259
325
  async throttled(fn) {
260
326
  await this.rateLimiter.consume();
@@ -300,6 +366,7 @@ export class D2LApiClient {
300
366
  headers,
301
367
  signal: AbortSignal.timeout(this.timeoutMs),
302
368
  });
369
+ this.recordStatus(response.status);
303
370
  // Preserve cookie material: a 401 only rejects this access token.
304
371
  if (response.status === 401) {
305
372
  throw new ApiError(401, path, "Brightspace rejected the access token.");
@@ -346,6 +413,7 @@ export class D2LApiClient {
346
413
  throw error;
347
414
  }
348
415
  // Wrap network/fetch errors
416
+ this.statsData.networkErrors++;
349
417
  const message = error instanceof Error ? error.message : String(error);
350
418
  throw new NetworkError(`Request to ${path} failed: ${message}`, error instanceof Error ? error : undefined);
351
419
  }
@@ -363,6 +431,7 @@ export class D2LApiClient {
363
431
  headers,
364
432
  signal: AbortSignal.timeout(this.timeoutMs),
365
433
  });
434
+ this.recordStatus(response.status);
366
435
  // Preserve cookie material for the shared HTTP refresh path.
367
436
  if (response.status === 401) {
368
437
  throw new ApiError(401, path, "Brightspace rejected the access token.");
@@ -416,6 +485,7 @@ export class D2LApiClient {
416
485
  throw error;
417
486
  }
418
487
  // Wrap network/fetch errors
488
+ this.statsData.networkErrors++;
419
489
  const message = error instanceof Error ? error.message : String(error);
420
490
  throw new NetworkError(`Request to ${path} failed: ${message}`, error instanceof Error ? error : undefined);
421
491
  }
@@ -483,4 +553,20 @@ export class D2LApiClient {
483
553
  get cacheSize() {
484
554
  return this.cache.size;
485
555
  }
556
+ /**
557
+ * Read-only snapshot of this client's lightweight request counters. A
558
+ * fresh object every call, so a caller holding onto it never sees later
559
+ * updates and can't mutate the client's own counts. Never carries a URL,
560
+ * username, or token.
561
+ */
562
+ stats() {
563
+ return {
564
+ statusClasses: { ...this.statsData.statusClasses },
565
+ networkErrors: this.statsData.networkErrors,
566
+ cacheHits: this.statsData.cacheHits,
567
+ cacheMisses: this.statsData.cacheMisses,
568
+ coalescedJoins: this.statsData.coalescedJoins,
569
+ tokenRefreshes: this.statsData.tokenRefreshes,
570
+ };
571
+ }
486
572
  }
package/build/index.js CHANGED
@@ -115,7 +115,7 @@ else {
115
115
  registerGetSyllabus(server, apiClient);
116
116
  registerGetDiscussions(server, apiClient);
117
117
  registerGetVideoTranscript(server, apiClient);
118
- registerGetServerInfo(server, config, PKG_VERSION);
118
+ registerGetServerInfo(server, config, PKG_VERSION, apiClient);
119
119
  log("DEBUG", "MCP tools registered (16 tools)");
120
120
  // Connect stdio transport
121
121
  const transport = new StdioServerTransport();
@@ -14,13 +14,15 @@ import { toolResponse } from "./tool-helpers.js";
14
14
  * trigger a sign-in. hasStoredCredential reflects the keychain lookup made at
15
15
  * startup, which is the credential this process actually holds.
16
16
  * microsoftSession comes from the plain summary saved beside the browser
17
- * state and is omitted when there is no saved state. Nothing secret —
18
- * password, username, token, cookie — is ever included.
17
+ * state and is omitted when there is no saved state. requests is the
18
+ * client's own lightweight counters (apiClient.stats()) — a snapshot of
19
+ * what has happened so far this process, not a network call of its own.
20
+ * Nothing secret — password, username, token, cookie — is ever included.
19
21
  */
20
- export function registerGetServerInfo(server, config, version) {
22
+ export function registerGetServerInfo(server, config, version, apiClient) {
21
23
  server.registerTool("get_server_info", {
22
24
  title: "Get Server Info",
23
- 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, and (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox. Use this for troubleshooting or when the user asks which version they have. Never contacts Brightspace and never returns secrets.",
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.",
24
26
  inputSchema: GetServerInfoSchema,
25
27
  }, async () => {
26
28
  const microsoftSession = await readMicrosoftSession(config.sessionDir);
@@ -34,6 +36,7 @@ export function registerGetServerInfo(server, config, version) {
34
36
  schoolUrl: config.baseUrl,
35
37
  hasStoredCredential: config.password !== undefined,
36
38
  ...(microsoftSession && { microsoftSession }),
39
+ requests: apiClient.stats(),
37
40
  });
38
41
  });
39
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.8.14",
3
+ "version": "3.9.0",
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",