brightspace-mcp-server 3.8.14 → 3.9.1
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 +22 -1
- package/build/api/client.js +95 -7
- package/build/auth/token-manager.js +49 -0
- package/build/index.js +23 -8
- package/build/tools/get-server-info.js +7 -4
- package/build/utils/config.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -129,6 +129,27 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
129
129
|
|
|
130
130
|
Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or Downloads without Files and Folders permission (`EPERM`). Grant access in System Settings → Privacy & Security → Files and Folders, or run elsewhere.
|
|
131
131
|
|
|
132
|
+
## No browser? Paste a session cookie or token
|
|
133
|
+
|
|
134
|
+
The normal setup drives a real (usually hidden) browser through sign-in, which doesn't work in Docker, headless Linux, WSL without a display, or on a tenant whose MFA requires a hardware security key. Two environment variables skip the browser entirely; set one and leave the normal setup untouched:
|
|
135
|
+
|
|
136
|
+
- **`D2L_SESSION_COOKIE`** — the `d2lSessionVal` and `d2lSecureSessionVal` cookies from a browser tab where you're already signed in to Brightspace. In Chrome DevTools: open your Brightspace site, **F12 → Application → Cookies**, find `d2lSessionVal` and `d2lSecureSessionVal`, and set the variable to either form:
|
|
137
|
+
```bash
|
|
138
|
+
export D2L_SESSION_COOKIE="d2lSessionVal=<value>; d2lSecureSessionVal=<value>"
|
|
139
|
+
# or just the two values, in that order:
|
|
140
|
+
export D2L_SESSION_COOKIE="<d2lSessionVal>;<d2lSecureSessionVal>"
|
|
141
|
+
```
|
|
142
|
+
- **`D2L_ACCESS_TOKEN`** — a pre-issued Bearer token (an admin-issued Valence token, or one minted by a TA script):
|
|
143
|
+
```bash
|
|
144
|
+
export D2L_ACCESS_TOKEN="<token>"
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Set either in your MCP client's `env` config instead of a shell export if you're not running the server from a terminal. If both are set, `D2L_ACCESS_TOKEN` wins.
|
|
148
|
+
|
|
149
|
+
If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.json` on disk, also set **`D2L_BASE_URL`** to your school's Brightspace URL — with no config file to read it from, the server otherwise defaults to Purdue's.
|
|
150
|
+
|
|
151
|
+
**The catch:** neither one renews itself. A pasted session cookie dies at D2L's own idle timeout (the same timeout that would eventually log you out in a browser), and a pre-issued token dies whenever it expires or is revoked. When that happens the server does **not** fall back to a browser login — it answers with an error telling you to paste a fresh value. There's no in-between: this is a deliberate escape hatch for environments that can't run a browser at all, not a way to skip typing your password once.
|
|
152
|
+
|
|
132
153
|
## What You Can Ask About
|
|
133
154
|
|
|
134
155
|
| Topic | Examples |
|
|
@@ -143,7 +164,7 @@ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or
|
|
|
143
164
|
| Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
|
|
144
165
|
| Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
|
|
145
166
|
| 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,
|
|
167
|
+
| 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
168
|
| 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
169
|
| 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
170
|
|
package/build/api/client.js
CHANGED
|
@@ -76,10 +76,32 @@ export class D2LApiClient {
|
|
|
76
76
|
cacheTTLs;
|
|
77
77
|
timeoutMs;
|
|
78
78
|
onAuthExpired;
|
|
79
|
+
authExpiredMessage;
|
|
79
80
|
retryConfig;
|
|
80
81
|
versions = null;
|
|
81
82
|
/** Single in-flight discovery, so concurrent first requests share one fetch. */
|
|
82
83
|
versionsInFlight = null;
|
|
84
|
+
/**
|
|
85
|
+
* GETs keyed by the unresolved template path + query (the same string
|
|
86
|
+
* get() caches under), so a second caller asking for the same resource
|
|
87
|
+
* while the first is still in flight joins that promise instead of
|
|
88
|
+
* issuing its own request. Claude Desktop fans out tool calls in
|
|
89
|
+
* parallel and our tools fan out per course, so identical paths are
|
|
90
|
+
* often requested within milliseconds of each other. One client, one
|
|
91
|
+
* token manager, so every GET it issues already shares one auth
|
|
92
|
+
* identity — nothing further to key on.
|
|
93
|
+
*
|
|
94
|
+
* Adapted from JhostinAleck/brightspace-mcp (MIT) — RequestCoalescer.ts.
|
|
95
|
+
*/
|
|
96
|
+
pendingGets = new Map();
|
|
97
|
+
statsData = {
|
|
98
|
+
statusClasses: { "2xx": 0, "401": 0, "403": 0, "404": 0, "429": 0, "5xx": 0, other: 0 },
|
|
99
|
+
networkErrors: 0,
|
|
100
|
+
cacheHits: 0,
|
|
101
|
+
cacheMisses: 0,
|
|
102
|
+
coalescedJoins: 0,
|
|
103
|
+
tokenRefreshes: 0,
|
|
104
|
+
};
|
|
83
105
|
constructor(options) {
|
|
84
106
|
// HTTPS-only enforcement, on a parsed URL rather than a string prefix so
|
|
85
107
|
// a malformed base cannot slip through as "not http".
|
|
@@ -98,6 +120,7 @@ export class D2LApiClient {
|
|
|
98
120
|
this.tokenManager = options.tokenManager;
|
|
99
121
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
100
122
|
this.onAuthExpired = options.onAuthExpired;
|
|
123
|
+
this.authExpiredMessage = options.authExpiredMessage;
|
|
101
124
|
this.retryConfig = options.retry ?? {};
|
|
102
125
|
// Merge user-provided TTLs with defaults
|
|
103
126
|
this.cacheTTLs = { ...DEFAULT_CACHE_TTLS, ...options.cacheTTLs };
|
|
@@ -196,16 +219,34 @@ export class D2LApiClient {
|
|
|
196
219
|
const age = this.cache.ageOf(path);
|
|
197
220
|
if (age !== undefined && age <= options.ttl) {
|
|
198
221
|
log("DEBUG", `Cache hit: ${path}`);
|
|
222
|
+
this.statsData.cacheHits++;
|
|
199
223
|
return this.cache.get(path);
|
|
200
224
|
}
|
|
225
|
+
this.statsData.cacheMisses++;
|
|
201
226
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
227
|
+
// Join an identical GET already in flight rather than issuing a second
|
|
228
|
+
// one. Keyed on the same unresolved path the cache above uses, so the
|
|
229
|
+
// join happens before version discovery or a token is even touched.
|
|
230
|
+
const pending = this.pendingGets.get(path);
|
|
231
|
+
if (pending) {
|
|
232
|
+
this.statsData.coalescedJoins++;
|
|
233
|
+
log("DEBUG", `Coalescing GET onto an in-flight request: ${path}`);
|
|
234
|
+
return pending;
|
|
207
235
|
}
|
|
208
|
-
|
|
236
|
+
const inFlight = (async () => {
|
|
237
|
+
const resolved = await this.resolvePath(path);
|
|
238
|
+
const data = await this.withAuthentication(resolved, token => this.makeRequest(resolved, token));
|
|
239
|
+
if (options?.ttl) {
|
|
240
|
+
this.cache.set(path, data, options.ttl);
|
|
241
|
+
log("DEBUG", `Cached response for ${path} (TTL: ${options.ttl}ms)`);
|
|
242
|
+
}
|
|
243
|
+
return data;
|
|
244
|
+
})().finally(() => {
|
|
245
|
+
if (this.pendingGets.get(path) === inFlight)
|
|
246
|
+
this.pendingGets.delete(path);
|
|
247
|
+
});
|
|
248
|
+
this.pendingGets.set(path, inFlight);
|
|
249
|
+
return inFlight;
|
|
209
250
|
}
|
|
210
251
|
/**
|
|
211
252
|
* Make a GET request to the D2L API and return raw Response object.
|
|
@@ -242,8 +283,15 @@ export class D2LApiClient {
|
|
|
242
283
|
// A rejected JWT does not prove its underlying cookie is expired. Read a
|
|
243
284
|
// token written by another process or mint over HTTP before opening login.
|
|
244
285
|
// TokenRefreshError propagates here, so a temporary outage never starts MFA.
|
|
286
|
+
// One 401-recovery cascade counts as one refresh even when it takes both a
|
|
287
|
+
// mint attempt and a browser login to land a token the server accepts --
|
|
288
|
+
// `refreshed` tracks whether this cascade has already been counted so the
|
|
289
|
+
// fallback to tryAutoReauth below doesn't count it a second time.
|
|
245
290
|
const fresh = await this.tokenManager.getToken(token.accessToken);
|
|
291
|
+
let refreshed = false;
|
|
246
292
|
if (fresh && fresh.accessToken !== token.accessToken) {
|
|
293
|
+
this.statsData.tokenRefreshes++;
|
|
294
|
+
refreshed = true;
|
|
247
295
|
try {
|
|
248
296
|
return await send(fresh);
|
|
249
297
|
}
|
|
@@ -253,8 +301,28 @@ export class D2LApiClient {
|
|
|
253
301
|
}
|
|
254
302
|
}
|
|
255
303
|
const loggedIn = await this.tryAutoReauth(path, fresh?.accessToken ?? token.accessToken);
|
|
304
|
+
if (!refreshed)
|
|
305
|
+
this.statsData.tokenRefreshes++;
|
|
256
306
|
return send(loggedIn);
|
|
257
307
|
}
|
|
308
|
+
/** Buckets one observed HTTP status into statsData.statusClasses. */
|
|
309
|
+
recordStatus(status) {
|
|
310
|
+
const classes = this.statsData.statusClasses;
|
|
311
|
+
if (status >= 200 && status < 300)
|
|
312
|
+
classes["2xx"]++;
|
|
313
|
+
else if (status === 401)
|
|
314
|
+
classes["401"]++;
|
|
315
|
+
else if (status === 403)
|
|
316
|
+
classes["403"]++;
|
|
317
|
+
else if (status === 404)
|
|
318
|
+
classes["404"]++;
|
|
319
|
+
else if (status === 429)
|
|
320
|
+
classes["429"]++;
|
|
321
|
+
else if (status >= 500)
|
|
322
|
+
classes["5xx"]++;
|
|
323
|
+
else
|
|
324
|
+
classes.other++;
|
|
325
|
+
}
|
|
258
326
|
/** One rate limiter token per attempt. */
|
|
259
327
|
async throttled(fn) {
|
|
260
328
|
await this.rateLimiter.consume();
|
|
@@ -285,7 +353,7 @@ export class D2LApiClient {
|
|
|
285
353
|
}
|
|
286
354
|
log("WARN", "Auto-reauthentication did not produce a valid token");
|
|
287
355
|
}
|
|
288
|
-
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
|
|
356
|
+
throw new ApiError(401, path, this.authExpiredMessage ?? `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
|
|
289
357
|
}
|
|
290
358
|
/**
|
|
291
359
|
* Make one HTTP request. Authentication recovery is shared with raw downloads.
|
|
@@ -300,6 +368,7 @@ export class D2LApiClient {
|
|
|
300
368
|
headers,
|
|
301
369
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
302
370
|
});
|
|
371
|
+
this.recordStatus(response.status);
|
|
303
372
|
// Preserve cookie material: a 401 only rejects this access token.
|
|
304
373
|
if (response.status === 401) {
|
|
305
374
|
throw new ApiError(401, path, "Brightspace rejected the access token.");
|
|
@@ -346,6 +415,7 @@ export class D2LApiClient {
|
|
|
346
415
|
throw error;
|
|
347
416
|
}
|
|
348
417
|
// Wrap network/fetch errors
|
|
418
|
+
this.statsData.networkErrors++;
|
|
349
419
|
const message = error instanceof Error ? error.message : String(error);
|
|
350
420
|
throw new NetworkError(`Request to ${path} failed: ${message}`, error instanceof Error ? error : undefined);
|
|
351
421
|
}
|
|
@@ -363,6 +433,7 @@ export class D2LApiClient {
|
|
|
363
433
|
headers,
|
|
364
434
|
signal: AbortSignal.timeout(this.timeoutMs),
|
|
365
435
|
});
|
|
436
|
+
this.recordStatus(response.status);
|
|
366
437
|
// Preserve cookie material for the shared HTTP refresh path.
|
|
367
438
|
if (response.status === 401) {
|
|
368
439
|
throw new ApiError(401, path, "Brightspace rejected the access token.");
|
|
@@ -416,6 +487,7 @@ export class D2LApiClient {
|
|
|
416
487
|
throw error;
|
|
417
488
|
}
|
|
418
489
|
// Wrap network/fetch errors
|
|
490
|
+
this.statsData.networkErrors++;
|
|
419
491
|
const message = error instanceof Error ? error.message : String(error);
|
|
420
492
|
throw new NetworkError(`Request to ${path} failed: ${message}`, error instanceof Error ? error : undefined);
|
|
421
493
|
}
|
|
@@ -483,4 +555,20 @@ export class D2LApiClient {
|
|
|
483
555
|
get cacheSize() {
|
|
484
556
|
return this.cache.size;
|
|
485
557
|
}
|
|
558
|
+
/**
|
|
559
|
+
* Read-only snapshot of this client's lightweight request counters. A
|
|
560
|
+
* fresh object every call, so a caller holding onto it never sees later
|
|
561
|
+
* updates and can't mutate the client's own counts. Never carries a URL,
|
|
562
|
+
* username, or token.
|
|
563
|
+
*/
|
|
564
|
+
stats() {
|
|
565
|
+
return {
|
|
566
|
+
statusClasses: { ...this.statsData.statusClasses },
|
|
567
|
+
networkErrors: this.statsData.networkErrors,
|
|
568
|
+
cacheHits: this.statsData.cacheHits,
|
|
569
|
+
cacheMisses: this.statsData.cacheMisses,
|
|
570
|
+
coalescedJoins: this.statsData.coalescedJoins,
|
|
571
|
+
tokenRefreshes: this.statsData.tokenRefreshes,
|
|
572
|
+
};
|
|
573
|
+
}
|
|
486
574
|
}
|
|
@@ -24,6 +24,8 @@ export class TokenManager {
|
|
|
24
24
|
baseUrl;
|
|
25
25
|
tokenTtl;
|
|
26
26
|
mint;
|
|
27
|
+
envAccessToken;
|
|
28
|
+
envSessionCookie;
|
|
27
29
|
/** Single in-flight mint, so concurrent callers share one request. */
|
|
28
30
|
mintInFlight = null;
|
|
29
31
|
rejectedAccessToken = null;
|
|
@@ -35,6 +37,8 @@ export class TokenManager {
|
|
|
35
37
|
this.baseUrl = options.baseUrl ? new URL(options.baseUrl).origin : undefined;
|
|
36
38
|
this.tokenTtl = options.tokenTtl ?? DEFAULT_TOKEN_TTL_SECONDS;
|
|
37
39
|
this.mint = options.mint ?? mintAccessToken;
|
|
40
|
+
this.envAccessToken = options.envAccessToken;
|
|
41
|
+
this.envSessionCookie = options.envSessionCookie;
|
|
38
42
|
}
|
|
39
43
|
/**
|
|
40
44
|
* Get the current token if valid, otherwise null.
|
|
@@ -44,6 +48,21 @@ export class TokenManager {
|
|
|
44
48
|
async getToken(rejectedAccessToken) {
|
|
45
49
|
if (rejectedAccessToken)
|
|
46
50
|
this.rejectedAccessToken = rejectedAccessToken;
|
|
51
|
+
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE bypass disk, mint, and the browser
|
|
52
|
+
// entirely: every call hands back the same env-derived token. The only
|
|
53
|
+
// way this "expires" is Brightspace rejecting it, which the caller
|
|
54
|
+
// reports back as rejectedAccessToken — at that point there is nothing
|
|
55
|
+
// fresher to offer, so this returns null rather than the same rejected
|
|
56
|
+
// value again. (See D2LApiClient.tryAutoReauth / authExpiredMessage for
|
|
57
|
+
// the user-facing "paste a fresh one" error this produces.)
|
|
58
|
+
if (this.envAccessToken || this.envSessionCookie) {
|
|
59
|
+
const token = this.buildEnvToken();
|
|
60
|
+
if (rejectedAccessToken && token.accessToken === rejectedAccessToken) {
|
|
61
|
+
log("DEBUG", "The env-provided credential was rejected; no replacement is available");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
47
66
|
// Check memory cache first
|
|
48
67
|
if (this.cachedToken && this.isUsable(this.cachedToken)) {
|
|
49
68
|
log("DEBUG", "Returning cached token");
|
|
@@ -69,6 +88,36 @@ export class TokenManager {
|
|
|
69
88
|
log("DEBUG", "No valid token available");
|
|
70
89
|
return null;
|
|
71
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the token for D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE;
|
|
93
|
+
* D2L_ACCESS_TOKEN wins when both are set. expiresAt is pushed far into the
|
|
94
|
+
* future: a pasted credential has no known lifetime, and isValid()'s
|
|
95
|
+
* refresh-buffer check must never be the thing that discards it — only
|
|
96
|
+
* Brightspace actually rejecting it (a 401) should.
|
|
97
|
+
*/
|
|
98
|
+
buildEnvToken() {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const FAR_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; // 10 years
|
|
101
|
+
if (this.envAccessToken) {
|
|
102
|
+
return {
|
|
103
|
+
accessToken: this.envAccessToken,
|
|
104
|
+
tenantOrigin: this.baseUrl,
|
|
105
|
+
capturedAt: now,
|
|
106
|
+
expiresAt: now + FAR_FUTURE_MS,
|
|
107
|
+
source: "env",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
// Reuses the client's existing "cookie:" passthrough (see
|
|
112
|
+
// D2LApiClient.buildAuthHeaders) instead of minting a Bearer JWT.
|
|
113
|
+
accessToken: `cookie:${this.envSessionCookie}`,
|
|
114
|
+
tenantOrigin: this.baseUrl,
|
|
115
|
+
capturedAt: now,
|
|
116
|
+
expiresAt: now + FAR_FUTURE_MS,
|
|
117
|
+
source: "env",
|
|
118
|
+
cookieHeader: this.envSessionCookie,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
72
121
|
/**
|
|
73
122
|
* Only use persisted session material, including updates from other processes.
|
|
74
123
|
*/
|
package/build/index.js
CHANGED
|
@@ -67,18 +67,33 @@ else {
|
|
|
67
67
|
sessionDir: config.sessionDir,
|
|
68
68
|
baseUrl: config.baseUrl,
|
|
69
69
|
tokenTtl: config.tokenTtl,
|
|
70
|
+
envAccessToken: config.envAccessToken,
|
|
71
|
+
envSessionCookie: config.envSessionCookie,
|
|
70
72
|
});
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE are opt-in browser-free sign-in
|
|
74
|
+
// escape hatches (Docker, headless Linux, WSL, hardware-key MFA
|
|
75
|
+
// tenants). When either is set, AuthRunner is never created: there is
|
|
76
|
+
// no stored credential to drive a browser login from, and a rejected
|
|
77
|
+
// pasted credential should fail with a clear "paste a fresh one"
|
|
78
|
+
// message rather than spawning auth-cli.
|
|
79
|
+
const envAuthActive = Boolean(config.envAccessToken || config.envSessionCookie);
|
|
80
|
+
const authRunner = envAuthActive
|
|
81
|
+
? undefined
|
|
82
|
+
: new AuthRunner({
|
|
83
|
+
onProgress: (message) => {
|
|
84
|
+
void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
|
|
85
|
+
},
|
|
86
|
+
});
|
|
77
87
|
// Create D2L API Client with auto-reauth support
|
|
78
88
|
const apiClient = new D2LApiClient({
|
|
79
89
|
baseUrl: config.baseUrl,
|
|
80
90
|
tokenManager,
|
|
81
|
-
onAuthExpired: () => authRunner.run(),
|
|
91
|
+
onAuthExpired: authRunner ? () => authRunner.run() : undefined,
|
|
92
|
+
authExpiredMessage: config.envAccessToken
|
|
93
|
+
? "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set."
|
|
94
|
+
: config.envSessionCookie
|
|
95
|
+
? "The pasted D2L_SESSION_COOKIE has expired. Copy a fresh d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser and update the environment variable; this server does not fall back to a browser login while D2L_SESSION_COOKIE is set."
|
|
96
|
+
: undefined,
|
|
82
97
|
});
|
|
83
98
|
// Nothing here reaches Brightspace. API versions are discovered by the
|
|
84
99
|
// first request that needs them, and the first request with no saved
|
|
@@ -115,7 +130,7 @@ else {
|
|
|
115
130
|
registerGetSyllabus(server, apiClient);
|
|
116
131
|
registerGetDiscussions(server, apiClient);
|
|
117
132
|
registerGetVideoTranscript(server, apiClient);
|
|
118
|
-
registerGetServerInfo(server, config, PKG_VERSION);
|
|
133
|
+
registerGetServerInfo(server, config, PKG_VERSION, apiClient);
|
|
119
134
|
log("DEBUG", "MCP tools registered (16 tools)");
|
|
120
135
|
// Connect stdio transport
|
|
121
136
|
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
|
}
|
package/build/utils/config.js
CHANGED
|
@@ -49,6 +49,12 @@ export async function loadConfig() {
|
|
|
49
49
|
const activeOnly = envBoolean(process.env.D2L_ACTIVE_ONLY, "D2L_ACTIVE_ONLY")
|
|
50
50
|
?? store?.activeOnly
|
|
51
51
|
?? true;
|
|
52
|
+
// D2L_ACCESS_TOKEN beats D2L_SESSION_COOKIE beats the normal stored-credential
|
|
53
|
+
// browser flow; both are validated whenever present regardless of which one
|
|
54
|
+
// wins, so a typo in the losing variable still fails loudly at startup.
|
|
55
|
+
const envAccessToken = readEnvSecret(process.env.D2L_ACCESS_TOKEN, "D2L_ACCESS_TOKEN");
|
|
56
|
+
const rawSessionCookie = readEnvSecret(process.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
|
|
57
|
+
const envSessionCookie = rawSessionCookie ? parseSessionCookieEnv(rawSessionCookie) : undefined;
|
|
52
58
|
const configuredUrl = new URL(process.env.D2L_BASE_URL || store?.baseUrl || "https://purdue.brightspace.com");
|
|
53
59
|
if (configuredUrl.protocol !== "https:" || configuredUrl.username || configuredUrl.password) {
|
|
54
60
|
throw new Error("The Brightspace URL must be an HTTPS school URL without embedded credentials.");
|
|
@@ -70,6 +76,8 @@ export async function loadConfig() {
|
|
|
70
76
|
username,
|
|
71
77
|
password,
|
|
72
78
|
campus: process.env.D2L_CAMPUS || store?.campus,
|
|
79
|
+
envAccessToken,
|
|
80
|
+
envSessionCookie,
|
|
73
81
|
courseFilter: {
|
|
74
82
|
includeCourseIds,
|
|
75
83
|
excludeCourseIds,
|
|
@@ -115,6 +123,76 @@ function envBoolean(value, source) {
|
|
|
115
123
|
console.error(`[config] Ignoring ${source}=${JSON.stringify(value)}: expected true or false`);
|
|
116
124
|
return undefined;
|
|
117
125
|
}
|
|
126
|
+
/** CR, LF, or NUL in a header value enables request smuggling / header injection. */
|
|
127
|
+
const CONTROL_CHAR_PATTERN = /[\r\n\0]/;
|
|
128
|
+
/**
|
|
129
|
+
* Validate a pasted auth secret (D2L_SESSION_COOKIE or D2L_ACCESS_TOKEN).
|
|
130
|
+
* Unlike positiveSeconds/envBoolean above, a bad value here is never silently
|
|
131
|
+
* ignored: both env vars exist specifically to skip the browser, so a typo
|
|
132
|
+
* must fail loudly at startup rather than fall back to a sign-in flow the
|
|
133
|
+
* user deliberately avoided. Returns undefined only when the variable is
|
|
134
|
+
* genuinely unset (absent or empty), matching the other env helpers' treatment
|
|
135
|
+
* of "".
|
|
136
|
+
*
|
|
137
|
+
* The CR/LF/NUL check is adapted from the injection guard in
|
|
138
|
+
* JhostinAleck/brightspace-mcp (MIT), AccessToken.ts:10-19. Surrounding
|
|
139
|
+
* whitespace is rejected rather than trimmed: a trailing newline or space is
|
|
140
|
+
* exactly the kind of thing a terminal paste or `export FOO=$(cat file)`
|
|
141
|
+
* leaves behind, and silently stripping it would hide the mistake instead of
|
|
142
|
+
* surfacing it.
|
|
143
|
+
*/
|
|
144
|
+
export function readEnvSecret(value, varName) {
|
|
145
|
+
if (value === undefined || value === "")
|
|
146
|
+
return undefined;
|
|
147
|
+
if (CONTROL_CHAR_PATTERN.test(value)) {
|
|
148
|
+
throw new Error(`${varName} contains a carriage return, line feed, or NUL character. Paste the value as a single line with no embedded newlines.`);
|
|
149
|
+
}
|
|
150
|
+
if (value !== value.trim()) {
|
|
151
|
+
throw new Error(`${varName} has leading or trailing whitespace. Remove it and try again.`);
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
const D2L_SESSION_VAL = "d2lSessionVal";
|
|
156
|
+
const D2L_SECURE_SESSION_VAL = "d2lSecureSessionVal";
|
|
157
|
+
const SESSION_COOKIE_FORMAT_ERROR = 'D2L_SESSION_COOKIE must be either "d2lSessionVal=...; d2lSecureSessionVal=..." ' +
|
|
158
|
+
"(the cookie header copied from a logged-in browser) or the two cookie values " +
|
|
159
|
+
"separated by a semicolon, in that order (d2lSessionVal;d2lSecureSessionVal).";
|
|
160
|
+
/**
|
|
161
|
+
* Normalize D2L_SESSION_COOKIE into the "d2lSessionVal=...; d2lSecureSessionVal=..."
|
|
162
|
+
* header D2L's API expects. Accepts a full cookie-header fragment (extra
|
|
163
|
+
* cookies alongside the two named ones are ignored, order doesn't matter) or
|
|
164
|
+
* the two raw values separated by a semicolon with no cookie names, in which
|
|
165
|
+
* case the first is d2lSessionVal and the second d2lSecureSessionVal.
|
|
166
|
+
*/
|
|
167
|
+
export function parseSessionCookieEnv(raw) {
|
|
168
|
+
const segments = raw.split(";").map((part) => part.trim()).filter((part) => part.length > 0);
|
|
169
|
+
const named = {};
|
|
170
|
+
// The form is decided by whether a segment is NAMED d2lSessionVal/
|
|
171
|
+
// d2lSecureSessionVal (its name before the first "="), not by whether any
|
|
172
|
+
// "=" appears at all -- the two-raw-values form's second value can itself
|
|
173
|
+
// contain "=" (base64 padding), which must not be mistaken for a name.
|
|
174
|
+
let anyNamed = false;
|
|
175
|
+
for (const segment of segments) {
|
|
176
|
+
const eq = segment.indexOf("=");
|
|
177
|
+
if (eq === -1)
|
|
178
|
+
continue;
|
|
179
|
+
const name = segment.slice(0, eq).trim();
|
|
180
|
+
if (name === D2L_SESSION_VAL || name === D2L_SECURE_SESSION_VAL) {
|
|
181
|
+
anyNamed = true;
|
|
182
|
+
named[name] = segment.slice(eq + 1).trim();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (anyNamed) {
|
|
186
|
+
if (named[D2L_SESSION_VAL] && named[D2L_SECURE_SESSION_VAL]) {
|
|
187
|
+
return `${D2L_SESSION_VAL}=${named[D2L_SESSION_VAL]}; ${D2L_SECURE_SESSION_VAL}=${named[D2L_SECURE_SESSION_VAL]}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (segments.length === 2) {
|
|
191
|
+
const [sessionVal, secureSessionVal] = segments;
|
|
192
|
+
return `${D2L_SESSION_VAL}=${sessionVal}; ${D2L_SECURE_SESSION_VAL}=${secureSessionVal}`;
|
|
193
|
+
}
|
|
194
|
+
throw new Error(SESSION_COOKIE_FORMAT_ERROR);
|
|
195
|
+
}
|
|
118
196
|
function expandTilde(filePath) {
|
|
119
197
|
if (filePath.startsWith("~")) {
|
|
120
198
|
return path.join(os.homedir(), filePath.slice(1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.1",
|
|
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",
|