brightspace-mcp-server 5.1.13 → 5.1.15
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/auth-lock.js +5 -4
- package/build/runtime-relay.js +8 -1
- package/build/shared-runtime.js +37 -2
- package/build/utils/config.js +28 -6
- package/package.json +1 -1
package/build/auth/auth-lock.js
CHANGED
|
@@ -196,12 +196,13 @@ async function waitForTakeover(lockPath, stale) {
|
|
|
196
196
|
export async function acquireProcessLock(lockPath, options = {}) {
|
|
197
197
|
await fs.mkdir(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
198
198
|
const mode = options.mode ?? "explicit";
|
|
199
|
-
const lease = await acquireLease(lockPath, 0, mode);
|
|
199
|
+
const lease = await acquireLease(lockPath, 0, mode, options.abandoned);
|
|
200
200
|
return () => lease.release();
|
|
201
201
|
}
|
|
202
|
-
async function acquireLease(lockPath, depth, mode) {
|
|
202
|
+
async function acquireLease(lockPath, depth, mode, abandoned) {
|
|
203
203
|
if (depth > 16)
|
|
204
204
|
throw new AuthenticationInProgressError();
|
|
205
|
+
const gone = (candidate) => lockOps.isDead(candidate) || abandoned?.(candidate.pid) === true;
|
|
205
206
|
const owner = { pid: process.pid, host: hostname(), nonce: randomUUID(), mode };
|
|
206
207
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
207
208
|
try {
|
|
@@ -224,7 +225,7 @@ async function acquireLease(lockPath, depth, mode) {
|
|
|
224
225
|
const stale = await readOwner(lockPath);
|
|
225
226
|
if (!stale)
|
|
226
227
|
throw new AuthenticationInProgressError();
|
|
227
|
-
if (!
|
|
228
|
+
if (!gone(stale)) {
|
|
228
229
|
// A live owner is not stale — the existing behavior for two contending
|
|
229
230
|
// explicit runs, or an automatic run finding anything alive at all.
|
|
230
231
|
// The one exception: an explicit run may take over a live *automatic*
|
|
@@ -244,7 +245,7 @@ async function acquireLease(lockPath, depth, mode) {
|
|
|
244
245
|
const claim = await acquireLease(path.join(lockPath, "reclaim.lock"), depth + 1, mode);
|
|
245
246
|
try {
|
|
246
247
|
const current = await readOwner(lockPath);
|
|
247
|
-
if (current?.nonce !== stale.nonce || !
|
|
248
|
+
if (current?.nonce !== stale.nonce || !gone(current))
|
|
248
249
|
throw new AuthenticationInProgressError();
|
|
249
250
|
const retired = `${lockPath}.stale.${owner.nonce}`;
|
|
250
251
|
// A rename blocked by contention means another contender is inside this
|
package/build/runtime-relay.js
CHANGED
|
@@ -144,11 +144,18 @@ async function post(address, message, signal, reply) {
|
|
|
144
144
|
await response.body?.cancel();
|
|
145
145
|
throw new Unreachable(`HTTP ${response.status}`);
|
|
146
146
|
}
|
|
147
|
+
// A runtime answers every request with JSON or an event stream, or with a
|
|
148
|
+
// bodiless 202. Anything else is another program that took a dead
|
|
149
|
+
// runtime's port, so this is no runtime at all: start a fresh one.
|
|
150
|
+
const type = response.headers.get("content-type") ?? "";
|
|
151
|
+
if (response.status !== 202 && !/application\/json|text\/event-stream/i.test(type)) {
|
|
152
|
+
await response.body?.cancel();
|
|
153
|
+
throw new Unreachable(`HTTP ${response.status} from something other than a Brightspace runtime`);
|
|
154
|
+
}
|
|
147
155
|
if (!response.ok)
|
|
148
156
|
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
|
|
149
157
|
if (response.status === 202 || !response.body)
|
|
150
158
|
return;
|
|
151
|
-
const type = response.headers.get("content-type") ?? "";
|
|
152
159
|
if (!type.includes("text/event-stream")) {
|
|
153
160
|
const body = JSON.parse(await response.text());
|
|
154
161
|
for (const item of Array.isArray(body) ? body : [body])
|
package/build/shared-runtime.js
CHANGED
|
@@ -13,6 +13,8 @@ import { log } from "./utils/logger.js";
|
|
|
13
13
|
export const DEFAULT_IDLE_MS = 10 * 60_000;
|
|
14
14
|
/** How long a starting runtime waits for a runtime that is starting or stopping to settle. */
|
|
15
15
|
const CLAIM_WAIT_MS = 10_000;
|
|
16
|
+
/** How long a published runtime has to answer before it is taken for dead. */
|
|
17
|
+
const PROBE_TIMEOUT_MS = 2_000;
|
|
16
18
|
const STATE_FILE = "state.json";
|
|
17
19
|
const OWNER_LOCK = "owner.lock";
|
|
18
20
|
/**
|
|
@@ -108,19 +110,52 @@ export async function serveSharedRuntime(runtime, { dir, idleMs }) {
|
|
|
108
110
|
/**
|
|
109
111
|
* Take the owner lock for `dir`, waiting out a runtime that is starting or
|
|
110
112
|
* stopping. undefined when another live runtime has published its address.
|
|
113
|
+
*
|
|
114
|
+
* A published address that does not answer belongs to a runtime that died
|
|
115
|
+
* without cleaning up, typically in a reboot, which also leaves its owner
|
|
116
|
+
* lock behind. Its pid may since have gone to an unrelated process, so the
|
|
117
|
+
* lock is reclaimed when its owner is that pid, not only when the pid is gone.
|
|
118
|
+
* A runtime that is still starting holds the lock under its own pid and has
|
|
119
|
+
* not published yet, so it is waited for instead.
|
|
111
120
|
*/
|
|
112
121
|
async function claimOwnership(dir) {
|
|
113
122
|
const deadline = Date.now() + CLAIM_WAIT_MS;
|
|
114
123
|
for (;;) {
|
|
124
|
+
const published = await readRuntimeAddress(dir);
|
|
125
|
+
if (published && await answers(published))
|
|
126
|
+
return undefined;
|
|
115
127
|
try {
|
|
116
|
-
return await acquireProcessLock(path.join(dir, OWNER_LOCK), {
|
|
128
|
+
return await acquireProcessLock(path.join(dir, OWNER_LOCK), {
|
|
129
|
+
mode: "exclusive",
|
|
130
|
+
abandoned: (pid) => pid === published?.pid,
|
|
131
|
+
});
|
|
117
132
|
}
|
|
118
133
|
catch (error) {
|
|
119
134
|
if (!(error instanceof AuthenticationInProgressError))
|
|
120
135
|
throw error;
|
|
121
136
|
}
|
|
122
|
-
if (
|
|
137
|
+
if (Date.now() > deadline)
|
|
123
138
|
return undefined;
|
|
124
139
|
await delay(50);
|
|
125
140
|
}
|
|
126
141
|
}
|
|
142
|
+
/**
|
|
143
|
+
* Whether the runtime at `address` is still serving it. Anything but a
|
|
144
|
+
* refused or timed-out connection, or a 401 from another server now on that
|
|
145
|
+
* port, counts: a runtime that is shutting down (503) is still alive.
|
|
146
|
+
*/
|
|
147
|
+
async function answers(address) {
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(address.url, {
|
|
150
|
+
headers: { Authorization: `Bearer ${address.token}` },
|
|
151
|
+
signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
|
|
152
|
+
});
|
|
153
|
+
await response.body?.cancel();
|
|
154
|
+
// A runtime answers an authorized GET with 405 (it serves POST only). Any
|
|
155
|
+
// other answer is another program that took a dead runtime's port.
|
|
156
|
+
return response.status === 405;
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
}
|
package/build/utils/config.js
CHANGED
|
@@ -102,13 +102,11 @@ export async function loadConfig({ credentials = true } = {}) {
|
|
|
102
102
|
?? positiveSeconds(store?.tokenTtl, "tokenTtl in config.json")
|
|
103
103
|
?? 3600;
|
|
104
104
|
// Resolve includeCourseIds: env > store > undefined
|
|
105
|
-
const includeCourseIds = process.env.D2L_INCLUDE_COURSES
|
|
106
|
-
|
|
107
|
-
: store?.includeCourses;
|
|
105
|
+
const includeCourseIds = courseIdList(process.env.D2L_INCLUDE_COURSES, "D2L_INCLUDE_COURSES")
|
|
106
|
+
?? store?.includeCourses;
|
|
108
107
|
// Resolve excludeCourseIds: env > store > undefined
|
|
109
|
-
const excludeCourseIds = process.env.D2L_EXCLUDE_COURSES
|
|
110
|
-
|
|
111
|
-
: store?.excludeCourses;
|
|
108
|
+
const excludeCourseIds = courseIdList(process.env.D2L_EXCLUDE_COURSES, "D2L_EXCLUDE_COURSES")
|
|
109
|
+
?? store?.excludeCourses;
|
|
112
110
|
// Resolve activeOnly: env > store > default (true)
|
|
113
111
|
const activeOnly = envBoolean(process.env.D2L_ACTIVE_ONLY, "D2L_ACTIVE_ONLY")
|
|
114
112
|
?? store?.activeOnly
|
|
@@ -195,6 +193,30 @@ function positiveSeconds(value, source) {
|
|
|
195
193
|
console.error(`[config] Ignoring ${source}=${JSON.stringify(value)}: expected a positive whole number of seconds`);
|
|
196
194
|
return undefined;
|
|
197
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* A comma-separated list of course (org unit) ids. parseInt read "123x" as
|
|
198
|
+
* 123 and dropped "CS180" without a word, so a whitelist of course codes
|
|
199
|
+
* became empty and an empty whitelist filters nothing: every course showed.
|
|
200
|
+
* Each entry must be a positive whole number or it is ignored with a warning,
|
|
201
|
+
* and a list with no valid id is ignored outright so config.json applies, as
|
|
202
|
+
* positiveSeconds does. Empty entries from a stray comma are skipped quietly.
|
|
203
|
+
*/
|
|
204
|
+
function courseIdList(value, source) {
|
|
205
|
+
const entries = (value ?? "").split(",").map((entry) => entry.trim()).filter((entry) => entry.length > 0);
|
|
206
|
+
if (entries.length === 0)
|
|
207
|
+
return undefined;
|
|
208
|
+
const ids = [];
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
if (/^\d+$/.test(entry) && Number(entry) > 0)
|
|
211
|
+
ids.push(Number(entry));
|
|
212
|
+
else
|
|
213
|
+
console.error(`[config] Ignoring ${JSON.stringify(entry)} in ${source}: expected a numeric course id`);
|
|
214
|
+
}
|
|
215
|
+
if (ids.length > 0)
|
|
216
|
+
return ids;
|
|
217
|
+
console.error(`[config] Ignoring ${source}=${JSON.stringify(value)}: no valid course id`);
|
|
218
|
+
return undefined;
|
|
219
|
+
}
|
|
198
220
|
/**
|
|
199
221
|
* An on/off environment variable. Comparing against the exact string "false"
|
|
200
222
|
* read "0", "no", "False" and a typo as true, so D2L_HEADLESS=0 kept the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.15",
|
|
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",
|