brightspace-mcp-server 3.10.6 → 3.10.8

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
@@ -101,6 +101,7 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
101
101
  | `D2L_INCLUDE_COURSES` / `D2L_EXCLUDE_COURSES` | Limit which courses the AI sees, by course id |
102
102
  | `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
103
103
  | `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
104
+ | `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging session problems ([how](docs/dev-mode.md)) |
104
105
 
105
106
  ## When it asks you to sign in
106
107
 
@@ -11,6 +11,7 @@ import { ApiError, RateLimitError, NetworkError } from "./errors.js";
11
11
  import { withRetry, isRetryableFailure, retryAfterMsFrom, parseRetryAfter, } from "./retry.js";
12
12
  import { log } from "../utils/logger.js";
13
13
  import { AUTH_COMMAND } from "../utils/commands.js";
14
+ import { devActivity } from "../utils/dev-activity.js";
14
15
  /** An ordinary course HTML link to the login page is not an expired session. */
15
16
  function isExpiredSessionRedirect(body, baseUrl) {
16
17
  const expiredTarget = (target) => {
@@ -307,6 +308,7 @@ export class D2LApiClient {
307
308
  }
308
309
  /** Buckets one observed HTTP status into statsData.statusClasses. */
309
310
  recordStatus(status) {
311
+ devActivity("http_response", { status });
310
312
  const classes = this.statsData.statusClasses;
311
313
  if (status >= 200 && status < 300)
312
314
  classes["2xx"]++;
@@ -341,6 +343,7 @@ export class D2LApiClient {
341
343
  * If successful, returns the fresh token. Otherwise throws 401 ApiError.
342
344
  */
343
345
  async tryAutoReauth(path, rejectedAccessToken) {
346
+ devActivity("auth_required");
344
347
  if (this.onAuthExpired) {
345
348
  log("INFO", "Attempting auto-reauthentication...");
346
349
  const success = await this.onAuthExpired();
@@ -9,6 +9,7 @@ import * as path from "node:path";
9
9
  import { log } from "../utils/logger.js";
10
10
  import { AuthError } from "../utils/errors.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
+ import { devActivity } from "../utils/dev-activity.js";
12
13
  /**
13
14
  * Timeout for the auth process. It has to outlast the child's own MFA wait,
14
15
  * which is five minutes: a person has to find their phone, unlock it, and read
@@ -292,6 +293,8 @@ export class AuthRunner {
292
293
  * child's real completion is tracked separately on this.childDone.
293
294
  */
294
295
  async spawnAuth() {
296
+ const started = Date.now();
297
+ devActivity("recovery_started");
295
298
  log("INFO", "Auto-launching brightspace-auth...");
296
299
  return await new Promise((resolve, reject) => {
297
300
  const child = spawn(process.execPath, // use the same Node binary
@@ -384,6 +387,8 @@ export class AuthRunner {
384
387
  // fresh joiner sees it — see MFA_PENDING_MARKER's own comment.
385
388
  const publishChallenge = (matched) => {
386
389
  const firstChallenge = this.pendingChallenge === null;
390
+ if (firstChallenge)
391
+ devActivity("mfa_observed", { elapsedMs: Date.now() - started });
387
392
  if (firstChallenge || matched) {
388
393
  this.pendingChallenge = { numberMatch: matched ?? this.pendingChallenge?.numberMatch };
389
394
  }
@@ -400,6 +405,7 @@ export class AuthRunner {
400
405
  if (childFinished)
401
406
  return;
402
407
  childFinished = true;
408
+ devActivity("recovery_finished", { outcome: error ? "error" : "success", reason: error?.kind, elapsedMs: Date.now() - started });
403
409
  clearTimeout(timer);
404
410
  if (killTimer)
405
411
  clearTimeout(killTimer);
@@ -7,6 +7,7 @@ import { SessionStore } from "./session-store.js";
7
7
  import { mintAccessToken } from "./token-mint.js";
8
8
  import { log } from "../utils/logger.js";
9
9
  import { TokenRefreshError } from "../api/errors.js";
10
+ import { devActivity } from "../utils/dev-activity.js";
10
11
  /**
11
12
  * Token refresh buffer - tokens within this time of expiry are considered invalid.
12
13
  * This prevents using tokens that might expire during a request.
@@ -140,6 +141,8 @@ export class TokenManager {
140
141
  return inFlight;
141
142
  }
142
143
  async runMint(stale) {
144
+ const started = Date.now();
145
+ devActivity("token_mint_started");
143
146
  log("DEBUG", "Trying to mint an access token from the session cookie");
144
147
  let result;
145
148
  try {
@@ -150,8 +153,13 @@ export class TokenManager {
150
153
  });
151
154
  }
152
155
  catch (error) {
156
+ devActivity("token_mint_finished", { outcome: "transport", elapsedMs: Date.now() - started });
153
157
  throw new TokenRefreshError("token service request failed", error instanceof Error ? error : undefined);
154
158
  }
159
+ devActivity("token_mint_finished", {
160
+ outcome: result.ok ? "success" : result.reason === "sessionExpired" ? "sessionExpired" : "error",
161
+ elapsedMs: Date.now() - started,
162
+ });
155
163
  // The auth CLI or another MCP process can finish a login while HTTP minting
156
164
  // is in flight. Its newer session wins over either result of this request.
157
165
  const current = await this.sessionStore.load();
package/build/index.js CHANGED
@@ -13,6 +13,7 @@ import { loadConfig } from "./utils/config.js";
13
13
  import { TokenManager, AuthRunner } from "./auth/index.js";
14
14
  import { D2LApiClient } from "./api/index.js";
15
15
  import { startUpdateChecks } from "./utils/update-checker.js";
16
+ import { configureDevActivity, trackToolActivity } from "./utils/dev-activity.js";
16
17
  import { readFileSync } from "node:fs";
17
18
  import { fileURLToPath } from "node:url";
18
19
  import { dirname, resolve } from "node:path";
@@ -52,6 +53,7 @@ else {
52
53
  try {
53
54
  // Load configuration
54
55
  const config = await loadConfig();
56
+ configureDevActivity(config.sessionDir);
55
57
  log("DEBUG", "Configuration loaded", { sessionDir: config.sessionDir });
56
58
  // Create MCP server instance
57
59
  const server = new McpServer({
@@ -122,6 +124,7 @@ else {
122
124
  // shows an MFA challenge mid-call and waits for the approval instead of
123
125
  // asking to be called again (see relayMfaChallenges).
124
126
  relayMfaChallenges(server);
127
+ trackToolActivity(server);
125
128
  registerGetMyCourses(server, apiClient, config);
126
129
  registerGetUpcomingDueDates(server, apiClient, config);
127
130
  registerGetCalendarEvents(server, apiClient, config);
@@ -0,0 +1,88 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import { randomUUID } from "node:crypto";
3
+ import { appendFileSync, mkdirSync, statSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ const calls = new AsyncLocalStorage();
6
+ const runId = randomUUID();
7
+ const MAX_BYTES = 5 * 1024 * 1024;
8
+ let directory;
9
+ let warned = false;
10
+ let lastStart;
11
+ let lastSuccess;
12
+ /** Explicit opt-in; separate from ordinary stderr debugging. */
13
+ export function configureDevActivity(sessionDir) {
14
+ directory = process.env.D2L_DEV_MODE === "true" ? join(sessionDir, "dev-activity") : undefined;
15
+ lastStart = lastSuccess = undefined;
16
+ warned = false;
17
+ }
18
+ /** No arbitrary text, errors, URLs, request arguments or response content. */
19
+ export function devActivity(event, fields = {}) {
20
+ write(event, fields);
21
+ }
22
+ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs) {
23
+ if (!directory)
24
+ return;
25
+ if (!["tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
26
+ "token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished"].includes(event))
27
+ return;
28
+ const at = new Date().toISOString();
29
+ const record = { at, runId, pid: process.pid, event };
30
+ if (context)
31
+ Object.assign(record, context);
32
+ for (const [key, value] of Object.entries({ elapsedMs: fields.elapsedMs, status: fields.status, idleMs, sinceSuccessMs })) {
33
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0)
34
+ record[key] = value;
35
+ }
36
+ if (["success", "error", "sessionExpired", "transport"].includes(fields.outcome ?? ""))
37
+ record.outcome = fields.outcome;
38
+ if (["busy", "cooldown", "unsupported", "secureStorage", "transport", "timeout", "failed", "mfaPending"].includes(fields.reason ?? ""))
39
+ record.reason = fields.reason;
40
+ try {
41
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
42
+ const file = join(directory, `activity-${at.slice(0, 10)}-${process.pid}-${runId}.jsonl`);
43
+ let size = 0;
44
+ try {
45
+ size = statSync(file).size;
46
+ }
47
+ catch (error) {
48
+ if (error.code !== "ENOENT")
49
+ throw error;
50
+ }
51
+ const line = JSON.stringify(record) + "\n";
52
+ if (size + Buffer.byteLength(line) <= MAX_BYTES)
53
+ appendFileSync(file, line, { mode: 0o600 });
54
+ }
55
+ catch {
56
+ if (!warned) {
57
+ warned = true;
58
+ console.error("[WARN] Dev activity could not be saved; tool execution continues.");
59
+ }
60
+ }
61
+ }
62
+ /** Wraps registered handlers, composing with the MFA progress relay. */
63
+ export function trackToolActivity(server) {
64
+ if (!directory)
65
+ return;
66
+ const register = server.registerTool.bind(server);
67
+ server.registerTool = ((name, config, callback) => register(name, config, (async (...args) => {
68
+ // Tool names come from registration, never the client's arguments.
69
+ const context = { callId: randomUUID(), tool: /^[a-zA-Z0-9_-]{1,100}$/.test(name) ? name : "other" };
70
+ const started = Date.now();
71
+ write("tool_started", {}, context, lastStart === undefined ? undefined : started - lastStart, lastSuccess === undefined ? undefined : started - lastSuccess);
72
+ lastStart = started;
73
+ return calls.run(context, async () => {
74
+ let outcome = "error";
75
+ try {
76
+ const result = await callback(...args);
77
+ if (!result?.isError) {
78
+ outcome = "success";
79
+ lastSuccess = Date.now();
80
+ }
81
+ return result;
82
+ }
83
+ finally {
84
+ write("tool_finished", { outcome, elapsedMs: Date.now() - started });
85
+ }
86
+ });
87
+ })));
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.10.6",
3
+ "version": "3.10.8",
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",
@@ -40,7 +40,6 @@
40
40
  },
41
41
  "devDependencies": {
42
42
  "@types/node": "^22.0.0",
43
- "@types/sanitize-filename": "^1.1.28",
44
43
  "@types/turndown": "^5.0.6",
45
44
  "typescript": "^5.9.0",
46
45
  "vitest": "^4.0.0"