brightspace-mcp-server 3.10.6 → 3.10.9

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
@@ -9,10 +9,12 @@
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
11
  import { enableStdoutGuard, log } from "./utils/logger.js";
12
+ import { guardServerProcess } from "./utils/process-guard.js";
12
13
  import { loadConfig } from "./utils/config.js";
13
14
  import { TokenManager, AuthRunner } from "./auth/index.js";
14
15
  import { D2LApiClient } from "./api/index.js";
15
16
  import { startUpdateChecks } from "./utils/update-checker.js";
17
+ import { configureDevActivity, trackToolActivity } from "./utils/dev-activity.js";
16
18
  import { readFileSync } from "node:fs";
17
19
  import { fileURLToPath } from "node:url";
18
20
  import { dirname, resolve } from "node:path";
@@ -44,14 +46,14 @@ else {
44
46
  // ── MCP Server (default) ────────────────────────────────────────────
45
47
  // CRITICAL: Enable stdout guard IMMEDIATELY to prevent corruption of stdio transport
46
48
  enableStdoutGuard();
47
- // Unhandled rejection handler
48
- process.on('unhandledRejection', (reason) => {
49
- log('ERROR', 'Unhandled promise rejection', reason);
50
- });
49
+ // A stray exception or a closed pipe must not silently drop the client's
50
+ // transport; see guardServerProcess.
51
+ guardServerProcess();
51
52
  async function main() {
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
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT — see LICENSE file for details.
5
+ */
6
+ import { log } from "./logger.js";
7
+ const RECONNECT_HINT = "Restart or reconnect the Brightspace MCP server in your MCP client to continue " +
8
+ "(for example, toggle it off and on in the client's MCP settings, or restart the client).";
9
+ /**
10
+ * Keep one bad moment from silently killing a stdio MCP server.
11
+ *
12
+ * Each MCP client launches its own server process. When that process dies,
13
+ * the client only ever reports "Transport closed" for every later call, even
14
+ * calls that never touch the network, while another client's process carries
15
+ * on fine. Node's defaults are what kill it: an exception thrown from any
16
+ * event listener, or an EPIPE on stdout with no 'error' listener, ends the
17
+ * process with nothing useful in the client's log.
18
+ *
19
+ * So: a stray exception is logged and the server keeps serving; a stdout pipe
20
+ * the client has already closed ends the process cleanly, since there is no
21
+ * one left to answer; and every other way the process stops says why on
22
+ * stderr, which MCP clients keep as the server's log.
23
+ */
24
+ export function guardServerProcess(proc = process) {
25
+ proc.on("uncaughtException", (error) => {
26
+ log("ERROR", "Uncaught exception; the server is still running", error);
27
+ });
28
+ proc.on("unhandledRejection", (reason) => {
29
+ log("ERROR", "Unhandled promise rejection", reason);
30
+ });
31
+ proc.stdout.on("error", (error) => {
32
+ if (error.code === "EPIPE") {
33
+ log("INFO", "The MCP client closed the connection (stdout pipe closed); shutting down");
34
+ proc.exit(0);
35
+ return;
36
+ }
37
+ log("ERROR", "Writing to the MCP client failed", error);
38
+ });
39
+ proc.stdin.on("end", () => {
40
+ log("INFO", "The MCP client closed stdin; the server will stop");
41
+ });
42
+ proc.on("exit", (code) => {
43
+ if (code === 0)
44
+ return;
45
+ log("ERROR", `Brightspace MCP server exited with code ${code}. ${RECONNECT_HINT}`);
46
+ });
47
+ }
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.9",
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"