brightspace-mcp-server 3.9.0 → 3.9.2
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 +31 -0
- package/build/api/client.js +3 -1
- package/build/auth/token-manager.js +49 -0
- package/build/index.js +22 -7
- package/build/utils/config.js +78 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -111,6 +111,8 @@ Claude Desktop uses a separate configuration, which the setup wizard can update
|
|
|
111
111
|
|
|
112
112
|
</details>
|
|
113
113
|
|
|
114
|
+
Building against this server or opening a PR? See [STABILITY.md](STABILITY.md) for what's safe to rely on and what counts as a breaking change.
|
|
115
|
+
|
|
114
116
|
**Running from a source checkout or fork?** When a newer release is published, the server tells a source checkout to `git pull` and `npm run build` rather than to install the npm package. For a fork you maintain, set `D2L_NO_UPDATE_CHECK=1` in the server's environment to turn off upstream update notices.
|
|
115
117
|
|
|
116
118
|
## Session Expired?
|
|
@@ -129,6 +131,35 @@ npx -y brightspace-mcp-server@latest auth
|
|
|
129
131
|
|
|
130
132
|
Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or Downloads without Files and Folders permission (`EPERM`). Grant access in System Settings → Privacy & Security → Files and Folders, or run elsewhere.
|
|
131
133
|
|
|
134
|
+
## No browser? Paste a session cookie or token
|
|
135
|
+
|
|
136
|
+
The normal setup drives a real (usually hidden) browser through sign-in, which doesn't work in Docker, headless Linux, WSL without a display, or on a tenant whose MFA requires a hardware security key. Two environment variables skip the browser entirely; set one and leave the normal setup untouched:
|
|
137
|
+
|
|
138
|
+
- **`D2L_SESSION_COOKIE`** — the `d2lSessionVal` and `d2lSecureSessionVal` cookies from a browser tab where you're already signed in to Brightspace. In Chrome DevTools: open your Brightspace site, **F12 → Application → Cookies**, find `d2lSessionVal` and `d2lSecureSessionVal`, and set the variable to either form:
|
|
139
|
+
```bash
|
|
140
|
+
export D2L_SESSION_COOKIE="d2lSessionVal=<value>; d2lSecureSessionVal=<value>"
|
|
141
|
+
# or just the two values, in that order:
|
|
142
|
+
export D2L_SESSION_COOKIE="<d2lSessionVal>;<d2lSecureSessionVal>"
|
|
143
|
+
```
|
|
144
|
+
- **`D2L_ACCESS_TOKEN`** — a pre-issued Bearer token (an admin-issued Valence token, or one minted by a TA script):
|
|
145
|
+
```bash
|
|
146
|
+
export D2L_ACCESS_TOKEN="<token>"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Set either in your MCP client's `env` config instead of a shell export if you're not running the server from a terminal. If both are set, `D2L_ACCESS_TOKEN` wins.
|
|
150
|
+
|
|
151
|
+
If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.json` on disk, also set **`D2L_BASE_URL`** to your school's Brightspace URL — with no config file to read it from, the server otherwise defaults to Purdue's.
|
|
152
|
+
|
|
153
|
+
**The catch:** neither one renews itself. A pasted session cookie dies at D2L's own idle timeout (the same timeout that would eventually log you out in a browser), and a pre-issued token dies whenever it expires or is revoked. When that happens the server does **not** fall back to a browser login — it answers with an error telling you to paste a fresh value. There's no in-between: this is a deliberate escape hatch for environments that can't run a browser at all, not a way to skip typing your password once.
|
|
154
|
+
|
|
155
|
+
## Troubleshooting
|
|
156
|
+
|
|
157
|
+
**Where to find logs:** MCP clients log the server's stderr themselves. On **macOS**, Claude Desktop writes to `~/Library/Logs/Claude/mcp*.log` (one file per server, plus `mcp.log` for the client itself). On **Windows**, it's `%APPDATA%\Claude\logs`. Other clients vary — check their own logs or output panel for the `brightspace-mcp-server` process.
|
|
158
|
+
|
|
159
|
+
**Works in a terminal but not in the client:** the client launches the server as its own subprocess, which doesn't inherit your shell's environment. Common causes: the native credential store is locked (a GUI app started before you unlocked your keyring or logged into macOS won't get a Keychain prompt the way a terminal does), `HOME` or `PATH` differ for GUI-launched processes versus your shell, or `npx` resolves a different cached version than the one on your `PATH`. To check, run the exact registered command (`npx -y brightspace-mcp-server@latest`, or `cmd /c npx -y brightspace-mcp-server@latest` on Windows) from a fresh terminal with no extra environment set, and compare.
|
|
160
|
+
|
|
161
|
+
**Which version am I running:** ask the assistant anything that calls `get_server_info` — it reports the running version, Node runtime, and config/session paths with no network call. If you're on a source checkout instead of the published npm package, that version comes from the local `build/` output, so it only reflects your latest `npm run build`, not what's on npm.
|
|
162
|
+
|
|
132
163
|
## What You Can Ask About
|
|
133
164
|
|
|
134
165
|
| Topic | Examples |
|
package/build/api/client.js
CHANGED
|
@@ -76,6 +76,7 @@ export class D2LApiClient {
|
|
|
76
76
|
cacheTTLs;
|
|
77
77
|
timeoutMs;
|
|
78
78
|
onAuthExpired;
|
|
79
|
+
authExpiredMessage;
|
|
79
80
|
retryConfig;
|
|
80
81
|
versions = null;
|
|
81
82
|
/** Single in-flight discovery, so concurrent first requests share one fetch. */
|
|
@@ -119,6 +120,7 @@ export class D2LApiClient {
|
|
|
119
120
|
this.tokenManager = options.tokenManager;
|
|
120
121
|
this.timeoutMs = options.timeoutMs ?? 30_000;
|
|
121
122
|
this.onAuthExpired = options.onAuthExpired;
|
|
123
|
+
this.authExpiredMessage = options.authExpiredMessage;
|
|
122
124
|
this.retryConfig = options.retry ?? {};
|
|
123
125
|
// Merge user-provided TTLs with defaults
|
|
124
126
|
this.cacheTTLs = { ...DEFAULT_CACHE_TTLS, ...options.cacheTTLs };
|
|
@@ -351,7 +353,7 @@ export class D2LApiClient {
|
|
|
351
353
|
}
|
|
352
354
|
log("WARN", "Auto-reauthentication did not produce a valid token");
|
|
353
355
|
}
|
|
354
|
-
throw new ApiError(401, path, `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
|
|
356
|
+
throw new ApiError(401, path, this.authExpiredMessage ?? `Session expired. Please re-authenticate via ${AUTH_COMMAND}.`);
|
|
355
357
|
}
|
|
356
358
|
/**
|
|
357
359
|
* Make one HTTP request. Authentication recovery is shared with raw downloads.
|
|
@@ -24,6 +24,8 @@ export class TokenManager {
|
|
|
24
24
|
baseUrl;
|
|
25
25
|
tokenTtl;
|
|
26
26
|
mint;
|
|
27
|
+
envAccessToken;
|
|
28
|
+
envSessionCookie;
|
|
27
29
|
/** Single in-flight mint, so concurrent callers share one request. */
|
|
28
30
|
mintInFlight = null;
|
|
29
31
|
rejectedAccessToken = null;
|
|
@@ -35,6 +37,8 @@ export class TokenManager {
|
|
|
35
37
|
this.baseUrl = options.baseUrl ? new URL(options.baseUrl).origin : undefined;
|
|
36
38
|
this.tokenTtl = options.tokenTtl ?? DEFAULT_TOKEN_TTL_SECONDS;
|
|
37
39
|
this.mint = options.mint ?? mintAccessToken;
|
|
40
|
+
this.envAccessToken = options.envAccessToken;
|
|
41
|
+
this.envSessionCookie = options.envSessionCookie;
|
|
38
42
|
}
|
|
39
43
|
/**
|
|
40
44
|
* Get the current token if valid, otherwise null.
|
|
@@ -44,6 +48,21 @@ export class TokenManager {
|
|
|
44
48
|
async getToken(rejectedAccessToken) {
|
|
45
49
|
if (rejectedAccessToken)
|
|
46
50
|
this.rejectedAccessToken = rejectedAccessToken;
|
|
51
|
+
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE bypass disk, mint, and the browser
|
|
52
|
+
// entirely: every call hands back the same env-derived token. The only
|
|
53
|
+
// way this "expires" is Brightspace rejecting it, which the caller
|
|
54
|
+
// reports back as rejectedAccessToken — at that point there is nothing
|
|
55
|
+
// fresher to offer, so this returns null rather than the same rejected
|
|
56
|
+
// value again. (See D2LApiClient.tryAutoReauth / authExpiredMessage for
|
|
57
|
+
// the user-facing "paste a fresh one" error this produces.)
|
|
58
|
+
if (this.envAccessToken || this.envSessionCookie) {
|
|
59
|
+
const token = this.buildEnvToken();
|
|
60
|
+
if (rejectedAccessToken && token.accessToken === rejectedAccessToken) {
|
|
61
|
+
log("DEBUG", "The env-provided credential was rejected; no replacement is available");
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return token;
|
|
65
|
+
}
|
|
47
66
|
// Check memory cache first
|
|
48
67
|
if (this.cachedToken && this.isUsable(this.cachedToken)) {
|
|
49
68
|
log("DEBUG", "Returning cached token");
|
|
@@ -69,6 +88,36 @@ export class TokenManager {
|
|
|
69
88
|
log("DEBUG", "No valid token available");
|
|
70
89
|
return null;
|
|
71
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the token for D2L_ACCESS_TOKEN or D2L_SESSION_COOKIE;
|
|
93
|
+
* D2L_ACCESS_TOKEN wins when both are set. expiresAt is pushed far into the
|
|
94
|
+
* future: a pasted credential has no known lifetime, and isValid()'s
|
|
95
|
+
* refresh-buffer check must never be the thing that discards it — only
|
|
96
|
+
* Brightspace actually rejecting it (a 401) should.
|
|
97
|
+
*/
|
|
98
|
+
buildEnvToken() {
|
|
99
|
+
const now = Date.now();
|
|
100
|
+
const FAR_FUTURE_MS = 10 * 365 * 24 * 60 * 60 * 1000; // 10 years
|
|
101
|
+
if (this.envAccessToken) {
|
|
102
|
+
return {
|
|
103
|
+
accessToken: this.envAccessToken,
|
|
104
|
+
tenantOrigin: this.baseUrl,
|
|
105
|
+
capturedAt: now,
|
|
106
|
+
expiresAt: now + FAR_FUTURE_MS,
|
|
107
|
+
source: "env",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return {
|
|
111
|
+
// Reuses the client's existing "cookie:" passthrough (see
|
|
112
|
+
// D2LApiClient.buildAuthHeaders) instead of minting a Bearer JWT.
|
|
113
|
+
accessToken: `cookie:${this.envSessionCookie}`,
|
|
114
|
+
tenantOrigin: this.baseUrl,
|
|
115
|
+
capturedAt: now,
|
|
116
|
+
expiresAt: now + FAR_FUTURE_MS,
|
|
117
|
+
source: "env",
|
|
118
|
+
cookieHeader: this.envSessionCookie,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
72
121
|
/**
|
|
73
122
|
* Only use persisted session material, including updates from other processes.
|
|
74
123
|
*/
|
package/build/index.js
CHANGED
|
@@ -67,18 +67,33 @@ else {
|
|
|
67
67
|
sessionDir: config.sessionDir,
|
|
68
68
|
baseUrl: config.baseUrl,
|
|
69
69
|
tokenTtl: config.tokenTtl,
|
|
70
|
+
envAccessToken: config.envAccessToken,
|
|
71
|
+
envSessionCookie: config.envSessionCookie,
|
|
70
72
|
});
|
|
71
|
-
//
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE are opt-in browser-free sign-in
|
|
74
|
+
// escape hatches (Docker, headless Linux, WSL, hardware-key MFA
|
|
75
|
+
// tenants). When either is set, AuthRunner is never created: there is
|
|
76
|
+
// no stored credential to drive a browser login from, and a rejected
|
|
77
|
+
// pasted credential should fail with a clear "paste a fresh one"
|
|
78
|
+
// message rather than spawning auth-cli.
|
|
79
|
+
const envAuthActive = Boolean(config.envAccessToken || config.envSessionCookie);
|
|
80
|
+
const authRunner = envAuthActive
|
|
81
|
+
? undefined
|
|
82
|
+
: new AuthRunner({
|
|
83
|
+
onProgress: (message) => {
|
|
84
|
+
void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
|
|
85
|
+
},
|
|
86
|
+
});
|
|
77
87
|
// Create D2L API Client with auto-reauth support
|
|
78
88
|
const apiClient = new D2LApiClient({
|
|
79
89
|
baseUrl: config.baseUrl,
|
|
80
90
|
tokenManager,
|
|
81
|
-
onAuthExpired: () => authRunner.run(),
|
|
91
|
+
onAuthExpired: authRunner ? () => authRunner.run() : undefined,
|
|
92
|
+
authExpiredMessage: config.envAccessToken
|
|
93
|
+
? "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set."
|
|
94
|
+
: config.envSessionCookie
|
|
95
|
+
? "The pasted D2L_SESSION_COOKIE has expired. Copy a fresh d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser and update the environment variable; this server does not fall back to a browser login while D2L_SESSION_COOKIE is set."
|
|
96
|
+
: undefined,
|
|
82
97
|
});
|
|
83
98
|
// Nothing here reaches Brightspace. API versions are discovered by the
|
|
84
99
|
// first request that needs them, and the first request with no saved
|
package/build/utils/config.js
CHANGED
|
@@ -49,6 +49,12 @@ export async function loadConfig() {
|
|
|
49
49
|
const activeOnly = envBoolean(process.env.D2L_ACTIVE_ONLY, "D2L_ACTIVE_ONLY")
|
|
50
50
|
?? store?.activeOnly
|
|
51
51
|
?? true;
|
|
52
|
+
// D2L_ACCESS_TOKEN beats D2L_SESSION_COOKIE beats the normal stored-credential
|
|
53
|
+
// browser flow; both are validated whenever present regardless of which one
|
|
54
|
+
// wins, so a typo in the losing variable still fails loudly at startup.
|
|
55
|
+
const envAccessToken = readEnvSecret(process.env.D2L_ACCESS_TOKEN, "D2L_ACCESS_TOKEN");
|
|
56
|
+
const rawSessionCookie = readEnvSecret(process.env.D2L_SESSION_COOKIE, "D2L_SESSION_COOKIE");
|
|
57
|
+
const envSessionCookie = rawSessionCookie ? parseSessionCookieEnv(rawSessionCookie) : undefined;
|
|
52
58
|
const configuredUrl = new URL(process.env.D2L_BASE_URL || store?.baseUrl || "https://purdue.brightspace.com");
|
|
53
59
|
if (configuredUrl.protocol !== "https:" || configuredUrl.username || configuredUrl.password) {
|
|
54
60
|
throw new Error("The Brightspace URL must be an HTTPS school URL without embedded credentials.");
|
|
@@ -70,6 +76,8 @@ export async function loadConfig() {
|
|
|
70
76
|
username,
|
|
71
77
|
password,
|
|
72
78
|
campus: process.env.D2L_CAMPUS || store?.campus,
|
|
79
|
+
envAccessToken,
|
|
80
|
+
envSessionCookie,
|
|
73
81
|
courseFilter: {
|
|
74
82
|
includeCourseIds,
|
|
75
83
|
excludeCourseIds,
|
|
@@ -115,6 +123,76 @@ function envBoolean(value, source) {
|
|
|
115
123
|
console.error(`[config] Ignoring ${source}=${JSON.stringify(value)}: expected true or false`);
|
|
116
124
|
return undefined;
|
|
117
125
|
}
|
|
126
|
+
/** CR, LF, or NUL in a header value enables request smuggling / header injection. */
|
|
127
|
+
const CONTROL_CHAR_PATTERN = /[\r\n\0]/;
|
|
128
|
+
/**
|
|
129
|
+
* Validate a pasted auth secret (D2L_SESSION_COOKIE or D2L_ACCESS_TOKEN).
|
|
130
|
+
* Unlike positiveSeconds/envBoolean above, a bad value here is never silently
|
|
131
|
+
* ignored: both env vars exist specifically to skip the browser, so a typo
|
|
132
|
+
* must fail loudly at startup rather than fall back to a sign-in flow the
|
|
133
|
+
* user deliberately avoided. Returns undefined only when the variable is
|
|
134
|
+
* genuinely unset (absent or empty), matching the other env helpers' treatment
|
|
135
|
+
* of "".
|
|
136
|
+
*
|
|
137
|
+
* The CR/LF/NUL check is adapted from the injection guard in
|
|
138
|
+
* JhostinAleck/brightspace-mcp (MIT), AccessToken.ts:10-19. Surrounding
|
|
139
|
+
* whitespace is rejected rather than trimmed: a trailing newline or space is
|
|
140
|
+
* exactly the kind of thing a terminal paste or `export FOO=$(cat file)`
|
|
141
|
+
* leaves behind, and silently stripping it would hide the mistake instead of
|
|
142
|
+
* surfacing it.
|
|
143
|
+
*/
|
|
144
|
+
export function readEnvSecret(value, varName) {
|
|
145
|
+
if (value === undefined || value === "")
|
|
146
|
+
return undefined;
|
|
147
|
+
if (CONTROL_CHAR_PATTERN.test(value)) {
|
|
148
|
+
throw new Error(`${varName} contains a carriage return, line feed, or NUL character. Paste the value as a single line with no embedded newlines.`);
|
|
149
|
+
}
|
|
150
|
+
if (value !== value.trim()) {
|
|
151
|
+
throw new Error(`${varName} has leading or trailing whitespace. Remove it and try again.`);
|
|
152
|
+
}
|
|
153
|
+
return value;
|
|
154
|
+
}
|
|
155
|
+
const D2L_SESSION_VAL = "d2lSessionVal";
|
|
156
|
+
const D2L_SECURE_SESSION_VAL = "d2lSecureSessionVal";
|
|
157
|
+
const SESSION_COOKIE_FORMAT_ERROR = 'D2L_SESSION_COOKIE must be either "d2lSessionVal=...; d2lSecureSessionVal=..." ' +
|
|
158
|
+
"(the cookie header copied from a logged-in browser) or the two cookie values " +
|
|
159
|
+
"separated by a semicolon, in that order (d2lSessionVal;d2lSecureSessionVal).";
|
|
160
|
+
/**
|
|
161
|
+
* Normalize D2L_SESSION_COOKIE into the "d2lSessionVal=...; d2lSecureSessionVal=..."
|
|
162
|
+
* header D2L's API expects. Accepts a full cookie-header fragment (extra
|
|
163
|
+
* cookies alongside the two named ones are ignored, order doesn't matter) or
|
|
164
|
+
* the two raw values separated by a semicolon with no cookie names, in which
|
|
165
|
+
* case the first is d2lSessionVal and the second d2lSecureSessionVal.
|
|
166
|
+
*/
|
|
167
|
+
export function parseSessionCookieEnv(raw) {
|
|
168
|
+
const segments = raw.split(";").map((part) => part.trim()).filter((part) => part.length > 0);
|
|
169
|
+
const named = {};
|
|
170
|
+
// The form is decided by whether a segment is NAMED d2lSessionVal/
|
|
171
|
+
// d2lSecureSessionVal (its name before the first "="), not by whether any
|
|
172
|
+
// "=" appears at all -- the two-raw-values form's second value can itself
|
|
173
|
+
// contain "=" (base64 padding), which must not be mistaken for a name.
|
|
174
|
+
let anyNamed = false;
|
|
175
|
+
for (const segment of segments) {
|
|
176
|
+
const eq = segment.indexOf("=");
|
|
177
|
+
if (eq === -1)
|
|
178
|
+
continue;
|
|
179
|
+
const name = segment.slice(0, eq).trim();
|
|
180
|
+
if (name === D2L_SESSION_VAL || name === D2L_SECURE_SESSION_VAL) {
|
|
181
|
+
anyNamed = true;
|
|
182
|
+
named[name] = segment.slice(eq + 1).trim();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (anyNamed) {
|
|
186
|
+
if (named[D2L_SESSION_VAL] && named[D2L_SECURE_SESSION_VAL]) {
|
|
187
|
+
return `${D2L_SESSION_VAL}=${named[D2L_SESSION_VAL]}; ${D2L_SECURE_SESSION_VAL}=${named[D2L_SECURE_SESSION_VAL]}`;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (segments.length === 2) {
|
|
191
|
+
const [sessionVal, secureSessionVal] = segments;
|
|
192
|
+
return `${D2L_SESSION_VAL}=${sessionVal}; ${D2L_SECURE_SESSION_VAL}=${secureSessionVal}`;
|
|
193
|
+
}
|
|
194
|
+
throw new Error(SESSION_COOKIE_FORMAT_ERROR);
|
|
195
|
+
}
|
|
118
196
|
function expandTilde(filePath) {
|
|
119
197
|
if (filePath.startsWith("~")) {
|
|
120
198
|
return path.join(os.homedir(), filePath.slice(1));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.2",
|
|
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",
|