brightspace-mcp-server 5.1.14 → 5.1.16

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.
@@ -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 (!lockOps.isDead(stale)) {
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 || !lockOps.isDead(current))
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
@@ -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])
@@ -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), { mode: "exclusive" });
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 (await readRuntimeAddress(dir) || Date.now() > deadline)
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
+ }
@@ -3,11 +3,15 @@
3
3
  * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
+ /** A numeric reference as its character, or left as written when it names no code point (issue #352). */
7
+ function codePoint(whole, code) {
8
+ return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
9
+ }
6
10
  /** Decode the character references Brightspace's HTML editor writes into attribute values. */
7
11
  export function decodeEntities(value) {
8
12
  return value
9
- .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(parseInt(hex, 16)))
10
- .replace(/&#(\d+);/g, (_, dec) => String.fromCodePoint(parseInt(dec, 10)))
13
+ .replace(/&#x([0-9a-f]+);/gi, (whole, hex) => codePoint(whole, parseInt(hex, 16)))
14
+ .replace(/&#(\d+);/g, (whole, dec) => codePoint(whole, parseInt(dec, 10)))
11
15
  .replace(/&quot;/g, '"')
12
16
  .replace(/&lt;/g, "<")
13
17
  .replace(/&gt;/g, ">")
@@ -26,11 +26,11 @@ function decodeEntities(text) {
26
26
  return ENTITIES[key];
27
27
  if (key.startsWith("#x")) {
28
28
  const code = Number.parseInt(key.slice(2), 16);
29
- return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : whole;
29
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
30
30
  }
31
31
  if (key.startsWith("#")) {
32
32
  const code = Number.parseInt(key.slice(1), 10);
33
- return Number.isFinite(code) && code > 0 ? String.fromCodePoint(code) : whole;
33
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
34
34
  }
35
35
  return whole;
36
36
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.14",
3
+ "version": "5.1.16",
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",