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 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, 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 |
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
 
@@ -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
- 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)`);
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
- return data;
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
- // Create AuthRunner for auto-reauthentication
72
- const authRunner = new AuthRunner({
73
- onProgress: (message) => {
74
- void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
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. 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
  }
@@ -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.8.14",
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",