brightspace-mcp-server 5.0.13 → 5.1.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/README.md CHANGED
@@ -107,11 +107,18 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
107
107
  | `D2L_ACTIVE_ONLY=false` | Include courses whose enrollment has ended |
108
108
  | `D2L_CURRENT_ONLY=true` | Only show courses in their current enrollment window, like Brightspace's "Current Courses" widget |
109
109
  | `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
110
+ | `D2L_RUNTIME_IDLE_SECONDS` | How long the shared background server waits with no question running before it stops (default 600) |
110
111
  | `D2L_DEV_MODE=true` | Write a local, content-free log of tool calls and sign-in events, for debugging session problems ([how](docs/dev-mode.md)) |
111
112
 
112
- ## One server for all your chats (optional)
113
+ ## One server for all your chats
113
114
 
114
- Normally every chat starts its own copy of the server. With many Codex chats open that is many copies, each roughly 70 MB of memory. Instead you can run **one** copy yourself and point every chat at it by URL. It uses the same saved password, saved session, and sign-in as before; a question that needs a sign-in still signs in once, for every chat.
115
+ Every chat already shares one Brightspace server, with nothing to set up. Each chat still launches the usual command, but that command is now only a small relay: the first question from any chat starts one shared background server for this computer, and every other chat (Claude Desktop, Codex, Cursor, …) uses that same one. That means one sign-in, one saved session, and one cache for all of them. Two chats starting at the same moment still end up with one server. Closing a chat never interrupts another chat's question. After 10 minutes with no question running, the shared server stops by itself, and the next question starts it again.
116
+
117
+ Chats share a server only when they use the same settings and the same version of this package. A chat started with different `D2L_*` settings, or after an update, gets its own. Because the background server isn't attached to any chat, its log goes to `runtime.log` under `~/.d2l-session/…/shared-runtime/`. Set `D2L_RUNTIME_IDLE_SECONDS` to change how long it waits before stopping.
118
+
119
+ ### Running the shared server yourself (optional)
120
+
121
+ You can also run **one** copy yourself and point every chat at it by URL. It uses the same saved password, saved session, and sign-in as before; a question that needs a sign-in still signs in once, for every chat.
115
122
 
116
123
  1. **Make a secret token, once.** Chats must present it, so other programs and web pages can't read your grades. Generate one:
117
124
  ```bash
@@ -138,7 +145,7 @@ Things that change:
138
145
  - **Stopping it:** press Ctrl+C. Questions already running finish first; press Ctrl+C again to stop at once.
139
146
  - **Updating it:** restart the server, not Codex. Restarting a client only reconnects it to the server that is already running the old version. Stop the server and start the same `http` command again with the same `D2L_HTTP_TOKEN`, port, and settings; from a source checkout, run `npm run update` first. Update notices from a shared server say this too.
140
147
 
141
- Any other MCP client that can connect to a Streamable HTTP URL with a bearer token can share the same server. To go back to one copy per chat, put the `command`/`args` entry back (rerun `setup` and say yes when it offers to switch Codex back to its own copy per chat) and stop the shared server.
148
+ Any other MCP client that can connect to a Streamable HTTP URL with a bearer token can share the same server. To go back to the automatic shared server, put the `command`/`args` entry back (rerun `setup` and say yes when it offers to switch Codex back) and stop the server you started.
142
149
 
143
150
  ## When it asks you to sign in
144
151
 
@@ -161,6 +168,7 @@ How often you're asked is up to your school, not this tool. Everything else abou
161
168
  - Run `npx -y brightspace-mcp-server@latest doctor` first — it checks your Node version, the sign-in browser, saved setup, credential store, Brightspace connectivity, saved sign-in, and installed version, and tells you exactly what to fix.
162
169
  - **`Authentication failed: The native credential store is locked or unavailable.`** Your password store is locked or hasn't let this server read the saved password. Unlock it (on a Mac, log in or unlock Keychain Access), then run `npx -y brightspace-mcp-server@latest auth` again, or `setup` if that still fails, and choose **Always Allow** when Keychain asks.
163
170
  - **It works in the terminal but not in the app:** the app starts it separately and may not see your password store yet — see [docs/troubleshooting.md](docs/troubleshooting.md).
171
+ - **The app says the server timed out while starting:** open the app's MCP server log. Each startup step prints a timestamped `Startup: ...` line before it begins and `done in N ms` when it ends, and a step still running after 10 seconds prints `Startup: still ...` once. The last such line names the step it was stuck in, often the saved-password lookup in your password store. These lines never include your username, password, or cookies.
164
172
  - **Which version do I have?** Ask your AI "which version of the Brightspace server am I running?"
165
173
  - **Still stuck?** [Open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) and paste what the terminal printed.
166
174
 
@@ -101,7 +101,7 @@ function readBody(req) {
101
101
  * carry no Origin other than this server's own (a page in a browser always
102
102
  * sends one), and present the bearer token.
103
103
  */
104
- export function startHttpServer({ runtime, token, port }) {
104
+ export function startHttpServer({ runtime, token, port, onActiveChange }) {
105
105
  const expectedToken = createHash("sha256").update(token).digest();
106
106
  const authorized = (header) => {
107
107
  const match = /^Bearer[ ]+(\S+)[ ]*$/i.exec(header ?? "");
@@ -157,8 +157,10 @@ export function startHttpServer({ runtime, token, port }) {
157
157
  };
158
158
  const httpServer = createServer((req, res) => {
159
159
  active += 1;
160
+ onActiveChange?.(active);
160
161
  res.once("close", () => {
161
162
  active -= 1;
163
+ onActiveChange?.(active);
162
164
  // A keep-alive connection that just finished its last request would
163
165
  // otherwise hold a closing server open until it timed out.
164
166
  if (closing)
package/build/index.js CHANGED
@@ -6,14 +6,10 @@
6
6
  *
7
7
  * https://github.com/rohanmuppa/brightspace-mcp-server
8
8
  */
9
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
9
  import { enableStdoutGuard, log } from "./utils/logger.js";
11
10
  import { guardServerProcess } from "./utils/process-guard.js";
12
11
  import { loadConfig } from "./utils/config.js";
13
- import { setActiveShortcuts } from "./utils/two-factor-shortcuts.js";
14
- import { startUpdateChecks } from "./utils/update-checker.js";
15
- import { configureDevActivity } from "./utils/dev-activity.js";
16
- import { createMcpServer, createRuntime } from "./server.js";
12
+ import { startupPhase } from "./utils/startup-phase.js";
17
13
  import { readFileSync } from "node:fs";
18
14
  import { fileURLToPath } from "node:url";
19
15
  import { dirname, resolve } from "node:path";
@@ -30,7 +26,7 @@ const PKG_VERSION = (() => {
30
26
  })();
31
27
  // ── Subcommand routing (before any MCP initialization) ──────────────
32
28
  const subcommand = process.argv[2];
33
- const COMMANDS = ['setup', 'auth', 'doctor', 'http'];
29
+ const COMMANDS = ['setup', 'auth', 'doctor', 'http', 'runtime'];
34
30
  // A mistyped command (`htp`) must not quietly start a stdio server that sits
35
31
  // waiting on stdin. Flags alone still mean the default stdio server.
36
32
  if (subcommand !== undefined && !subcommand.startsWith('-') && !COMMANDS.includes(subcommand)) {
@@ -54,23 +50,26 @@ else if (subcommand === 'doctor') {
54
50
  await import('./doctor.js');
55
51
  }
56
52
  else {
57
- // ── MCP Server (default: stdio; `http`: one shared localhost server) ──
58
- const http = subcommand === 'http';
53
+ // ── MCP Server ──────────────────────────────────────────────────────
54
+ // default: stdio, relayed to the one shared runtime, started on demand;
55
+ // `runtime <dir>`: that shared runtime (started by the default command);
56
+ // `http`: one shared localhost server the user runs and connects to by URL.
57
+ const mode = subcommand === 'http' ? 'http' : subcommand === 'runtime' ? 'runtime' : 'stdio';
59
58
  // CRITICAL: Enable stdout guard IMMEDIATELY to prevent corruption of stdio transport
60
59
  enableStdoutGuard();
61
60
  // A stray exception or a closed pipe must not silently drop the client's
62
61
  // transport; see guardServerProcess.
63
- guardServerProcess(process, !http);
64
- /** Set once the HTTP server is listening; shutdown waits on it. */
65
- let stopHttp;
62
+ guardServerProcess(process, mode === 'stdio');
63
+ /** Set once the HTTP server or shared runtime is listening; shutdown waits on it. */
64
+ let stopServing;
66
65
  async function main() {
67
66
  try {
68
- // Load configuration
69
- const config = await loadConfig();
70
- configureDevActivity(config.sessionDir);
71
- // Lets the MFA approval message mention the setup options only to users
72
- // who have neither turned on. Booleans only; the key is never passed.
73
- setActiveShortcuts({ rememberMfa: config.rememberMfa === true, keySaved: Boolean(config.totpUri) });
67
+ // Announce startup before any await: loading the configuration reads the
68
+ // OS credential store, which can stall, and a client that times out
69
+ // needs these timestamped lines to see where. No account details.
70
+ log("INFO", `Brightspace MCP Server v${PKG_VERSION} starting (Node ${process.version}, ${process.platform}, ${mode})`);
71
+ // The relay leaves the credential store to the runtime; see loadConfig.
72
+ const config = await startupPhase("loading configuration", () => loadConfig({ credentials: mode !== "stdio" }));
74
73
  log("DEBUG", "Configuration loaded", { sessionDir: config.sessionDir });
75
74
  log("INFO", "");
76
75
  log("INFO", "========================================");
@@ -79,17 +78,36 @@ else {
79
78
  log("INFO", " github.com/rohanmuppa/brightspace-mcp-server");
80
79
  log("INFO", "========================================");
81
80
  log("INFO", "");
82
- // The one stdio client hears the sign-in's progress lines as logging
83
- // messages. The HTTP server has many clients and tells none of them;
84
- // see createRuntime.
85
- let stdioServer;
86
- const runtime = createRuntime(config, PKG_VERSION, http ? undefined : (message) => {
87
- void stdioServer?.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
88
- });
89
- // Check for updates now, then every few hours. A stdio server can stay
90
- // alive for days, so a boot-only check would never see a release. The
81
+ if (mode === 'stdio') {
82
+ // Every MCP client on this computer that launches this command with
83
+ // the same settings shares one runtime: one sign-in, one cache, one
84
+ // rate limiter. The first request starts it; it stops when idle.
85
+ // Each chat runs one of these, so it loads only the relay, never the
86
+ // tools, API client or browser code the runtime itself needs.
87
+ const { runtimeDirectory } = await import("./shared-runtime.js");
88
+ const { relaySharedRuntime, spawnSharedRuntime, stdioLines } = await import("./runtime-relay.js");
89
+ const dir = runtimeDirectory(config, PKG_VERSION);
90
+ await relaySharedRuntime(stdioLines(), { dir, startRuntime: () => spawnSharedRuntime(__filename, dir) });
91
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio, sharing one Brightspace runtime with this computer's other MCP clients");
92
+ log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
93
+ return;
94
+ }
95
+ const { configureDevActivity } = await import("./utils/dev-activity.js");
96
+ const { setActiveShortcuts } = await import("./utils/two-factor-shortcuts.js");
97
+ const { startUpdateChecks } = await import("./utils/update-checker.js");
98
+ const { createRuntime } = await import("./server.js");
99
+ configureDevActivity(config.sessionDir);
100
+ // Lets the MFA approval message mention the setup options only to users
101
+ // who have neither turned on. Booleans only; the key is never passed.
102
+ setActiveShortcuts({ rememberMfa: config.rememberMfa === true, keySaved: Boolean(config.totpUri) });
103
+ // Both serve many clients, so the sign-in's progress lines go to this
104
+ // process's log and to none of them; see createRuntime. Each call's MFA
105
+ // challenge still reaches the client that made the call.
106
+ const runtime = createRuntime(config, PKG_VERSION);
107
+ // Check for updates now, then every few hours. A server can stay alive
108
+ // for days, so a boot-only check would never see a release. The
91
109
  // interval is unref'd and cannot hold the process open.
92
- startUpdateChecks({ transport: http ? "http" : "stdio" });
110
+ startUpdateChecks({ transport: mode === "http" ? "http" : "stdio" });
93
111
  // Log active course filter config if any filter is set
94
112
  if (config.courseFilter.includeCourseIds || config.courseFilter.excludeCourseIds || !config.courseFilter.activeOnly) {
95
113
  log("DEBUG", "Course filter config", {
@@ -98,37 +116,43 @@ else {
98
116
  activeOnly: config.courseFilter.activeOnly,
99
117
  });
100
118
  }
101
- if (http) {
102
- const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
103
- const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
104
- stopHttp = running.close;
105
- log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (26 tools, 4 prompts registered)`);
106
- log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
107
- return;
119
+ if (mode === 'runtime') {
120
+ const { DEFAULT_IDLE_MS, runtimeDirectory, serveSharedRuntime } = await import("./shared-runtime.js");
121
+ const idleSeconds = Number(process.env.D2L_RUNTIME_IDLE_SECONDS);
122
+ const idleMs = Number.isFinite(idleSeconds) && idleSeconds > 0 ? idleSeconds * 1000 : DEFAULT_IDLE_MS;
123
+ const dir = process.argv[3] ?? runtimeDirectory(config, PKG_VERSION);
124
+ const shared = await serveSharedRuntime(runtime, { dir, idleMs });
125
+ if (!shared) {
126
+ log("INFO", "Another shared runtime is already serving these settings; exiting");
127
+ process.exit(0);
128
+ }
129
+ stopServing = shared.stop;
130
+ log("INFO", `Shared runtime listening on ${shared.address.url} (pid ${process.pid}); stops after ${Math.round(idleMs / 1000)}s idle`);
131
+ await shared.stopped;
132
+ process.exit(0);
108
133
  }
109
- // Connect stdio transport
110
- stdioServer = createMcpServer(runtime);
111
- const transport = new StdioServerTransport();
112
- await stdioServer.connect(transport);
113
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (26 tools, 4 prompts registered)");
114
- log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
134
+ const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
135
+ const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
136
+ stopServing = running.close;
137
+ log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (26 tools, 4 prompts registered)`);
138
+ log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
115
139
  }
116
140
  catch (error) {
117
141
  log("ERROR", "MCP Server failed to start", error);
118
142
  process.exit(1);
119
143
  }
120
144
  }
121
- // Graceful shutdown. The HTTP server first answers the calls already in
122
- // progress (other chats may be mid-call); a second signal stops at once.
145
+ // Graceful shutdown. A server shared by other chats first answers the
146
+ // calls already in progress; a second signal stops at once.
123
147
  let shuttingDown = false;
124
148
  const shutdown = () => {
125
- if (shuttingDown || !stopHttp) {
149
+ if (shuttingDown || !stopServing) {
126
150
  log('INFO', 'Shutting down MCP server');
127
151
  process.exit(0);
128
152
  }
129
153
  shuttingDown = true;
130
154
  log('INFO', 'Shutting down MCP server after the calls in progress finish (press Ctrl+C again to stop now)');
131
- void stopHttp().then(() => process.exit(0));
155
+ void stopServing().then(() => process.exit(0));
132
156
  };
133
157
  process.on('SIGINT', shutdown);
134
158
  process.on('SIGTERM', shutdown);
@@ -0,0 +1,290 @@
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
+ import { spawn } from "node:child_process";
7
+ import { createInterface } from "node:readline";
8
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, truncateSync } from "node:fs";
9
+ import * as path from "node:path";
10
+ import { setTimeout as delay } from "node:timers/promises";
11
+ import { readRuntimeAddress } from "./shared-runtime.js";
12
+ import { log } from "./utils/logger.js";
13
+ import { startupPhase } from "./utils/startup-phase.js";
14
+ /** How long a chat waits for a runtime it started to publish its address. */
15
+ const DEFAULT_START_TIMEOUT_MS = 30_000;
16
+ /** One try, and one more on a runtime started fresh after the first could not be reached. */
17
+ const MAX_ATTEMPTS = 2;
18
+ const RUNTIME_LOG = "runtime.log";
19
+ /** The runtime log is kept across restarts until it grows past this. */
20
+ const MAX_LOG_BYTES = 1024 * 1024;
21
+ /** The request never reached a runtime: try again on a fresh one. */
22
+ class Unreachable extends Error {
23
+ }
24
+ /**
25
+ * Relay one MCP client's messages to the shared runtime for `dir`, starting
26
+ * one when none is running or the published one does not answer.
27
+ *
28
+ * This is what the default command does on stdio. Nothing starts until the
29
+ * client's first message, so an editor that launches the server and never
30
+ * uses it never starts a runtime. Each request is one POST; its reply and the
31
+ * notifications that belong to it (an MFA challenge's progress) come back on
32
+ * that POST's stream and go straight to this client and no other. When this
33
+ * client goes away its requests in progress are dropped, which ends only
34
+ * those requests in the runtime.
35
+ */
36
+ export async function relaySharedRuntime(transport, { dir, startRuntime, startTimeoutMs = DEFAULT_START_TIMEOUT_MS }) {
37
+ const disconnected = new AbortController();
38
+ let current;
39
+ let locating;
40
+ /** The address to use, after `failed` (if given) did not answer. */
41
+ const locate = (failed) => {
42
+ if (current && current.token !== failed?.token)
43
+ return Promise.resolve(current);
44
+ locating ??= (async () => {
45
+ const published = await readRuntimeAddress(dir);
46
+ if (published && published.token !== failed?.token)
47
+ return published;
48
+ // A runtime that exits with 0 lost the race to another one, which will
49
+ // publish; any other exit before an address appears is a failed start.
50
+ let exited;
51
+ void startRuntime()?.then((exit) => { exited = exit; });
52
+ // The runtime logs its own startup to runtime.log, which the client
53
+ // never sees; these lines put the wait for it in the client's log.
54
+ return startupPhase("starting the shared runtime", async () => {
55
+ const deadline = Date.now() + startTimeoutMs;
56
+ while (Date.now() < deadline && !disconnected.signal.aborted) {
57
+ await delay(50);
58
+ const started = await readRuntimeAddress(dir);
59
+ if (started && started.token !== failed?.token)
60
+ return started;
61
+ if (exited && exited.code !== 0) {
62
+ const why = exited.reason ? `: ${exited.reason.replace(/\.$/, "")}` : ` (exit code ${exited.code})`;
63
+ throw new Error(`The shared Brightspace runtime stopped while starting${why}. Its log is ${path.join(dir, RUNTIME_LOG)}.`);
64
+ }
65
+ }
66
+ throw new Error(`The shared Brightspace runtime did not start within ${Math.round(startTimeoutMs / 1000)}s. ` +
67
+ `Its log is ${path.join(dir, RUNTIME_LOG)}.`);
68
+ });
69
+ })().then((address) => (current = address)).finally(() => { locating = undefined; });
70
+ return locating;
71
+ };
72
+ const forward = async (message) => {
73
+ const id = "method" in message && "id" in message ? message.id : undefined;
74
+ let answered = false;
75
+ const reply = (out) => {
76
+ if (id !== undefined && "id" in out && out.id === id && !("method" in out))
77
+ answered = true;
78
+ void transport.send(out).catch(() => { });
79
+ };
80
+ try {
81
+ let failed;
82
+ for (let attempt = 1;; attempt += 1) {
83
+ const address = await locate(failed);
84
+ try {
85
+ await post(address, message, disconnected.signal, reply);
86
+ if (id !== undefined && !answered)
87
+ throw new Error("the runtime ended the call without answering it");
88
+ return;
89
+ }
90
+ catch (error) {
91
+ if (!(error instanceof Unreachable) || attempt >= MAX_ATTEMPTS)
92
+ throw error;
93
+ log("INFO", `Shared runtime did not answer (${error.message}); starting a fresh one`);
94
+ failed = address;
95
+ }
96
+ }
97
+ }
98
+ catch (error) {
99
+ if (disconnected.signal.aborted)
100
+ return;
101
+ log("ERROR", "Relaying to the shared runtime failed", error);
102
+ if (id !== undefined && !answered)
103
+ reply(failure(id, error));
104
+ }
105
+ };
106
+ transport.onmessage = (message) => { void forward(message); };
107
+ transport.onclose = () => disconnected.abort();
108
+ await transport.start();
109
+ }
110
+ function failure(id, error) {
111
+ const reason = error instanceof Error ? error.message : String(error);
112
+ return {
113
+ jsonrpc: "2.0",
114
+ id,
115
+ error: { code: -32603, message: `Could not reach the shared Brightspace runtime: ${reason}` },
116
+ };
117
+ }
118
+ /**
119
+ * POST one message and hand every message that comes back to `reply`.
120
+ * Throws Unreachable when the runtime never took the request: nothing
121
+ * listening, shutting down (503), or a different runtime now holding the
122
+ * address (401, a token from a runtime that has since been replaced).
123
+ */
124
+ async function post(address, message, signal, reply) {
125
+ let response;
126
+ try {
127
+ response = await fetch(address.url, {
128
+ method: "POST",
129
+ headers: {
130
+ Authorization: `Bearer ${address.token}`,
131
+ "Content-Type": "application/json",
132
+ Accept: "application/json, text/event-stream",
133
+ },
134
+ body: JSON.stringify(message),
135
+ signal,
136
+ });
137
+ }
138
+ catch (error) {
139
+ if (signal.aborted)
140
+ throw error;
141
+ throw new Unreachable(error instanceof Error ? String(error.cause?.message ?? error.message) : String(error));
142
+ }
143
+ if (response.status === 503 || response.status === 401) {
144
+ await response.body?.cancel();
145
+ throw new Unreachable(`HTTP ${response.status}`);
146
+ }
147
+ if (!response.ok)
148
+ throw new Error(`HTTP ${response.status}: ${await response.text()}`);
149
+ if (response.status === 202 || !response.body)
150
+ return;
151
+ const type = response.headers.get("content-type") ?? "";
152
+ if (!type.includes("text/event-stream")) {
153
+ const body = JSON.parse(await response.text());
154
+ for (const item of Array.isArray(body) ? body : [body])
155
+ reply(item);
156
+ return;
157
+ }
158
+ const decoder = new TextDecoder();
159
+ let buffered = "";
160
+ for await (const chunk of response.body) {
161
+ buffered += decoder.decode(chunk, { stream: true });
162
+ const { events, rest } = splitEvents(buffered);
163
+ buffered = rest;
164
+ for (const data of events)
165
+ reply(JSON.parse(data));
166
+ }
167
+ }
168
+ /**
169
+ * The complete server-sent events in `buffered`, as the joined text of each
170
+ * event's `data:` lines, and whatever incomplete event is left over. Events
171
+ * with no data (comments, priming events) are dropped.
172
+ */
173
+ function splitEvents(buffered) {
174
+ const blocks = buffered.split(/\r?\n\r?\n/);
175
+ const rest = blocks.pop() ?? "";
176
+ const events = blocks
177
+ .map((block) => block
178
+ .split(/\r?\n/)
179
+ .filter((line) => line.startsWith("data:"))
180
+ .map((line) => line.slice(5).replace(/^ /, ""))
181
+ .join("\n"))
182
+ .filter((data) => data.length > 0);
183
+ return { events, rest };
184
+ }
185
+ /**
186
+ * Start `brightspace-mcp-server runtime <dir>` detached, so it outlives the
187
+ * chat that started it and serves every other one. Its log goes to
188
+ * runtime.log in `dir`, since no MCP client is listening to its stderr.
189
+ * Resolves with how it exited if it exits while this chat is still open.
190
+ */
191
+ export function spawnSharedRuntime(entry, dir) {
192
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
193
+ const logFile = path.join(dir, RUNTIME_LOG);
194
+ try {
195
+ if (statSync(logFile).size > MAX_LOG_BYTES)
196
+ truncateSync(logFile);
197
+ }
198
+ catch {
199
+ // No log yet.
200
+ }
201
+ const logFd = openSync(logFile, "a", 0o600);
202
+ // Where this run's lines start, so a failure is read from this run only.
203
+ const offset = statSync(logFile).size;
204
+ // The runtime outlives the chat that started it, so it runs in its own
205
+ // directory: Windows will not delete or rename a folder that a live process
206
+ // is working in, and that chat's project folder would stay locked until the
207
+ // runtime idled out. The settings it needs, .env included, are already in
208
+ // this process's environment; a relative session directory is pinned to
209
+ // where this chat resolved it.
210
+ const env = { ...process.env };
211
+ const sessionDir = env.D2L_SESSION_DIR;
212
+ if (sessionDir && !sessionDir.startsWith("~") && !path.isAbsolute(sessionDir))
213
+ env.D2L_SESSION_DIR = path.resolve(sessionDir);
214
+ try {
215
+ const child = spawn(process.execPath, [entry, "runtime", path.resolve(dir)], {
216
+ cwd: dir,
217
+ detached: true,
218
+ stdio: ["ignore", "ignore", logFd],
219
+ env,
220
+ windowsHide: true,
221
+ });
222
+ child.once("error", (error) => log("ERROR", "Could not start the shared runtime", error));
223
+ child.unref();
224
+ return new Promise((resolve) => {
225
+ child.once("exit", (code) => {
226
+ let reason;
227
+ if (code !== 0) {
228
+ try {
229
+ reason = startupFailure(readFileSync(logFile).subarray(offset).toString("utf-8"));
230
+ }
231
+ catch {
232
+ // No log to read; the exit code alone is reported.
233
+ }
234
+ }
235
+ resolve({ code, reason });
236
+ });
237
+ child.once("error", () => resolve({ code: null }));
238
+ });
239
+ }
240
+ finally {
241
+ closeSync(logFd);
242
+ }
243
+ }
244
+ /**
245
+ * The relay's side of stdio: one JSON-RPC message per line, as the MCP stdio
246
+ * transport frames them. The SDK's own transport validates every message
247
+ * with its schemas, which is the memory the relay exists to save; a line that
248
+ * is not JSON is dropped, since the runtime would reject it anyway.
249
+ */
250
+ export function stdioLines(input = process.stdin, output = process.stdout) {
251
+ const transport = {
252
+ async start() {
253
+ const lines = createInterface({ input });
254
+ lines.on("line", (line) => {
255
+ if (!line.trim())
256
+ return;
257
+ let message;
258
+ try {
259
+ message = JSON.parse(line);
260
+ }
261
+ catch {
262
+ return;
263
+ }
264
+ transport.onmessage?.(message);
265
+ });
266
+ lines.once("close", () => transport.onclose?.());
267
+ },
268
+ async send(message) {
269
+ output.write(JSON.stringify(message) + "\n");
270
+ },
271
+ async close() {
272
+ transport.onclose?.();
273
+ },
274
+ };
275
+ return transport;
276
+ }
277
+ /**
278
+ * The error a runtime logged when it failed to start ("MCP Server failed to
279
+ * start", in index.ts), without its stack, from the given part of its log.
280
+ * The logger has already redacted it, as it does every line.
281
+ */
282
+ export function startupFailure(logText) {
283
+ const marker = "MCP Server failed to start ";
284
+ const line = logText.split(/\r?\n/).reverse().find((candidate) => candidate.includes(marker));
285
+ if (!line)
286
+ return undefined;
287
+ const error = line.slice(line.indexOf(marker) + marker.length);
288
+ const message = /^(.*?)\s+at\s+\S*:\d+:\d+/.exec(error)?.[1] ?? /^(.*?)\s+at\s+\S+\s+\(/.exec(error)?.[1] ?? error;
289
+ return message.trim().slice(0, 300) || undefined;
290
+ }
package/build/server.js CHANGED
@@ -19,12 +19,11 @@ import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPl
19
19
  * whether or not they ever asked about a course, and a tenant that was
20
20
  * briefly unreachable would take the whole server down with it.
21
21
  *
22
- * onAuthProgress receives the sign-in child's progress lines. Over stdio they
23
- * go to the one client as logging messages; the HTTP server passes none, so
24
- * they reach only its own log, never a client that did not ask. Each call's
25
- * MFA challenge still reaches that call's client (see relayMfaChallenges).
22
+ * The runtime serves many clients, so the sign-in child's progress lines go
23
+ * only to its own log, never to a client that did not ask. Each call's MFA
24
+ * challenge still reaches that call's client (see relayMfaChallenges).
26
25
  */
27
- export function createRuntime(config, version, onAuthProgress) {
26
+ export function createRuntime(config, version) {
28
27
  const tokenManager = new TokenManager({
29
28
  sessionDir: config.sessionDir,
30
29
  baseUrl: config.baseUrl,
@@ -41,7 +40,7 @@ export function createRuntime(config, version, onAuthProgress) {
41
40
  const envAuthActive = Boolean(config.envAccessToken || config.envSessionCookie);
42
41
  const authRunner = envAuthActive
43
42
  ? undefined
44
- : new AuthRunner({ sessionDir: config.sessionDir, onProgress: onAuthProgress });
43
+ : new AuthRunner({ sessionDir: config.sessionDir });
45
44
  // currentMfaAnnouncer() is read inside the tool call that hit the expired
46
45
  // session, so the challenge is announced to that call's client only.
47
46
  const apiClient = new D2LApiClient({