brightspace-mcp-server 4.0.2 → 4.0.4
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/build/auth/credential-store.js +64 -39
- package/build/auth/microsoft-session.js +10 -0
- package/build/auth/username.js +15 -0
- package/build/setup/config.js +12 -1
- package/build/setup/prepare.js +2 -2
- package/build/setup.js +6 -2
- package/build/tools/get-server-info.js +1 -1
- package/build/utils/config.js +10 -2
- package/package.json +1 -1
|
@@ -8,6 +8,7 @@ import * as fs from "node:fs/promises";
|
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { spawn } from "node:child_process";
|
|
10
10
|
import { acquireProcessLock, AuthenticationInProgressError } from "./auth-lock.js";
|
|
11
|
+
import { canonicalUsername } from "./username.js";
|
|
11
12
|
const SERVICE = "brightspace-mcp-server";
|
|
12
13
|
const LINUX_GUIDANCE = "Linux requires secret-tool and an unlocked Secret Service. Install libsecret-tools (Debian/Ubuntu) or your distribution's libsecret tools, unlock the desktop keyring, then retry. Temporary kernel key storage is not supported.";
|
|
13
14
|
export class NativeCredentialStoreError extends Error {
|
|
@@ -158,63 +159,87 @@ export const nativeCredentialBackend = {
|
|
|
158
159
|
}
|
|
159
160
|
},
|
|
160
161
|
};
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
/**
|
|
163
|
+
* Where one of an account's secrets lives, canonical key first. Releases up to
|
|
164
|
+
* 4.0.0 hashed the username exactly as given, so that key follows as a legacy
|
|
165
|
+
* one to read from and clean up. The authenticator enrollment uses a separate
|
|
166
|
+
* `totp:` prefix so the seed can never be returned where a password is
|
|
167
|
+
* expected, and the same username rules so one account's two secrets live or
|
|
168
|
+
* die together. A Purdue-fork enrollment saved under the lowercased email is
|
|
169
|
+
* already the canonical key. TOTP idea from ElliotDrel/brightspace-mcp-server
|
|
170
|
+
* (branch codex/purdue-totp).
|
|
171
|
+
*/
|
|
172
|
+
function accountKeys(prefix, baseUrl, username) {
|
|
173
|
+
const origin = new URL(baseUrl).origin;
|
|
174
|
+
const key = (name) => `${prefix}:${createHash("sha256").update(JSON.stringify([origin, name])).digest("hex")}`;
|
|
175
|
+
const canonical = key(canonicalUsername(username));
|
|
176
|
+
const exact = key(username);
|
|
177
|
+
return exact === canonical ? [canonical] : [canonical, exact];
|
|
164
178
|
}
|
|
165
179
|
/**
|
|
166
|
-
*
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* Idea from ElliotDrel/brightspace-mcp-server (branch codex/purdue-totp).
|
|
180
|
+
* Read a secret under its canonical key, falling back to a legacy key. A
|
|
181
|
+
* legacy secret is copied to the canonical key and removed only once the copy
|
|
182
|
+
* reads back intact; a store that refuses any step keeps the legacy entry,
|
|
183
|
+
* and the secret is returned either way.
|
|
171
184
|
*/
|
|
172
|
-
function
|
|
173
|
-
const
|
|
174
|
-
|
|
185
|
+
async function getSecret(prefix, baseUrl, username, backend) {
|
|
186
|
+
const [canonical, ...legacy] = accountKeys(prefix, baseUrl, username);
|
|
187
|
+
const current = await backend.getPassword(SERVICE, canonical);
|
|
188
|
+
if (current !== null)
|
|
189
|
+
return current;
|
|
190
|
+
for (const account of legacy) {
|
|
191
|
+
const value = await backend.getPassword(SERVICE, account);
|
|
192
|
+
if (value === null)
|
|
193
|
+
continue;
|
|
194
|
+
try {
|
|
195
|
+
await backend.setPassword(SERVICE, canonical, value);
|
|
196
|
+
if (await backend.getPassword(SERVICE, canonical) === value)
|
|
197
|
+
await backend.deletePassword(SERVICE, account);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Migration is best effort; the legacy entry still answers next time.
|
|
201
|
+
}
|
|
202
|
+
return value;
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
async function setSecret(prefix, baseUrl, username, value, backend, unverified) {
|
|
207
|
+
const [account] = accountKeys(prefix, baseUrl, username);
|
|
208
|
+
await backend.setPassword(SERVICE, account, value);
|
|
209
|
+
if (await backend.getPassword(SERVICE, account) !== value)
|
|
210
|
+
throw new NativeCredentialStoreError(unverified);
|
|
211
|
+
}
|
|
212
|
+
/** Remove the secret under every key it may sit under. Safe when none was saved. */
|
|
213
|
+
async function deleteSecret(prefix, baseUrl, username, backend, kept) {
|
|
214
|
+
for (const account of accountKeys(prefix, baseUrl, username)) {
|
|
215
|
+
// Some native stores raise on deleting an entry that does not exist.
|
|
216
|
+
if (await backend.getPassword(SERVICE, account) === null)
|
|
217
|
+
continue;
|
|
218
|
+
await backend.deletePassword(SERVICE, account);
|
|
219
|
+
if (await backend.getPassword(SERVICE, account) !== null)
|
|
220
|
+
throw new NativeCredentialStoreError(kept);
|
|
221
|
+
}
|
|
175
222
|
}
|
|
176
223
|
/** The account's saved authenticator enrollment, or null when none was saved. */
|
|
177
224
|
export async function getStoredTotpUri(baseUrl, username, backend = nativeCredentialBackend) {
|
|
178
|
-
return
|
|
225
|
+
return getSecret("totp", baseUrl, username, backend);
|
|
179
226
|
}
|
|
180
227
|
export async function setStoredTotpUri(baseUrl, username, uri, backend = nativeCredentialBackend) {
|
|
181
|
-
|
|
182
|
-
await backend.setPassword(SERVICE, account, uri);
|
|
183
|
-
if (await backend.getPassword(SERVICE, account) !== uri) {
|
|
184
|
-
throw new NativeCredentialStoreError("The authenticator enrollment could not be verified in the native credential store. Existing configuration was preserved.");
|
|
185
|
-
}
|
|
228
|
+
await setSecret("totp", baseUrl, username, uri, backend, "The authenticator enrollment could not be verified in the native credential store. Existing configuration was preserved.");
|
|
186
229
|
}
|
|
187
230
|
/** Remove an account's saved authenticator enrollment. Safe when none was saved. */
|
|
188
231
|
export async function deleteStoredTotpUri(baseUrl, username, backend = nativeCredentialBackend) {
|
|
189
|
-
|
|
190
|
-
// Some native stores raise on deleting an entry that does not exist.
|
|
191
|
-
if (await backend.getPassword(SERVICE, account) === null)
|
|
192
|
-
return;
|
|
193
|
-
await backend.deletePassword(SERVICE, account);
|
|
194
|
-
if (await backend.getPassword(SERVICE, account) !== null) {
|
|
195
|
-
throw new NativeCredentialStoreError("The saved authenticator enrollment could not be removed from the native credential store.");
|
|
196
|
-
}
|
|
232
|
+
await deleteSecret("totp", baseUrl, username, backend, "The saved authenticator enrollment could not be removed from the native credential store.");
|
|
197
233
|
}
|
|
198
234
|
export async function getStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
|
|
199
|
-
return
|
|
235
|
+
return getSecret("password", baseUrl, username, backend);
|
|
200
236
|
}
|
|
201
237
|
export async function setStoredPassword(baseUrl, username, password, backend = nativeCredentialBackend) {
|
|
202
|
-
|
|
203
|
-
await backend.setPassword(SERVICE, account, password);
|
|
204
|
-
if (await backend.getPassword(SERVICE, account) !== password) {
|
|
205
|
-
throw new NativeCredentialStoreError("The saved password could not be verified in the native credential store. Existing configuration was preserved.");
|
|
206
|
-
}
|
|
238
|
+
await setSecret("password", baseUrl, username, password, backend, "The saved password could not be verified in the native credential store. Existing configuration was preserved.");
|
|
207
239
|
}
|
|
208
240
|
/** Remove an account's saved password, so passwordless sign-in leaves none behind. */
|
|
209
241
|
export async function deleteStoredPassword(baseUrl, username, backend = nativeCredentialBackend) {
|
|
210
|
-
|
|
211
|
-
// Some native stores raise on deleting an entry that does not exist.
|
|
212
|
-
if (await backend.getPassword(SERVICE, account) === null)
|
|
213
|
-
return;
|
|
214
|
-
await backend.deletePassword(SERVICE, account);
|
|
215
|
-
if (await backend.getPassword(SERVICE, account) !== null) {
|
|
216
|
-
throw new NativeCredentialStoreError("The saved password could not be removed from the native credential store. Existing configuration was preserved.");
|
|
217
|
-
}
|
|
242
|
+
await deleteSecret("password", baseUrl, username, backend, "The saved password could not be removed from the native credential store. Existing configuration was preserved.");
|
|
218
243
|
}
|
|
219
244
|
function decodeKey(value) {
|
|
220
245
|
if (!/^[a-f0-9]{64}$/i.test(value)) {
|
|
@@ -7,6 +7,15 @@ 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 STAY_SIGNED_IN_NOTE = "Stay signed in keeps the Microsoft sign-in only; your school can still ask for 2FA on a new sign-in.";
|
|
11
|
+
/** One sentence for each Don't ask again outcome, after STAY_SIGNED_IN_NOTE. */
|
|
12
|
+
const REMEMBER_MFA_NOTES = {
|
|
13
|
+
ticked: "\"Don't ask again\" was ticked, so Microsoft may skip 2FA for a while; your school decides how long.",
|
|
14
|
+
already: "\"Don't ask again\" was already on, so Microsoft may skip 2FA for a while; your school decides how long.",
|
|
15
|
+
absent: "Your school's sign-in page didn't offer \"Don't ask again\", so 2FA is asked whenever your school requires it.",
|
|
16
|
+
unknown: "Whether \"Don't ask again\" applied isn't known yet; it is recorded at the next sign-in that reaches 2FA.",
|
|
17
|
+
off: "\"Don't ask again\" is off. To be asked less often, rerun setup and pick \"Less often\" (or set D2L_REMEMBER_MFA=true).",
|
|
18
|
+
};
|
|
10
19
|
/** The plain summary, relative to its session directory. */
|
|
11
20
|
export const MICROSOFT_SESSION_FILE = "microsoft-session.json";
|
|
12
21
|
const ENTRA_HOST = "login.microsoftonline.com";
|
|
@@ -80,5 +89,6 @@ export async function readMicrosoftSession(sessionDir, now = Date.now()) {
|
|
|
80
89
|
staySignedInExpires: staySignedIn && expires > 0 ? new Date(expires * 1000).toISOString() : null,
|
|
81
90
|
rememberMfa: summary.rememberMfa?.outcome ?? "unknown",
|
|
82
91
|
rememberMfaAt: summary.rememberMfa?.at ?? null,
|
|
92
|
+
note: `${STAY_SIGNED_IN_NOTE} ${REMEMBER_MFA_NOTES[summary.rememberMfa?.outcome ?? "unknown"]}`,
|
|
83
93
|
};
|
|
84
94
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT. See LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* The form of a username that names its account. An email address (a
|
|
8
|
+
* Microsoft Entra or Google sign-in) is case-insensitive, so it is lowercased;
|
|
9
|
+
* a plain username keeps its case, because a school may treat it as
|
|
10
|
+
* case-sensitive. Surrounding whitespace is never part of anyone's name.
|
|
11
|
+
*/
|
|
12
|
+
export function canonicalUsername(username) {
|
|
13
|
+
const trimmed = username.trim();
|
|
14
|
+
return trimmed.includes("@") ? trimmed.toLowerCase() : trimmed;
|
|
15
|
+
}
|
package/build/setup/config.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The settings setup saves, shared by the wizard, prepare and sign-in.
|
|
7
7
|
*/
|
|
8
|
+
import { canonicalUsername } from "../auth/username.js";
|
|
8
9
|
import { configStoreExists, loadConfigStore } from "../utils/config-store.js";
|
|
9
10
|
// ── URL validation ─────────────────────────────────────────────────
|
|
10
11
|
export function normalizeUrl(input) {
|
|
@@ -50,6 +51,16 @@ export function sameSchool(stored, chosen) {
|
|
|
50
51
|
return false;
|
|
51
52
|
}
|
|
52
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* The username to save: the typed one in canonical form, unless it names the
|
|
56
|
+
* account already saved. That one keeps its saved spelling, because releases
|
|
57
|
+
* up to 4.0.0 keyed its password, enrollment and session directory on the
|
|
58
|
+
* exact case, and only that spelling still derives those legacy keys.
|
|
59
|
+
*/
|
|
60
|
+
export function savedUsername(saved, typed) {
|
|
61
|
+
const canonical = canonicalUsername(typed);
|
|
62
|
+
return saved !== undefined && canonicalUsername(saved) === canonical ? saved : canonical;
|
|
63
|
+
}
|
|
53
64
|
/**
|
|
54
65
|
* Merge the wizard's answers over the settings already saved.
|
|
55
66
|
*
|
|
@@ -68,7 +79,7 @@ export function buildConfigToSave(existing, answers) {
|
|
|
68
79
|
const config = {
|
|
69
80
|
...carried,
|
|
70
81
|
baseUrl: answers.baseUrl,
|
|
71
|
-
username: answers.username,
|
|
82
|
+
username: savedUsername(carried?.username, answers.username),
|
|
72
83
|
// Always the freshly typed one: a carried v1 plaintext password would
|
|
73
84
|
// otherwise be the value written to the native store.
|
|
74
85
|
password: answers.password,
|
package/build/setup/prepare.js
CHANGED
|
@@ -180,7 +180,7 @@ export async function runPrepare(args, overrides = {}) {
|
|
|
180
180
|
delete config.password;
|
|
181
181
|
delete config.totpUri;
|
|
182
182
|
const notes = [];
|
|
183
|
-
if (carried?.username && args.username && carried.username !==
|
|
183
|
+
if (carried?.username && args.username && carried.username !== config.username) {
|
|
184
184
|
notes.push(`was ${carried.username}; sign-in starts fresh for the new account`);
|
|
185
185
|
}
|
|
186
186
|
if (!username)
|
|
@@ -214,7 +214,7 @@ export async function runPrepare(args, overrides = {}) {
|
|
|
214
214
|
}
|
|
215
215
|
if (username) {
|
|
216
216
|
try {
|
|
217
|
-
const token = await deps.getSessionToken(baseUrl, sessionDirFor(deps.env, config, baseUrl, username));
|
|
217
|
+
const token = await deps.getSessionToken(baseUrl, sessionDirFor(deps.env, config, baseUrl, config.username));
|
|
218
218
|
signedIn = Boolean(token);
|
|
219
219
|
}
|
|
220
220
|
catch {
|
package/build/setup.js
CHANGED
|
@@ -21,7 +21,7 @@ import { askShortcutQuestion } from "./utils/two-factor-shortcuts.js";
|
|
|
21
21
|
import { DOCTOR_COMMAND, prepareCommand, signInCommand } from "./utils/commands.js";
|
|
22
22
|
import { serverCommand } from "./utils/mcp-client-cli.js";
|
|
23
23
|
import { parseSetupArgs, SETUP_USAGE } from "./setup/args.js";
|
|
24
|
-
import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool } from "./setup/config.js";
|
|
24
|
+
import { buildConfigToSave, isValidUrl, normalizeUrl, readExistingConfig, sameSchool, savedUsername } from "./setup/config.js";
|
|
25
25
|
import { detectMcpClients, isChatGPTInstalled } from "./setup/clients.js";
|
|
26
26
|
import { presetForBaseUrl } from "./setup/presets.js";
|
|
27
27
|
import { formatPrepare, runPrepare } from "./setup/prepare.js";
|
|
@@ -254,7 +254,9 @@ async function interactive(args) {
|
|
|
254
254
|
}
|
|
255
255
|
let preset = args.preset ?? (baseUrl ? presetForBaseUrl(baseUrl)?.preset : undefined);
|
|
256
256
|
const carried = baseUrl && saved && sameSchool(saved.baseUrl, baseUrl) ? saved : null;
|
|
257
|
-
const knownUsername = args.username
|
|
257
|
+
const knownUsername = args.username !== undefined
|
|
258
|
+
? savedUsername(carried?.username, args.username)
|
|
259
|
+
: mode === "sign-in" ? carried?.username : undefined;
|
|
258
260
|
let passwordSaved = false;
|
|
259
261
|
if (mode === "sign-in" && baseUrl && knownUsername) {
|
|
260
262
|
try {
|
|
@@ -341,6 +343,8 @@ async function interactive(args) {
|
|
|
341
343
|
else {
|
|
342
344
|
console.log(dim(` Username: ${username}`));
|
|
343
345
|
}
|
|
346
|
+
// The saved spelling of the same account still finds what 4.0.0 saved.
|
|
347
|
+
username = savedUsername(saved && sameSchool(saved.baseUrl, baseUrl) ? saved.username : undefined, username);
|
|
344
348
|
console.log("");
|
|
345
349
|
// ── Step 3: Password (hidden) ────────────────────────────────────
|
|
346
350
|
let keepPassword = false;
|
|
@@ -57,7 +57,7 @@ export function registerGetServerInfo(server, config, version, apiClient, readSi
|
|
|
57
57
|
server.registerTool("get_server_info", {
|
|
58
58
|
title: "Get Server Info",
|
|
59
59
|
annotations: { readOnlyHint: true },
|
|
60
|
-
description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, the server's local timezone and UTC offset, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in and the Don't ask again MFA checkbox, (when signed in) the account's uniqueName/displayName as signedInAs, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting, when the user asks which version they have, or what timezone/account dates are being computed against. Never contacts Brightspace and never returns secrets.",
|
|
60
|
+
description: "Report which version of the Brightspace MCP server is running, the Node.js runtime, platform, config file path, session state directory, configured school URL, whether a credential is stored, the server's local timezone and UTC offset, (once a browser sign-in has been saved) what Microsoft remembered: stay-signed-in (which keeps the Microsoft sign-in but does not by itself skip 2FA) and the Don't ask again MFA checkbox, with a plain-language note on what that means for 2FA to relay to the user, (when signed in) the account's uniqueName/displayName as signedInAs, and this process's request counters (requests: responses by status class, network errors, cache hits/misses, coalesced joins, and token refreshes). Use this for troubleshooting, when the user asks which version they have, or what timezone/account dates are being computed against. Never contacts Brightspace and never returns secrets.",
|
|
61
61
|
inputSchema: GetServerInfoSchema,
|
|
62
62
|
}, async () => {
|
|
63
63
|
const [microsoftSession, signedInAs] = await Promise.all([
|
package/build/utils/config.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
|
+
import * as fs from "node:fs";
|
|
6
7
|
import * as path from "node:path";
|
|
7
8
|
import * as os from "node:os";
|
|
8
9
|
import { createHash } from "node:crypto";
|
|
@@ -10,6 +11,7 @@ import dotenv from "dotenv";
|
|
|
10
11
|
import { configStoreExists, getConfigStorePath, loadConfigStore } from "./config-store.js";
|
|
11
12
|
import { resolveStoredPassword } from "./secure-config.js";
|
|
12
13
|
import { getStoredTotpUri } from "../auth/credential-store.js";
|
|
14
|
+
import { canonicalUsername } from "../auth/username.js";
|
|
13
15
|
import { normalizeTotpEnrollment } from "../auth/totp.js";
|
|
14
16
|
import { migrateLegacyState } from "../auth/legacy-state.js";
|
|
15
17
|
/**
|
|
@@ -165,8 +167,14 @@ export async function loadConfig() {
|
|
|
165
167
|
export function accountSessionDirectory(root, baseUrl, username) {
|
|
166
168
|
if (!username)
|
|
167
169
|
return root;
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
+
const origin = new URL(baseUrl).origin;
|
|
171
|
+
const directory = (name) => path.join(root, "accounts", createHash("sha256").update(JSON.stringify([origin, name])).digest("hex"));
|
|
172
|
+
const canonical = directory(canonicalUsername(username));
|
|
173
|
+
// Releases up to 4.0.0 named the directory after the username exactly as
|
|
174
|
+
// given. Its encryption key is tied to that path, so a session already
|
|
175
|
+
// saved there keeps being used rather than stranded.
|
|
176
|
+
const legacy = directory(username);
|
|
177
|
+
return legacy !== canonical && !fs.existsSync(canonical) && fs.existsSync(legacy) ? legacy : canonical;
|
|
170
178
|
}
|
|
171
179
|
/**
|
|
172
180
|
* A token lifetime must be a whole, positive number of seconds. NaN, zero, a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "4.0.
|
|
3
|
+
"version": "4.0.4",
|
|
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",
|