brightspace-mcp-server 4.0.1 → 4.0.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/build/auth/credential-store.js +64 -39
- 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/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)) {
|
|
@@ -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;
|
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.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",
|