brightspace-mcp-server 5.1.4 → 5.1.6

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.
@@ -11,7 +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
+ import { devActivity, httpOperation } from "../utils/dev-activity.js";
15
15
  /**
16
16
  * True when a followed redirect ended on this Brightspace's sign-in page. A
17
17
  * web route asked with a dead cookie 302s there rather than serving the
@@ -426,8 +426,8 @@ export class D2LApiClient {
426
426
  return send(loggedIn);
427
427
  }
428
428
  /** Buckets one observed HTTP status into statsData.statusClasses. */
429
- recordStatus(status) {
430
- devActivity("http_response", { status });
429
+ recordStatus(status, path) {
430
+ devActivity("http_response", { status, operation: httpOperation(path) });
431
431
  const classes = this.statsData.statusClasses;
432
432
  if (status >= 200 && status < 300)
433
433
  classes["2xx"]++;
@@ -490,7 +490,7 @@ export class D2LApiClient {
490
490
  headers,
491
491
  signal: AbortSignal.timeout(this.timeoutMs),
492
492
  });
493
- this.recordStatus(response.status);
493
+ this.recordStatus(response.status, path);
494
494
  // Preserve cookie material: a 401 only rejects this access token.
495
495
  if (response.status === 401) {
496
496
  throw new ApiError(401, path, "Brightspace rejected the access token.");
@@ -558,7 +558,7 @@ export class D2LApiClient {
558
558
  headers,
559
559
  signal: idle.signal,
560
560
  });
561
- this.recordStatus(response.status);
561
+ this.recordStatus(response.status, path);
562
562
  // Preserve cookie material for the shared HTTP refresh path.
563
563
  if (response.status === 401) {
564
564
  throw new ApiError(401, path, "Brightspace rejected the access token.");
@@ -10,7 +10,7 @@ import * as path from "node:path";
10
10
  import { log } from "../utils/logger.js";
11
11
  import { AuthError } from "../utils/errors.js";
12
12
  import { AUTH_COMMAND } from "../utils/commands.js";
13
- import { devActivity } from "../utils/dev-activity.js";
13
+ import { devActivity, newRecoveryId, RECOVERY_ID_ENV } from "../utils/dev-activity.js";
14
14
  import { parsePhaseMarker } from "./auth-phases.js";
15
15
  import { AUTOMATIC_PENDING_MARKER, RELAYED_CHALLENGE_MARKER, RELAYED_STARTING_MARKER, authLockPath, challengeRelayedAt, relayChallenge } from "./mfa-challenge.js";
16
16
  import { inspectLockOwner } from "./auth-lock.js";
@@ -693,7 +693,8 @@ export class AuthRunner {
693
693
  */
694
694
  async spawnAuth() {
695
695
  const started = Date.now();
696
- devActivity("recovery_started");
696
+ const recoveryId = newRecoveryId();
697
+ devActivity("recovery_started", { recoveryId });
697
698
  log("INFO", "Auto-launching brightspace-auth...");
698
699
  return await new Promise((resolve, reject) => {
699
700
  const child = spawn(process.execPath, // use the same Node binary
@@ -702,7 +703,8 @@ export class AuthRunner {
702
703
  // Background recovery never opens a browser over the user's work
703
704
  // unless D2L_HEADLESS explicitly asks for one; the saved
704
705
  // visible-browser preference applies to manual sign-in only.
705
- env: { D2L_HEADLESS: "true", ...process.env },
706
+ // The recovery id lets the child's dev activity join this recovery.
707
+ env: { D2L_HEADLESS: "true", ...process.env, [RECOVERY_ID_ENV]: recoveryId },
706
708
  stdio: ["ignore", "pipe", "pipe"],
707
709
  detached: process.platform !== "win32",
708
710
  windowsHide: true,
@@ -831,7 +833,7 @@ export class AuthRunner {
831
833
  const firstChallenge = this.pendingChallenge === null || this.pendingChallenge.kind === "automaticPending";
832
834
  startWatching();
833
835
  if (firstChallenge)
834
- devActivity("mfa_observed", { elapsedMs: Date.now() - started });
836
+ devActivity("mfa_observed", { elapsedMs: Date.now() - started, recoveryId });
835
837
  if (firstChallenge || matched) {
836
838
  this.pendingChallenge = { kind: "mfaPending", numberMatch: matched ?? this.pendingChallenge?.numberMatch };
837
839
  }
@@ -872,7 +874,7 @@ export class AuthRunner {
872
874
  if (childFinished)
873
875
  return;
874
876
  childFinished = true;
875
- devActivity("recovery_finished", { outcome: error ? "error" : "success", reason: error?.kind, elapsedMs: Date.now() - started });
877
+ devActivity("recovery_finished", { outcome: error ? "error" : "success", reason: error?.kind, elapsedMs: Date.now() - started, recoveryId });
876
878
  clearTimeout(timer);
877
879
  if (killTimer)
878
880
  clearTimeout(killTimer);
@@ -914,7 +916,7 @@ export class AuthRunner {
914
916
  const numberMarker = MFA_NUMBER_MARKER.exec(line);
915
917
  const phaseMarker = parsePhaseMarker(line);
916
918
  if (phaseMarker) {
917
- devActivity("auth_phase", phaseMarker);
919
+ devActivity("auth_phase", { ...phaseMarker, recoveryId });
918
920
  }
919
921
  else if (numberMarker) {
920
922
  numberMatch = numberMarker[1];
package/build/auth-cli.js CHANGED
@@ -59,7 +59,7 @@ async function main() {
59
59
  const config = await loadConfig();
60
60
  // The sign-in itself runs here, not in the server, so the dev activity log
61
61
  // only sees its MFA events if this process is configured for them too.
62
- configureDevActivity(config.sessionDir);
62
+ configureDevActivity(config.sessionDir, { version: pkg.version, role: "auth" });
63
63
  console.error(`\n=== Brightspace Authentication v${pkg.version} ===\n`);
64
64
  console.error(config.headless
65
65
  ? "Authentication runs headlessly. MFA numbers and code prompts appear here."
package/build/index.js CHANGED
@@ -96,7 +96,7 @@ else {
96
96
  const { setActiveShortcuts } = await import("./utils/two-factor-shortcuts.js");
97
97
  const { startUpdateChecks } = await import("./utils/update-checker.js");
98
98
  const { createRuntime } = await import("./server.js");
99
- configureDevActivity(config.sessionDir);
99
+ configureDevActivity(config.sessionDir, { version: PKG_VERSION, role: "server" });
100
100
  // Lets the MFA approval message mention the setup options only to users
101
101
  // who have neither turned on. Booleans only; the key is never passed.
102
102
  setActiveShortcuts({ rememberMfa: config.rememberMfa === true, keySaved: Boolean(config.totpUri) });
@@ -4,27 +4,80 @@ import { appendFileSync, mkdirSync, statSync } from "node:fs";
4
4
  import { join } from "node:path";
5
5
  import { AUTH_PHASES } from "../auth/auth-phases.js";
6
6
  import { ERROR_KINDS, errorKind, resultErrorKind } from "./error-kind.js";
7
+ const API = String.raw `^/d2l/api/l[pe]/[^/]+/`;
8
+ const COURSE = API + String.raw `\d+/`;
9
+ /** First match wins; a path that matches none is "other". Matched on the path alone, query dropped. */
10
+ const HTTP_OPERATIONS = [
11
+ ["versions", "^/d2l/api/versions/"],
12
+ ["whoami", API + "users/whoami"],
13
+ ["enrollments", API + "enrollments/"],
14
+ ["groups", COURSE + String.raw `groupcategories/\d+/groups/`],
15
+ ["groupCategories", COURSE + "groupcategories/"],
16
+ ["ltiLinks", API + "lti/link/"],
17
+ ["quizList", COURSE + "quizzes/?$"],
18
+ ["quizAttempts", COURSE + String.raw `quizzes/\d+/attempts/`],
19
+ ["quiz", COURSE + "quizzes/"],
20
+ ["dropboxFolders", COURSE + "dropbox/folders/?$"],
21
+ ["dropboxSubmissions", COURSE + String.raw `dropbox/folders/\d+/submissions/`],
22
+ ["dropboxFeedback", COURSE + String.raw `dropbox/folders/\d+/feedback/`],
23
+ ["dropboxAttachment", COURSE + String.raw `dropbox/folders/\d+/attachments/`],
24
+ ["dropboxFolder", COURSE + "dropbox/folders/"],
25
+ ["dropboxFeedbackPage", "^/d2l/lms/dropbox/user/folder_user_view_feedback"],
26
+ ["rubrics", COURSE + "rubrics"],
27
+ ["grades", COURSE + "grades/"],
28
+ ["contentFile", COURSE + String.raw `content/topics/\d+/file`],
29
+ ["content", COURSE + "content/"],
30
+ ["newsAttachment", COURSE + String.raw `news/\d+/attachments/`],
31
+ ["news", COURSE + "news/"],
32
+ ["discussions", COURSE + "discussions/"],
33
+ ["overviewAttachment", COURSE + "overview/attachment"],
34
+ ["overview", COURSE + "overview"],
35
+ ["classlist", COURSE + "classlist/"],
36
+ ["quickLink", "^/d2l/common/dialogs/quickLink/"],
37
+ // The LTI launch page a video quickLink leads to (get_video_transcript).
38
+ ["ltiLaunch", String.raw `^/d2l/le/lti/\d+/toolLaunch/`],
39
+ ];
40
+ const OPERATION_PATTERNS = HTTP_OPERATIONS.map(([name, pattern]) => [name, new RegExp(pattern)]);
41
+ const OPERATION_NAMES = [...HTTP_OPERATIONS.map(([name]) => name), "other"];
42
+ /** Names what a request was for without keeping its endpoint, IDs or query. */
43
+ export function httpOperation(path) {
44
+ const bare = path.split(/[?#]/, 1)[0];
45
+ return OPERATION_PATTERNS.find(([, pattern]) => pattern.test(bare))?.[0] ?? "other";
46
+ }
47
+ /** Names one background recovery; the parent passes it to its auth child so their events join. */
48
+ export const RECOVERY_ID_ENV = "D2L_RECOVERY_ID";
49
+ const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7
50
  const calls = new AsyncLocalStorage();
8
51
  const runId = randomUUID();
9
52
  const MAX_BYTES = 5 * 1024 * 1024;
10
53
  let directory;
11
54
  let warned = false;
55
+ /** Set in an auth child: the parent recovery every event of this process belongs to. */
56
+ let processRecoveryId;
12
57
  let lastStart;
13
58
  let lastSuccess;
14
59
  /** Explicit opt-in; separate from ordinary stderr debugging. */
15
- export function configureDevActivity(sessionDir) {
60
+ export function configureDevActivity(sessionDir, run) {
16
61
  directory = process.env.D2L_DEV_MODE === "true" ? join(sessionDir, "dev-activity") : undefined;
17
62
  lastStart = lastSuccess = undefined;
18
63
  warned = false;
64
+ const inherited = process.env[RECOVERY_ID_ENV];
65
+ processRecoveryId = inherited && ID.test(inherited) ? inherited : undefined;
66
+ if (run)
67
+ write("run_started", {}, undefined, undefined, undefined, run);
68
+ }
69
+ /** A fresh, non-secret id for one recovery; see RECOVERY_ID_ENV. */
70
+ export function newRecoveryId() {
71
+ return randomUUID();
19
72
  }
20
73
  /** No arbitrary text, errors, URLs, request arguments or response content. */
21
74
  export function devActivity(event, fields = {}) {
22
75
  write(event, fields);
23
76
  }
24
- function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs) {
77
+ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs, run) {
25
78
  if (!directory)
26
79
  return;
27
- if (!["tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
80
+ if (!["run_started", "tool_started", "tool_finished", "http_response", "auth_required", "token_mint_started",
28
81
  "token_mint_finished", "recovery_started", "mfa_observed", "recovery_finished", "auth_phase",
29
82
  "mfa_method_selected", "mfa_code_submitted"].includes(event))
30
83
  return;
@@ -32,6 +85,17 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
32
85
  const record = { at, runId, pid: process.pid, event };
33
86
  if (context)
34
87
  Object.assign(record, context);
88
+ // A child's inherited id wins: it is the one its parent recorded.
89
+ const recoveryId = processRecoveryId ?? fields.recoveryId;
90
+ if (recoveryId && ID.test(recoveryId))
91
+ record.recoveryId = recoveryId;
92
+ if (run) {
93
+ if (/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]{1,40})?$/.test(run.version))
94
+ record.version = run.version;
95
+ record.node = process.versions.node;
96
+ if (run.role === "server" || run.role === "auth")
97
+ record.role = run.role;
98
+ }
35
99
  for (const [key, value] of Object.entries({ elapsedMs: fields.elapsedMs, status: fields.status, idleMs, sinceSuccessMs })) {
36
100
  if (typeof value === "number" && Number.isFinite(value) && value >= 0)
37
101
  record[key] = value;
@@ -45,6 +109,8 @@ function write(event, fields, context = calls.getStore(), idleMs, sinceSuccessMs
45
109
  record.phase = fields.phase;
46
110
  if (ERROR_KINDS.includes(fields.errorKind ?? ""))
47
111
  record.errorKind = fields.errorKind;
112
+ if (OPERATION_NAMES.includes(fields.operation ?? ""))
113
+ record.operation = fields.operation;
48
114
  try {
49
115
  mkdirSync(directory, { recursive: true, mode: 0o700 });
50
116
  const file = join(directory, `activity-${at.slice(0, 10)}-${process.pid}-${runId}.jsonl`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.4",
3
+ "version": "5.1.6",
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",