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 +2 -1
- package/build/api/client.js +92 -6
- package/build/api/paginate.js +32 -1
- package/build/index.js +1 -1
- package/build/tools/get-server-info.js +7 -4
- package/build/utils/html-converter.js +61 -0
- package/package.json +1 -1
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,
|
|
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.
|
package/build/api/client.js
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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/api/paginate.js
CHANGED
|
@@ -78,16 +78,47 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
|
|
|
78
78
|
}
|
|
79
79
|
return objects;
|
|
80
80
|
}
|
|
81
|
-
/**
|
|
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.
|
|
18
|
-
*
|
|
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,
|
|
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 ? `` : "";
|
|
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.
|
|
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",
|