brightspace-mcp-server 3.13.2 → 3.13.3

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
@@ -124,6 +124,8 @@ sits beside your password, so on **this** computer your two factors become one.
124
124
  anyone who only has your password, and it changes nothing for your other devices. Full enrollment
125
125
  steps: [docs/sign-in.md](docs/sign-in.md#answering-code-challenges-without-your-phone).
126
126
 
127
+ **To sign out early** (on a shared computer, say), run `npx -y brightspace-mcp-server@latest auth --logout`. 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. An AI app that is already running can keep answering until its current access token expires; restart the app to drop it right away. It only clears this computer; it does not sign you out of Microsoft or Brightspace anywhere else. If a sign-in is in progress, it refuses and deletes nothing.
128
+
127
129
  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).
128
130
 
129
131
  ## Something not working?
@@ -9,6 +9,8 @@ import { SessionStoreError } from "../utils/errors.js";
9
9
  import { NativeCredentialStoreError } from "./credential-store.js";
10
10
  import { readEncryptedRecord, saveEncryptedRecord, trashFile } from "./encrypted-store.js";
11
11
  export const BROWSER_STATE_FILE = "storage-state.encrypted.json";
12
+ /** Plaintext browser state from before encryption; load() still adopts it if present. */
13
+ export const LEGACY_BROWSER_STATE_FILE = "storage-state.json";
12
14
  function validState(value) {
13
15
  const state = value;
14
16
  return !!state && Array.isArray(state.cookies) && Array.isArray(state.origins)
@@ -27,7 +29,7 @@ export class BrowserStateStore {
27
29
  this.sessionDir = sessionDir;
28
30
  this.options = options;
29
31
  this.file = path.join(sessionDir, BROWSER_STATE_FILE);
30
- this.legacyFile = path.join(sessionDir, "storage-state.json");
32
+ this.legacyFile = path.join(sessionDir, LEGACY_BROWSER_STATE_FILE);
31
33
  }
32
34
  storeError(action, error) {
33
35
  if (error instanceof NativeCredentialStoreError)
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Brightspace MCP Server. Copyright (c) 2026 Rohan Muppa. MIT licensed.
3
+ *
4
+ * `auth --logout`: end the saved Brightspace session on this computer early.
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
8
+ * of Microsoft or D2L on their servers, and it leaves the saved password,
9
+ * config.json, and every credential-store entry (including the session
10
+ * encryption key, which the next sign-in reuses) exactly as they were.
11
+ */
12
+ import * as fs from "node:fs/promises";
13
+ import * as path from "node:path";
14
+ import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
15
+ import { BROWSER_STATE_FILE, LEGACY_BROWSER_STATE_FILE } from "./browser-state-store.js";
16
+ import { MICROSOFT_SESSION_FILE } from "./microsoft-session.js";
17
+ import { authLockPath } from "./mfa-challenge.js";
18
+ import { SESSION_FILE, SessionStore } from "./session-store.js";
19
+ import { AUTH_COMMAND } from "../utils/commands.js";
20
+ import { resolveSessionLocation } from "../utils/config.js";
21
+ /** Browser state files other than the session token itself, in the order they are deleted. */
22
+ const BROWSER_STATE_FILES = [BROWSER_STATE_FILE, LEGACY_BROWSER_STATE_FILE, MICROSOFT_SESSION_FILE];
23
+ class RemoveError extends Error {
24
+ file;
25
+ constructor(file, cause) {
26
+ super(`Could not remove ${file}: ${cause instanceof Error ? cause.message : String(cause)}`);
27
+ this.file = file;
28
+ this.name = "RemoveError";
29
+ }
30
+ }
31
+ async function removeIfPresent(file) {
32
+ try {
33
+ await fs.unlink(file);
34
+ return true;
35
+ }
36
+ catch (error) {
37
+ if (error.code === "ENOENT")
38
+ return false;
39
+ throw new RemoveError(path.basename(file), error);
40
+ }
41
+ }
42
+ /**
43
+ * Delete the saved session files in one account's session directory.
44
+ *
45
+ * `sessionDir` is the resolved directory: `accounts/<hash>/` when a username
46
+ * is configured, the session root otherwise. Siblings, other accounts, and
47
+ * the pre-account files above `accounts/` are never this account's session
48
+ * and are not touched. The plaintext `storage-state.json` a version 1 install
49
+ * left behind is included because loading browser state would re-adopt it.
50
+ *
51
+ * Holds the sign-in lock for the duration, in a mode that neither takes over
52
+ * a live sign-in nor can be taken over, and throws AuthenticationInProgressError
53
+ * without deleting anything if one is running. It also waits out any session
54
+ * write already in flight rather than racing it.
55
+ */
56
+ export async function clearSavedSession(sessionDir) {
57
+ // Taking the lock creates its parent directory; there is nothing to clear
58
+ // (and nothing to create) when there is no directory at all.
59
+ try {
60
+ await fs.access(sessionDir);
61
+ }
62
+ catch (error) {
63
+ if (error.code === "ENOENT")
64
+ return { removed: [] };
65
+ throw error;
66
+ }
67
+ const release = await acquireProcessLock(authLockPath(sessionDir), { mode: "exclusive" });
68
+ try {
69
+ const removed = [];
70
+ if (await new SessionStore(sessionDir).remove())
71
+ removed.push(SESSION_FILE);
72
+ for (const name of BROWSER_STATE_FILES) {
73
+ if (await removeIfPresent(path.join(sessionDir, name)))
74
+ removed.push(name);
75
+ }
76
+ return { removed };
77
+ }
78
+ finally {
79
+ await release();
80
+ }
81
+ }
82
+ const KEPT = "Kept: your saved password, config.json, and any authenticator or credential-store entries.";
83
+ const MEMORY_NOTE = "A running Brightspace MCP server may still hold an access token in memory until it expires. Restart your AI client to drop it right away.";
84
+ const LOCAL_ONLY = "This only clears files on this computer. It does not sign you out of Microsoft or Brightspace on their servers.";
85
+ /**
86
+ * The `auth --logout` command. Resolves to the process exit code: 0 when the
87
+ * session was cleared or there was nothing to clear, 2 when a sign-in is in
88
+ * progress (the same code `auth` uses for that), 1 for anything else.
89
+ */
90
+ export async function runLogout(options = {}) {
91
+ const print = options.print ?? ((line) => console.error(line));
92
+ try {
93
+ const sessionDir = options.sessionDir ?? resolveSessionLocation().sessionDir;
94
+ const { removed } = await clearSavedSession(sessionDir);
95
+ if (removed.length === 0) {
96
+ print("No saved Brightspace session was found on this computer, so there is nothing to clear.");
97
+ }
98
+ else {
99
+ print("Signed out of Brightspace on this computer.");
100
+ print(`Removed: ${removed.join(", ")}`);
101
+ print(KEPT);
102
+ print(`The next Brightspace request, or \`${AUTH_COMMAND}\`, will do a full sign-in.`);
103
+ }
104
+ print(MEMORY_NOTE);
105
+ print(LOCAL_ONLY);
106
+ return 0;
107
+ }
108
+ catch (error) {
109
+ if (error instanceof AuthenticationInProgressError) {
110
+ print("Not signed out: a sign-in is in progress. Nothing was deleted.");
111
+ print("Wait for it to finish (or cancel it), then run this command again.");
112
+ return 2;
113
+ }
114
+ if (error instanceof RemoveError) {
115
+ print(`Sign-out is incomplete. ${error.message}`);
116
+ print("Close anything that may be using that file, then run this command again to finish.");
117
+ return 1;
118
+ }
119
+ print(`Could not clear the saved session: ${error instanceof Error ? error.message : String(error)}`);
120
+ return 1;
121
+ }
122
+ }
@@ -7,7 +7,8 @@ import * as fs from "node:fs/promises";
7
7
  import * as path from "node:path";
8
8
  import { writeFileAtomic } from "../utils/atomic-write.js";
9
9
  import { BROWSER_STATE_FILE } from "./browser-state-store.js";
10
- const SUMMARY_FILE = "microsoft-session.json";
10
+ /** The plain summary, relative to its session directory. */
11
+ export const MICROSOFT_SESSION_FILE = "microsoft-session.json";
11
12
  const ENTRA_HOST = "login.microsoftonline.com";
12
13
  const ENTRA_COOKIES = new Set(["ESTSAUTH", "ESTSAUTHPERSISTENT", "ESTSAUTHLIGHT"]);
13
14
  const OUTCOMES = ["ticked", "already", "absent", "unknown", "off"];
@@ -41,7 +42,7 @@ function validSummary(value) {
41
42
  }
42
43
  async function readSummary(sessionDir) {
43
44
  try {
44
- const summary = JSON.parse(await fs.readFile(path.join(sessionDir, SUMMARY_FILE), "utf8"));
45
+ const summary = JSON.parse(await fs.readFile(path.join(sessionDir, MICROSOFT_SESSION_FILE), "utf8"));
45
46
  return validSummary(summary) ? summary : undefined;
46
47
  }
47
48
  catch {
@@ -59,7 +60,7 @@ export async function recordMicrosoftSession(sessionDir, state, rememberMfa) {
59
60
  rememberMfa: rememberMfa ?? (await readSummary(sessionDir))?.rememberMfa ?? null,
60
61
  };
61
62
  await fs.mkdir(sessionDir, { recursive: true, mode: 0o700 });
62
- await writeFileAtomic(path.join(sessionDir, SUMMARY_FILE), JSON.stringify(summary), { mode: 0o600 });
63
+ await writeFileAtomic(path.join(sessionDir, MICROSOFT_SESSION_FILE), JSON.stringify(summary), { mode: 0o600 });
63
64
  }
64
65
  /** The summary for saved browser state, or undefined when there is none. */
65
66
  export async function readMicrosoftSession(sessionDir, now = Date.now()) {
@@ -12,6 +12,8 @@ import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.j
12
12
  import { hasSessionEncryptionKey, NativeCredentialStoreError } from "./credential-store.js";
13
13
  import { decrypt, readEncryptedRecord, saveEncryptedRecord, trashFile } from "./encrypted-store.js";
14
14
  const DEFAULT_SESSION_DIR = path.join(os.homedir(), ".d2l-session");
15
+ /** The saved access token, relative to its session directory. */
16
+ export const SESSION_FILE = "session.json";
15
17
  function validTenantOrigin(origin) {
16
18
  if (origin === undefined)
17
19
  return true;
@@ -47,7 +49,7 @@ export class SessionStore {
47
49
  constructor(sessionDir = DEFAULT_SESSION_DIR, options = {}) {
48
50
  this.options = options;
49
51
  this.sessionDir = sessionDir;
50
- this.sessionFilePath = path.join(sessionDir, "session.json");
52
+ this.sessionFilePath = path.join(sessionDir, SESSION_FILE);
51
53
  }
52
54
  async withWriteLock(operation) {
53
55
  await fs.mkdir(this.sessionDir, { recursive: true, mode: 0o700 });
@@ -212,6 +214,31 @@ export class SessionStore {
212
214
  this.storeError("clear", error);
213
215
  }
214
216
  }
217
+ /**
218
+ * Delete the saved session without decrypting it, so a damaged file or a
219
+ * locked credential store cannot keep someone signed in. Permanent rather
220
+ * than moved to Trash: a session that was signed out on purpose should not
221
+ * stay recoverable. The encryption key stays in the credential store for the
222
+ * next sign-in. Resolves true when a file was removed.
223
+ */
224
+ async remove() {
225
+ try {
226
+ return await this.withWriteLock(async () => {
227
+ try {
228
+ await fs.unlink(this.sessionFilePath);
229
+ return true;
230
+ }
231
+ catch (error) {
232
+ if (error.code === "ENOENT")
233
+ return false;
234
+ throw error;
235
+ }
236
+ });
237
+ }
238
+ catch (error) {
239
+ this.storeError("remove", error);
240
+ }
241
+ }
215
242
  /** Prevent a slow mint from replacing credentials saved by a newer login. */
216
243
  async saveIfCurrent(token, expected) {
217
244
  try {
package/build/auth-cli.js CHANGED
@@ -13,6 +13,7 @@ import { retireLegacyProfile } from "./auth/legacy-profile.js";
13
13
  import { formatPhaseMarker } from "./auth/auth-phases.js";
14
14
  import { AuthenticationInProgressError } from "./auth/auth-lock.js";
15
15
  import { AUTOMATIC_PENDING_MARKER, challengeMarker } from "./auth/mfa-challenge.js";
16
+ import { runLogout } from "./auth/logout.js";
16
17
  import { configureDevActivity } from "./utils/dev-activity.js";
17
18
  import { AUTH_COMMAND, SETUP_COMMAND } from "./utils/commands.js";
18
19
  import { initUpdateChecker, peekUpdateNotice } from "./utils/update-checker.js";
@@ -35,6 +36,12 @@ async function requestMfaCode() {
35
36
  }
36
37
  }
37
38
  async function main() {
39
+ // Local and offline: no banner, no update check, no hand-off to a newer
40
+ // release. It only deletes this machine's saved session files.
41
+ if (process.argv.includes("--logout")) {
42
+ process.exitCode = await runLogout();
43
+ return;
44
+ }
38
45
  const automatic = process.argv.includes("--automatic");
39
46
  // If this copy is stale, hand off to the current release instead of running
40
47
  // our own out-of-date sign-in code. No prompt, no user action.
@@ -17,6 +17,8 @@
17
17
  export const PACKAGE_NAME = "brightspace-mcp-server";
18
18
  /** Re-authenticate. Always runs the current published release. */
19
19
  export const AUTH_COMMAND = `npx -y ${PACKAGE_NAME}@latest auth`;
20
+ /** End the saved Brightspace session on this computer. Always runs the current published release. */
21
+ export const LOGOUT_COMMAND = `npx -y ${PACKAGE_NAME}@latest auth --logout`;
20
22
  /** Re-run the setup wizard. Always runs the current published release. */
21
23
  export const SETUP_COMMAND = `npx -y ${PACKAGE_NAME}@latest setup`;
22
24
  /** Diagnose a broken setup. Always runs the current published release. */
@@ -12,13 +12,15 @@ import { resolveStoredPassword } from "./secure-config.js";
12
12
  import { getStoredTotpUri } from "../auth/credential-store.js";
13
13
  import { normalizeTotpEnrollment } from "../auth/totp.js";
14
14
  import { migrateLegacyState } from "../auth/legacy-state.js";
15
- export async function loadConfig() {
16
- dotenv.config({ quiet: true });
17
- // A corrupt or permission-denied config.json must not take the whole server
18
- // down at startup: env vars alone are a complete, if less convenient,
19
- // configuration. The failure is still surfaced loudly (never silent) so a
20
- // broken file doesn't masquerade as "no config.json was ever created".
21
- // Idea from lmgveerhoek's fork (MIT).
15
+ /**
16
+ * The saved settings, or null when there are none to use. A corrupt or
17
+ * permission-denied config.json must not take the whole server down at
18
+ * startup: env vars alone are a complete, if less convenient, configuration.
19
+ * The failure is still surfaced loudly (never silent) so a broken file doesn't
20
+ * masquerade as "no config.json was ever created".
21
+ * Idea from lmgveerhoek's fork (MIT).
22
+ */
23
+ function readConfigStore() {
22
24
  let store = null;
23
25
  let storeLoadError;
24
26
  if (configStoreExists()) {
@@ -39,12 +41,43 @@ export async function loadConfig() {
39
41
  else {
40
42
  console.error("[config] No config.json found, using environment variables");
41
43
  }
42
- // Resolve sessionDir: env > store > default
44
+ return store;
45
+ }
46
+ /**
47
+ * Which school and account this configuration signs in as, and where that
48
+ * account's session lives (env > store > default for each). Shared by
49
+ * loadConfig and by commands that need only the location, such as
50
+ * `auth --logout`, so they can never disagree about where a session is.
51
+ */
52
+ function resolveAccountLocation(store) {
43
53
  const sessionRoot = process.env.D2L_SESSION_DIR
44
54
  ? expandTilde(process.env.D2L_SESSION_DIR)
45
55
  : store?.sessionDir
46
56
  ? expandTilde(store.sessionDir)
47
57
  : path.join(os.homedir(), ".d2l-session");
58
+ const configuredUrl = new URL(process.env.D2L_BASE_URL || store?.baseUrl || "https://purdue.brightspace.com");
59
+ if (configuredUrl.protocol !== "https:" || configuredUrl.username || configuredUrl.password) {
60
+ throw new Error("The Brightspace URL must be an HTTPS school URL without embedded credentials.");
61
+ }
62
+ const baseUrl = configuredUrl.origin;
63
+ const username = process.env.D2L_USERNAME || store?.username;
64
+ // A new account must never inherit another account's cookies, even at the same school.
65
+ return { baseUrl, username, sessionRoot, sessionDir: accountSessionDirectory(sessionRoot, baseUrl, username) };
66
+ }
67
+ /**
68
+ * Where the configured account keeps its saved session. Unlike loadConfig it
69
+ * reads no password and migrates nothing, so it works when the credential
70
+ * store is locked. `sessionDir` holds the account's files; `sessionRoot` is
71
+ * the directory above any `accounts/<hash>/` layer.
72
+ */
73
+ export function resolveSessionLocation() {
74
+ dotenv.config({ quiet: true });
75
+ const { sessionDir, sessionRoot } = resolveAccountLocation(readConfigStore());
76
+ return { sessionDir, sessionRoot };
77
+ }
78
+ export async function loadConfig() {
79
+ dotenv.config({ quiet: true });
80
+ const store = readConfigStore();
48
81
  // Code-entry and other interactive MFA methods need a visible browser.
49
82
  const headless = envBoolean(process.env.D2L_HEADLESS, "D2L_HEADLESS")
50
83
  ?? store?.headless
@@ -89,12 +122,7 @@ export async function loadConfig() {
89
122
  const envAccessToken = readEnvSecret(process.env.D2L_ACCESS_TOKEN, "D2L_ACCESS_TOKEN");
90
123
  const rawSessionCookie = readEnvSecret(process.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
91
124
  const envSessionCookie = rawSessionCookie ? parseSessionCookieEnv(rawSessionCookie) : undefined;
92
- const configuredUrl = new URL(process.env.D2L_BASE_URL || store?.baseUrl || "https://purdue.brightspace.com");
93
- if (configuredUrl.protocol !== "https:" || configuredUrl.username || configuredUrl.password) {
94
- throw new Error("The Brightspace URL must be an HTTPS school URL without embedded credentials.");
95
- }
96
- const baseUrl = configuredUrl.origin;
97
- const username = process.env.D2L_USERNAME || store?.username;
125
+ const { baseUrl, username, sessionRoot, sessionDir } = resolveAccountLocation(store);
98
126
  const password = passwordless ? undefined : await resolveStoredPassword(baseUrl, username, store);
99
127
  // Opt-in: only an enrollment the user deliberately saved makes the Entra
100
128
  // sign-in answer a verification-code challenge itself. With none, every MFA
@@ -110,8 +138,6 @@ export async function loadConfig() {
110
138
  // that reports a locked or missing store, loudly, for the credential the
111
139
  // server genuinely cannot work without.
112
140
  : username ? (await getStoredTotpUri(baseUrl, username).catch(() => null)) ?? undefined : undefined;
113
- // A new account must never inherit another account's cookies, even at the same school.
114
- const sessionDir = accountSessionDirectory(sessionRoot, baseUrl, username);
115
141
  const legacyMigration = sessionDir !== sessionRoot ? await migrateLegacyState(sessionRoot) : undefined;
116
142
  return {
117
143
  baseUrl,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.13.2",
3
+ "version": "3.13.3",
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",