brightspace-mcp-server 5.0.12 → 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 +11 -3
- package/build/http-server.js +3 -1
- package/build/index.js +68 -44
- package/build/runtime-relay.js +290 -0
- package/build/server.js +5 -6
- package/build/shared-runtime.js +126 -0
- package/build/tools/download-dropbox-submission-file.js +10 -10
- package/build/tools/download-file.js +15 -15
- package/build/tools/get-calendar-events.js +1 -1
- package/build/tools/get-discussions.js +1 -1
- package/build/tools/get-dropbox-feedback.js +2 -2
- package/build/tools/get-dropbox-folders.js +1 -1
- package/build/tools/get-dropbox-submissions.js +1 -1
- package/build/tools/get-dropbox-user-submissions.js +1 -1
- package/build/tools/get-my-grades.js +1 -1
- package/build/tools/get-rubrics-for-object.js +1 -1
- package/build/tools/get-syllabus.js +5 -5
- package/build/tools/get-video-transcript.js +14 -2
- package/build/tools/schemas.js +1 -1
- package/build/tools/tool-helpers.js +8 -2
- package/build/utils/config.js +8 -3
- package/build/utils/course-files.js +68 -12
- package/build/utils/dev-activity.js +13 -2
- package/build/utils/error-kind.js +47 -0
- package/build/utils/startup-phase.js +37 -0
- package/package.json +1 -1
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
|
|
113
|
+
## One server for all your chats
|
|
113
114
|
|
|
114
|
-
|
|
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
|
|
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
|
|
package/build/http-server.js
CHANGED
|
@@ -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 {
|
|
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
|
|
58
|
-
|
|
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,
|
|
64
|
-
/** Set once the HTTP server is listening; shutdown waits on it. */
|
|
65
|
-
let
|
|
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
|
-
//
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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 (
|
|
102
|
-
const {
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
log("INFO", "
|
|
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.
|
|
122
|
-
//
|
|
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 || !
|
|
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
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
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
|
|
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({
|