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 +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 +3 -0
- package/build/utils/dev-activity.js +88 -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
|
@@ -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.
|
|
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"
|