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 +1 -0
- package/build/api/client.js +3 -0
- package/build/auth/auth-runner.js +6 -0
- package/build/auth/token-manager.js +8 -0
- package/build/index.js +7 -4
- package/build/utils/dev-activity.js +88 -0
- package/build/utils/process-guard.js +47 -0
- package/package.json +1 -2
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
|
|
package/build/api/client.js
CHANGED
|
@@ -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
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
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.
|
|
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"
|