brightspace-mcp-server 5.1.22 → 5.1.24

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
@@ -159,7 +159,7 @@ npx -y brightspace-mcp-server@latest auth
159
159
 
160
160
  **Want fewer 2FA prompts?** `setup` asks one question, "How often do you want to approve sign-ins on your phone?": **1** every time (default), **2** remember this computer, or **3** let this computer answer Microsoft's code requests itself (needs an authenticator setup key). Options 2 and 3 are opt-in and not for shared computers; to turn them off, rerun `setup` and pick 1. The tradeoffs and the steps for getting the key are in [docs/sign-in.md](docs/sign-in.md#fewer-2fa-prompts).
161
161
 
162
- **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Asking your AI app also cancels a sign-in still waiting for you to approve it; the `auth --logout` command instead refuses and deletes nothing while a sign-in is in progress. Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
162
+ **To sign out early** (on a shared computer, say), just ask your AI app to sign you out of Brightspace, or run `npx -y brightspace-mcp-server@latest auth --logout` in a terminal. It deletes this computer's saved Brightspace session, so the next question does a full sign-in. Your saved password, authenticator key, and settings are kept, so with a saved authenticator key that sign-in can finish without you; on a shared computer, also delete the saved `brightspace-mcp-server` entries from your computer's password store (Keychain Access on a Mac, Credential Manager on Windows). Other AI apps open on this computer are signed out too and will ask you to sign in on their next question. (An app you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.) It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. Both also cancel a sign-in an AI app started that is still waiting for you to approve it (`auth --logout` can't on Windows, and leaves an `auth` run in another terminal alone, deleting nothing). Cancelling doesn't get you a new prompt any sooner: Microsoft won't send another until the earlier one expires, up to 5 minutes after it was sent.
163
163
 
164
164
  How often you're asked is up to your school, not this tool. Everything else about sign-in — Duo, authenticator codes, visible-browser mode, and running without a browser at all (Docker, WSL, hardware keys) — is in [docs/sign-in.md](docs/sign-in.md).
165
165
 
@@ -119,7 +119,7 @@ export async function inspectLockOwner(lockPath) {
119
119
  /**
120
120
  * The PID of a live "automatic" owner (a background AuthRunner child), or
121
121
  * undefined when the lock is free, its owner is gone, or it is held some other
122
- * way. Used by sign_out to cancel another app's waiting sign-in.
122
+ * way. Used by sign_out and `auth --logout` to cancel another app's waiting sign-in.
123
123
  */
124
124
  export async function liveAutomaticOwner(lockPath) {
125
125
  const owner = await readOwner(lockPath);
@@ -243,16 +243,25 @@ function forceStop(pid) {
243
243
  const CANCEL_WAIT_MS = 5000;
244
244
  /**
245
245
  * Stop a background sign-in that ANOTHER server process on this computer
246
- * started and that holds the cross-process lock, so sign_out can cancel a
247
- * sign-in whichever app began it. Only an "automatic" owner is stopped (a
246
+ * started and that holds the cross-process lock, so sign_out and
247
+ * `auth --logout` can cancel a sign-in whichever app began it. Only an "automatic" owner is stopped (a
248
248
  * person's `auth` run in a terminal never is), and only after its command
249
249
  * line confirms it is this package's sign-in child: the PID comes from a file,
250
250
  * and a PID the system has since reused must never be killed. That check
251
- * needs ps, so on Windows nothing is stopped and sign_out reports the sign-in
252
- * as one it cannot cancel. Its own server's waiting calls are answered
251
+ * needs ps, so on Windows nothing is stopped and sign_out or `auth --logout`
252
+ * reports the sign-in as one it cannot cancel. Its own server's waiting calls are answered
253
253
  * "cancelled" (see the close handler in spawnAuth). The dead owner leaves a
254
254
  * stale lock the next lock holder reclaims. Resolves true once it has exited.
255
255
  */
256
+ /**
257
+ * True when a `ps` command line is this package's background sign-in child:
258
+ * `auth-cli.js` as a whole path segment, followed later by a standalone
259
+ * `--automatic`. The runtime binary is not checked, since an app may run the
260
+ * server on an embedded runtime that isn't named node.
261
+ */
262
+ export function isAutomaticSignInCommand(command) {
263
+ return /(?:^|[\s/\\])auth-cli\.js\s(?:.*\s)?--automatic(?:\s|$)/.test(command.trim());
264
+ }
256
265
  export async function stopAutomaticSignIn(lockPath) {
257
266
  if (process.platform === "win32")
258
267
  return false;
@@ -266,17 +275,20 @@ export async function stopAutomaticSignIn(lockPath) {
266
275
  catch {
267
276
  return false;
268
277
  }
269
- if (!/auth-cli\.js\b/.test(command) || !command.includes("--automatic"))
278
+ if (!isAutomaticSignInCommand(command))
270
279
  return false;
271
- log("WARN", `Cancelling another app's background sign-in (pid ${pid}) for sign_out`);
280
+ log("WARN", `Cancelling another app's background sign-in (pid ${pid}) to sign out`);
272
281
  forceStop(pid);
273
282
  const deadline = Date.now() + CANCEL_WAIT_MS;
274
283
  while (Date.now() < deadline) {
284
+ // Only "no such process" means it exited: EPERM is a live process this
285
+ // user cannot signal, which the SIGKILL did not stop either.
275
286
  try {
276
287
  process.kill(pid, 0);
277
288
  }
278
- catch {
279
- return true;
289
+ catch (error) {
290
+ if (error.code === "ESRCH")
291
+ return true;
280
292
  }
281
293
  await new Promise((resolve) => setTimeout(resolve, 50));
282
294
  }
@@ -3,8 +3,9 @@
3
3
  *
4
4
  * `auth --logout`: end the saved Brightspace session on this computer early.
5
5
  *
6
- * This is a local sign-out only. It deletes the files that let the next tool
7
- * call skip the browser, so that call does a full sign-in. It does not log out
6
+ * This is a local sign-out only. It first stops a background sign-in an AI
7
+ * app started (as sign_out does), then deletes the files that let the next
8
+ * tool call skip the browser, so that call does a full sign-in. It does not log out
8
9
  * of Microsoft or D2L on their servers, and it leaves the saved password,
9
10
  * config.json, and every credential-store entry (including the session
10
11
  * encryption key, which the next sign-in reuses) exactly as they were.
@@ -15,6 +16,7 @@ import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.j
15
16
  import { BROWSER_STATE_FILE, LEGACY_BROWSER_STATE_FILE } from "./browser-state-store.js";
16
17
  import { MICROSOFT_SESSION_FILE } from "./microsoft-session.js";
17
18
  import { authLockPath } from "./mfa-challenge.js";
19
+ import { stopAutomaticSignIn } from "./auth-runner.js";
18
20
  import { SESSION_FILE, SessionStore } from "./session-store.js";
19
21
  import { AUTH_COMMAND } from "../utils/commands.js";
20
22
  import { resolveSessionLocation } from "../utils/config.js";
@@ -94,16 +96,38 @@ export async function clearSavedSession(sessionDir) {
94
96
  const KEPT = "Kept: your saved password, config.json, and any authenticator or credential-store entries.";
95
97
  const MEMORY_NOTE = "Other Brightspace apps open on this computer sign out on their next request. One you opened before updating may keep answering for up to an hour; quit and reopen it to sign it out right away.";
96
98
  const LOCAL_ONLY = "This only clears files on this computer. It does not sign you out of Microsoft or Brightspace on their servers.";
99
+ const CANCELLED = "Cancelled the Brightspace sign-in that was waiting for approval, so the prompt or number from it no longer works.";
100
+ const NEXT_PROMPT = "Microsoft keeps that prompt open for up to 5 minutes after sending it and sends this account no other until then, so the next sign-in can take that long to show a new number to approve.";
97
101
  /**
98
102
  * The `auth --logout` command. Resolves to the process exit code: 0 when the
99
- * session was cleared or there was nothing to clear, 2 when a sign-in is in
100
- * progress (the same code `auth` uses for that), 1 for anything else.
103
+ * session was cleared or there was nothing to clear, 2 when a sign-in or
104
+ * sign-out it can't stop holds the lock (the same code `auth` uses for that),
105
+ * 1 for anything else. A background sign-in an AI app started is stopped
106
+ * first; a person's `auth` run is never stopped.
101
107
  */
102
108
  export async function runLogout(options = {}) {
103
109
  const print = options.print ?? ((line) => console.error(line));
104
110
  try {
105
111
  const sessionDir = options.sessionDir ?? resolveSessionLocation().sessionDir;
106
- const { removed } = await clearSavedSession(sessionDir);
112
+ // A sign-in an AI app started in the background holds the lock, and a
113
+ // terminal has no other way to stop it, so stop it as sign_out does.
114
+ const stopBackground = options.stopBackground ?? ((dir) => stopAutomaticSignIn(authLockPath(dir)));
115
+ const cancelled = await stopBackground(sessionDir);
116
+ if (cancelled)
117
+ print(CANCELLED);
118
+ let removed;
119
+ try {
120
+ ({ removed } = await clearSavedSession(sessionDir));
121
+ }
122
+ catch (error) {
123
+ // The lock was taken again between stopping one sign-in and clearing:
124
+ // most likely the AI app's next question started a new one.
125
+ if (cancelled && error instanceof AuthenticationInProgressError) {
126
+ print("Not signed out: a new sign-in started before the session could be cleared, so nothing was deleted. Run this command again.");
127
+ return 2;
128
+ }
129
+ throw error;
130
+ }
107
131
  if (removed.length === 0) {
108
132
  print("No saved Brightspace session was found on this computer, so there is nothing to clear.");
109
133
  }
@@ -114,13 +138,15 @@ export async function runLogout(options = {}) {
114
138
  print(`The next Brightspace request, or \`${AUTH_COMMAND}\`, will do a full sign-in.`);
115
139
  }
116
140
  print(MEMORY_NOTE);
141
+ if (cancelled)
142
+ print(NEXT_PROMPT);
117
143
  print(LOCAL_ONLY);
118
144
  return 0;
119
145
  }
120
146
  catch (error) {
121
147
  if (error instanceof AuthenticationInProgressError) {
122
- print("Not signed out: a sign-in is in progress. Nothing was deleted.");
123
- print("Wait for it to finish (or cancel it), then run this command again.");
148
+ print("Not signed out: a sign-in or sign-out this command can't stop is in progress, so nothing was deleted.");
149
+ print("If it is an `auth` run in another terminal, finish or close it there. On Windows, a sign-in an AI app started can't be stopped from here: wait for it to finish (up to 5 minutes). Then run this command again.");
124
150
  return 2;
125
151
  }
126
152
  if (error instanceof RemoveError) {
@@ -23,6 +23,13 @@ function richTextHtml(field) {
23
23
  const nested = typeof field.Text === "object" && field.Text !== null ? field.Text.Html : null;
24
24
  return nested ?? field.Html ?? null;
25
25
  }
26
+ /** The highest Score among attempts D2L has scored, or null when none has one. */
27
+ function bestAttemptScore(attempts) {
28
+ const scores = (attempts ?? [])
29
+ .map((a) => a.Score)
30
+ .filter((score) => typeof score === "number");
31
+ return scores.length > 0 ? Math.max(...scores) : null;
32
+ }
26
33
  /**
27
34
  * The grade types a student is actually scored on. D2L documents exactly four
28
35
  * GradeObject shapes, told apart by a string GradeType; anything else a
@@ -764,9 +771,9 @@ async function fetchCourseInventory(apiClient, courseId, baseUrl) {
764
771
  attemptsUsed: completedAttempts?.length ?? null,
765
772
  attemptsRemaining,
766
773
  attemptWarning,
767
- bestScore: completedAttempts && completedAttempts.length > 0
768
- ? Math.max(...completedAttempts.map((a) => a.Score ?? 0))
769
- : null,
774
+ // An attempt awaiting manual grading carries Score null: it is not a
775
+ // zero, so only scored attempts count toward the best (issue #370).
776
+ bestScore: bestAttemptScore(completedAttempts),
770
777
  gracePeriodMinutes: quiz.SubmissionGracePeriod ?? null,
771
778
  hasPassword: Boolean(quiz.Password),
772
779
  // Additive fields, each emitted only when it has something to say.
@@ -27,10 +27,10 @@ function text(message, isError = false) {
27
27
  /**
28
28
  * Register sign_out tool.
29
29
  *
30
- * The in-chat version of `auth --logout`, plus one thing the command does not
31
- * do: it first cancels a background sign-in still waiting on MFA, this
32
- * server's (AuthRunner.cancel, which answers every call waiting on it) or
33
- * another app's on this computer (stopAutomaticSignIn). A user who missed the
30
+ * The in-chat version of `auth --logout`. Both first cancel a background
31
+ * sign-in another app on this computer started (stopAutomaticSignIn); this
32
+ * one also cancels this server's own (AuthRunner.cancel, which answers every
33
+ * call waiting on it), which the command has no server to do. A user who missed the
34
34
  * prompt otherwise waited out its 5-minute window for a new one. Then it
35
35
  * deletes this computer's saved
36
36
  * session for the account the server is configured with (clearSavedSession,
@@ -59,6 +59,13 @@ function quickLinkFilePath(input, courseId, origin) {
59
59
  function enforcedFilePath(input, courseId, origin) {
60
60
  let path = input;
61
61
  if (!path.startsWith("/")) {
62
+ // Check the raw authority before URL parsing can normalize it. The first
63
+ // literal slash must start the path, not occur inside a query or fragment.
64
+ const authorityStart = path.indexOf("//") + 2;
65
+ const pathStart = path.indexOf("/", authorityStart);
66
+ if (pathStart <= authorityStart ||
67
+ /[?#\\\x00-\x20\x7f]/.test(path.slice(authorityStart, pathStart)))
68
+ return null;
62
69
  let link;
63
70
  try {
64
71
  link = new URL(path);
@@ -68,7 +75,7 @@ function enforcedFilePath(input, courseId, origin) {
68
75
  }
69
76
  if (!origin || link.origin !== new URL(origin).origin || link.username || link.password)
70
77
  return null;
71
- path = path.slice(path.indexOf("/", path.indexOf("//") + 2));
78
+ path = path.slice(pathStart);
72
79
  }
73
80
  if (!path.startsWith(ENFORCED_PREFIX))
74
81
  return null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.22",
3
+ "version": "5.1.24",
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",