brightspace-mcp-server 3.8.13 → 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
 
@@ -151,5 +151,6 @@ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or
151
151
 
152
152
  When a content-file download returns 403 or 404, `download_file` checks topic metadata and the course table of contents. Confirmed restrictions return `{ success: false, available: false, reason, message, startDate, endDate }` so the assistant can explain when content opens or why it has closed. Unexplained 404s and server/network failures retain their original errors.
153
153
 
154
+ When course content or announcements are converted to markdown, `javascript:`/`data:` links are rendered as plain text (the link itself is dropped, not followed) and D2L's per-session query parameters (`d2lSessionVal`, `d2lSecureSessionVal`, and the cache-busting `_`) are stripped from any remaining links and images before they reach the assistant.
154
155
 
155
156
  Licensed under the MIT License.
@@ -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
  }
@@ -78,16 +78,47 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
78
78
  }
79
79
  return objects;
80
80
  }
81
- /** A Next value resolved to a request path. */
81
+ /**
82
+ * A Next value resolved to a request path, or null when it should not be
83
+ * followed.
84
+ *
85
+ * Adapted from JhostinAleck/brightspace-mcp (MIT): beyond dropping an
86
+ * absolute Next's host (the client always prefixes its own configured base
87
+ * URL, so a hostile/garbled Next cannot redirect requests elsewhere), anything
88
+ * -- absolute or server-relative -- whose path falls outside `/d2l/api/` is
89
+ * rejected rather than followed, since that is the only space these paginated
90
+ * endpoints legitimately point back into.
91
+ */
82
92
  function nextPath(firstPath, next) {
83
93
  if (/^https?:\/\//i.test(next)) {
84
94
  const url = new URL(next);
95
+ if (!isApiPath(url.pathname)) {
96
+ log("INFO", "Pagination stopped: Next link left the D2L API", {
97
+ firstPath,
98
+ nextPath: url.pathname,
99
+ });
100
+ return null;
101
+ }
85
102
  return `${url.pathname}${url.search}`;
86
103
  }
87
104
  // A Next that begins with a slash is already a path, not a bookmark.
88
105
  // Appending it as one would ask for a page that does not exist.
89
106
  if (next.startsWith("/")) {
107
+ const nextPathOnly = next.split("?")[0] ?? next;
108
+ if (!isApiPath(nextPathOnly)) {
109
+ log("INFO", "Pagination stopped: Next link left the D2L API", {
110
+ firstPath,
111
+ // Log only the path, not the query string: a rejected server-relative
112
+ // Next can carry a `d2lSessionVal=` token in its query.
113
+ nextPath: nextPathOnly,
114
+ });
115
+ return null;
116
+ }
90
117
  return next;
91
118
  }
92
119
  return withBookmark(firstPath, next);
93
120
  }
121
+ /** Whether a Next path stays inside the D2L API namespace these endpoints page through. */
122
+ function isApiPath(pathname) {
123
+ return pathname.startsWith("/d2l/api/");
124
+ }
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
  }
@@ -60,9 +60,70 @@ function getTurndownService() {
60
60
  return body ? `\n\n${body}\n\n` : "";
61
61
  },
62
62
  });
63
+ // D2L appends per-session query params (d2lSessionVal, d2lSecureSessionVal,
64
+ // a `_` cache-buster) to in-content hrefs. Left alone, the markdown we hand
65
+ // the model echoes a live session token into the chat transcript. These
66
+ // two rules are unshifted ahead of Turndown's built-in link/image rules
67
+ // (addRule always inserts at the front of the rule list), so they run
68
+ // first and the defaults never see the raw attribute.
69
+ turndownService.addRule("linkSessionTokens", {
70
+ filter: (node) => node.nodeName === "A" && !!node.getAttribute("href"),
71
+ replacement: (content, node) => {
72
+ const href = node.getAttribute("href") ?? "";
73
+ // javascript:/data: hrefs aren't content links; drop the link but
74
+ // keep whatever text was inside it.
75
+ if (isUnsafeHref(href))
76
+ return content;
77
+ const cleaned = stripSessionParams(href).replace(/([()])/g, "\\$1");
78
+ const title = node.getAttribute("title");
79
+ const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
80
+ return `[${content}](${cleaned}${titlePart})`;
81
+ },
82
+ });
83
+ turndownService.addRule("imageSessionTokens", {
84
+ filter: "img",
85
+ replacement: (_content, node) => {
86
+ const el = node;
87
+ const alt = el.getAttribute("alt") ?? "";
88
+ const src = stripSessionParams(el.getAttribute("src") ?? "");
89
+ const title = el.getAttribute("title");
90
+ const titlePart = title ? ` "${title}"` : "";
91
+ return src ? `![${alt}](${src}${titlePart})` : "";
92
+ },
93
+ });
63
94
  }
64
95
  return turndownService;
65
96
  }
97
+ const SESSION_QUERY_PARAMS = new Set(["d2lsessionval", "d2lsecuresessionval", "_"]);
98
+ /**
99
+ * Strip D2L's per-session query params (`d2lSessionVal`, `d2lSecureSessionVal`,
100
+ * and the `_` cache-buster) from a URL. Everything else about the URL --
101
+ * scheme, path, other params -- is left untouched, including a relative href,
102
+ * which is returned as-is apart from this filtering.
103
+ */
104
+ function stripSessionParams(url) {
105
+ const queryStart = url.indexOf("?");
106
+ if (queryStart < 0)
107
+ return url;
108
+ const earlyHash = url.indexOf("#");
109
+ if (earlyHash >= 0 && earlyHash < queryStart) {
110
+ // The "?" falls inside the fragment (e.g. "#frag?x=1"), not a real query
111
+ // string -- nothing to strip, and treating it as one would mangle the
112
+ // fragment.
113
+ return url;
114
+ }
115
+ const hashStart = url.indexOf("#", queryStart);
116
+ const query = url.slice(queryStart + 1, hashStart < 0 ? undefined : hashStart);
117
+ const hash = hashStart < 0 ? "" : url.slice(hashStart);
118
+ const kept = query
119
+ .split("&")
120
+ .filter((param) => param !== "" && !SESSION_QUERY_PARAMS.has(param.split("=")[0].toLowerCase()));
121
+ return url.slice(0, queryStart) + (kept.length > 0 ? `?${kept.join("&")}` : "") + hash;
122
+ }
123
+ /** True for `javascript:`/`data:` hrefs -- never a legitimate content link. */
124
+ function isUnsafeHref(href) {
125
+ return /^\s*(javascript|data):/i.test(href);
126
+ }
66
127
  /** A cell's text, flattened so one table cell stays one table cell. */
67
128
  function cellText(content) {
68
129
  return content
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.8.13",
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",