brightspace-mcp-server 5.0.7 → 5.0.9

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
@@ -129,15 +129,16 @@ Normally every chat starts its own copy of the server. With many Codex chats ope
129
129
  url = "http://127.0.0.1:18765/mcp"
130
130
  bearer_token_env_var = "D2L_HTTP_TOKEN"
131
131
  ```
132
- (`env_http_headers = { "Authorization" = "D2L_HTTP_AUTHORIZATION" }` also works, if that variable holds `Bearer <token>`.) A project's own `.codex/config.toml` overrides this one, so remove any `brightspace` entry there or change it the same way. If you rerun `setup` later, say no when it offers to replace the Codex entry.
132
+ (`env_http_headers = { "Authorization" = "D2L_HTTP_AUTHORIZATION" }` also works, if that variable holds `Bearer <token>`.) A project's own `.codex/config.toml` overrides this one, so remove any `brightspace` entry there or change it the same way. Rerunning `setup` recognizes this entry and keeps it, token settings included.
133
133
  4. **Restart Codex.** Chats that were already open keep the old per-chat server until Codex restarts. If you stop or restart the shared server, calls fail until it is back; if they still fail after that, start a new chat or restart Codex.
134
134
 
135
135
  Things that change:
136
136
  - **Settings go to the server, not to Codex.** A URL entry has no process to pass an `env` table to, so `D2L_*` settings (the [table above](#settings-you-might-want)) are set in the terminal that runs `http`, in a `.env` file in the directory you start it from, or saved by `setup`.
137
137
  - **Sign-in messages show up in the server's terminal.** The number to approve still comes back in the chat's answer, and, in clients that show tool progress, while the question is running.
138
138
  - **Stopping it:** press Ctrl+C. Questions already running finish first; press Ctrl+C again to stop at once.
139
+ - **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.
139
140
 
140
- 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 (rerunning `setup` does it) and stop the shared server.
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.
141
142
 
142
143
  ## When it asks you to sign in
143
144
 
package/build/index.js CHANGED
@@ -75,7 +75,7 @@ else {
75
75
  // Check for updates now, then every few hours. A stdio server can stay
76
76
  // alive for days, so a boot-only check would never see a release. The
77
77
  // interval is unref'd and cannot hold the process open.
78
- startUpdateChecks();
78
+ startUpdateChecks({ transport: http ? "http" : "stdio" });
79
79
  // Log active course filter config if any filter is set
80
80
  if (config.courseFilter.includeCourseIds || config.courseFilter.excludeCourseIds || !config.courseFilter.activeOnly) {
81
81
  log("DEBUG", "Course filter config", {
@@ -163,7 +163,8 @@ export function detectMcpClients(overrides = {}) {
163
163
  }
164
164
  /**
165
165
  * Register the server in every given app without asking. An entry that runs
166
- * something else is left alone unless `replace` is set, and a write that
166
+ * something else, or connects to the shared server by URL, is left alone
167
+ * unless `replace` is set, and a write that
167
168
  * fails (an agent's sandbox may block another app's settings folder) is
168
169
  * reported for that app instead of stopping the rest.
169
170
  */
@@ -174,13 +175,16 @@ export function registerAll(targets, { replace }) {
174
175
  const existing = target.inspect();
175
176
  if (existing.state === "current")
176
177
  return { ...base, outcome: "current" };
178
+ if (existing.state === "shared_http" && !replace)
179
+ return { ...base, outcome: "shared_http", url: existing.url };
177
180
  if (existing.state === "different" && !replace) {
178
181
  return { ...base, outcome: "different_left", current: existing.current };
179
182
  }
180
- if (!target.write(existing.state === "different")) {
183
+ const replacing = existing.state !== "missing";
184
+ if (!target.write(replacing)) {
181
185
  return { ...base, outcome: "failed", message: `${target.name} did not accept the registration.` };
182
186
  }
183
- return { ...base, outcome: existing.state === "different" ? "replaced" : "added" };
187
+ return { ...base, outcome: replacing ? "replaced" : "added" };
184
188
  }
185
189
  catch (error) {
186
190
  return { ...base, outcome: "failed", message: error instanceof Error ? error.message : String(error) };
@@ -87,6 +87,8 @@ function appLine(outcome) {
87
87
  return ok("app", `${outcome.name}: old "brightspace" entry replaced`, false);
88
88
  case "current":
89
89
  return ok("app", `${outcome.name}: already set up`, false);
90
+ case "shared_http":
91
+ return ok("app", `${outcome.name}: already set up to use the shared server at ${outcome.url}`, false);
90
92
  case "different_left":
91
93
  return fail("app", `${outcome.name}: "brightspace" already runs ${outcome.current} — left as is (pass --replace to change it)`, false);
92
94
  case "failed":
package/build/setup.js CHANGED
@@ -151,34 +151,43 @@ export function planQuestions(mode, known) {
151
151
  // ── Registering with one app ───────────────────────────────────────
152
152
  /**
153
153
  * Registers Brightspace in one app, never silently replacing an entry
154
- * that points somewhere else: a matching entry is left alone, and a different
155
- * one is printed (so it can be restored) and replaced only on a yes.
156
- * Resolves true when the app ends up running the expected entry.
154
+ * that points somewhere else: a matching entry is left alone, an entry using
155
+ * the shared server by URL is kept unless the person asks to switch back, and
156
+ * a different one is printed (so it can be restored) and replaced only on a yes.
157
+ * Resolves true when the app ends up set up.
157
158
  */
158
- async function registerWithClient(rl, target) {
159
+ export async function registerWithClient(target, askLine, log = console.log) {
159
160
  const existing = target.inspect();
160
161
  if (existing.state === "current") {
161
- console.log(green(` ${target.name} already has Brightspace configured.`));
162
+ log(green(` ${target.name} already has Brightspace configured.`));
162
163
  return true;
163
164
  }
165
+ if (existing.state === "shared_http") {
166
+ log(green(` ${target.name} uses the shared Brightspace server at ${existing.url}.`));
167
+ const switchBack = await askLine(` Switch it back to its own copy per chat (${serverCommand().join(" ")})? (yes/no): `);
168
+ if (!/^y(es)?$/i.test(switchBack)) {
169
+ log(dim(` Kept ${target.name} on the shared server.`));
170
+ return true;
171
+ }
172
+ }
164
173
  if (existing.state === "different") {
165
- console.log(yellow(` ${target.name} has "brightspace" pointing at:`));
166
- console.log(` ${existing.current}`);
167
- const replace = await ask(rl, ` Replace it with ${serverCommand().join(" ")}? (yes/no): `);
174
+ log(yellow(` ${target.name} has "brightspace" pointing at:`));
175
+ log(` ${existing.current}`);
176
+ const replace = await askLine(` Replace it with ${serverCommand().join(" ")}? (yes/no): `);
168
177
  if (!/^y(es)?$/i.test(replace)) {
169
- console.log(dim(` Left ${target.name} unchanged.`));
178
+ log(dim(` Left ${target.name} unchanged.`));
170
179
  return false;
171
180
  }
172
181
  }
173
182
  try {
174
- if (target.write(existing.state === "different")) {
175
- console.log(green(` ${target.name} configured!`));
183
+ if (target.write(existing.state !== "missing")) {
184
+ log(green(` ${target.name} configured!`));
176
185
  return true;
177
186
  }
178
- console.log(yellow(` Could not configure ${target.name}. See README.md for the manual command.`));
187
+ log(yellow(` Could not configure ${target.name}. See README.md for the manual command.`));
179
188
  }
180
189
  catch (err) {
181
- console.log(yellow(` Could not configure ${target.name}: ${err instanceof Error ? err.message : String(err)}`));
190
+ log(yellow(` Could not configure ${target.name}: ${err instanceof Error ? err.message : String(err)}`));
182
191
  }
183
192
  return false;
184
193
  }
@@ -474,7 +483,7 @@ async function interactive(args) {
474
483
  if (plan.registerApps) {
475
484
  for (const target of detectMcpClients()) {
476
485
  const answer = await ask(rl2, target.question);
477
- if (/^y(es)?$/i.test(answer) && await registerWithClient(rl2, target)) {
486
+ if (/^y(es)?$/i.test(answer) && await registerWithClient(target, (question) => ask(rl2, question))) {
478
487
  configuredClients.push(target.name);
479
488
  }
480
489
  console.log("");
package/build/update.js CHANGED
@@ -312,6 +312,11 @@ export function main() {
312
312
  }
313
313
  console.log("");
314
314
  console.log(" Restart your MCP client to use the latest version.");
315
+ console.log(" If you run the shared HTTP server, restart the server instead: a client restart only");
316
+ console.log(" reconnects to the old process. Stop it, start node build/index.js http again with the");
317
+ console.log(" same D2L_HTTP_TOKEN, port, and D2L_* settings, and reconnect any client that does not.");
318
+ console.log(dim(" A chat already connected over stdio keeps its own copy and cannot move onto the shared"));
319
+ console.log(dim(" server mid-session; restart its client to load the new build."));
315
320
  console.log("");
316
321
  }
317
322
  // `npm run update` runs this file directly. VITEST is set only by the test
@@ -31,13 +31,33 @@ export function serverCommand(platform = process.platform) {
31
31
  ? ["cmd", "/c", "npx", "-y", `${PACKAGE_NAME}@latest`]
32
32
  : ["npx", "-y", `${PACKAGE_NAME}@latest`];
33
33
  }
34
+ /**
35
+ * Whether a URL is where the shared server listens: plain http on this
36
+ * computer, at the MCP path (see HTTP_HOST and MCP_PATH in http-server.ts,
37
+ * which also accepts "localhost"). Any other URL is some other server.
38
+ */
39
+ function isSharedServerUrl(url) {
40
+ let parsed;
41
+ try {
42
+ parsed = new URL(url);
43
+ }
44
+ catch {
45
+ return false;
46
+ }
47
+ return parsed.protocol === "http:"
48
+ && (parsed.hostname === "127.0.0.1" || parsed.hostname === "localhost")
49
+ && parsed.pathname === "/mcp";
50
+ }
34
51
  export function classifyRegistration(entry, platform = process.platform) {
35
52
  if (entry === undefined)
36
53
  return { state: "missing" };
37
54
  if (entry === null || typeof entry !== "object") {
38
55
  return { state: "different", current: "(an entry setup could not read)" };
39
56
  }
40
- const { command, args } = entry;
57
+ const { command, args, url } = entry;
58
+ if (typeof command !== "string" && typeof url === "string") {
59
+ return isSharedServerUrl(url) ? { state: "shared_http", url } : { state: "different", current: url };
60
+ }
41
61
  if (typeof command !== "string") {
42
62
  return { state: "different", current: JSON.stringify(entry) };
43
63
  }
@@ -20,7 +20,11 @@
20
20
  *
21
21
  * A source checkout gets different advice: installing the npm package would
22
22
  * update some other copy, and for a fork it would mean leaving its changes
23
- * behind. Such a checkout is told to pull and rebuild, or to opt out.
23
+ * behind. Such a checkout is told to run its own updater, or to opt out.
24
+ *
25
+ * What to restart depends on the transport: a stdio server dies with its
26
+ * client, but the shared HTTP server outlives every client and must itself be
27
+ * restarted.
24
28
  *
25
29
  * Set D2L_NO_UPDATE_CHECK to any value to switch the check off entirely.
26
30
  */
@@ -162,13 +166,37 @@ export async function fetchLatestVersion(fetchImpl = fetch) {
162
166
  return null;
163
167
  }
164
168
  }
169
+ /** The command that starts the shared HTTP server from each kind of install. */
170
+ const HTTP_START_COMMAND = {
171
+ "npx-cache": `npx -y ${PACKAGE_NAME}@latest http`,
172
+ "npm-install": `${PACKAGE_NAME} http`,
173
+ "source-checkout": "node build/index.js http",
174
+ };
175
+ /**
176
+ * What to restart so the new version actually runs. A stdio server lives as
177
+ * long as its client, so restarting the client is enough. The shared HTTP
178
+ * server outlives every client: restarting one only reconnects it to the same
179
+ * old process, so the server itself has to be restarted, with the token and
180
+ * port its clients already use.
181
+ */
182
+ function reloadStep(installKind, transport, to, afterAStep) {
183
+ // "Then" only follows an update step; an npx install with no stale cache to
184
+ // clear has none, so the restart is the first thing to do.
185
+ const restart = afterAStep ? "Then restart" : "Restart";
186
+ if (transport === "stdio")
187
+ return `${restart} your MCP client to pick up v${to}.`;
188
+ return `${restart} the shared HTTP server to pick up v${to}: restarting an MCP client only reconnects it ` +
189
+ `to this running server. Stop it (Ctrl+C where it runs, or stop its service), then start it again with ` +
190
+ `${HTTP_START_COMMAND[installKind]}, using the same D2L_HTTP_TOKEN, port, and D2L_* settings, ` +
191
+ `and reconnect any client that does not reconnect on its own.`;
192
+ }
165
193
  /**
166
194
  * Run one update check. Never throws and never blocks the caller for long:
167
195
  * the registry request is bounded by a timeout and every failure is
168
196
  * swallowed, because a version check must not affect the server.
169
197
  */
170
198
  export async function initUpdateChecker(deps = {}) {
171
- const { fetchImpl = fetch, env = process.env, installedVersion = getInstalledVersion(), installKind = installKindOf(), clearCaches = () => clearAllNpxCaches(), } = deps;
199
+ const { fetchImpl = fetch, env = process.env, installedVersion = getInstalledVersion(), installKind = installKindOf(), clearCaches = () => clearAllNpxCaches(), transport = "stdio", } = deps;
172
200
  if (env.D2L_NO_UPDATE_CHECK)
173
201
  return;
174
202
  try {
@@ -180,24 +208,27 @@ export async function initUpdateChecker(deps = {}) {
180
208
  const from = safeVersionLabel(installedVersion);
181
209
  const to = safeVersionLabel(latest);
182
210
  const headline = `Update available: v${from} to v${to}.`;
183
- let text;
211
+ let update;
184
212
  if (installKind === "npx-cache") {
185
213
  const count = await clearCaches();
186
- const cleanup = count > 0
214
+ update = count > 0
187
215
  ? `Cleared ${count} stale npx cache director${count === 1 ? "y" : "ies"} for ${PACKAGE_NAME} ` +
188
- `(kept the one this server is running from). `
216
+ `(kept the one this server is running from).`
189
217
  : "";
190
- text = `${headline} ${cleanup}Restart your MCP client to pick up v${to}.`;
191
218
  }
192
219
  else if (installKind === "source-checkout") {
193
- text = `${headline} This server runs from a source checkout, so update that checkout ` +
194
- `(git pull, then npm run build) and restart your MCP client. ` +
195
- `If it is a fork you maintain, set D2L_NO_UPDATE_CHECK=1 to turn off upstream update notices.`;
220
+ update = `This server runs from a source checkout, so update that checkout with npm run update ` +
221
+ `(it pulls, installs dependencies, and rebuilds).`;
196
222
  }
197
223
  else {
198
- text = `${headline} Run: ${GLOBAL_INSTALL_COMMAND}` +
199
- `, then ${CLEAR_NPX_CACHE_COMMAND} and restart your MCP client.`;
224
+ update = `Run: ${GLOBAL_INSTALL_COMMAND}, then ${CLEAR_NPX_CACHE_COMMAND}.`;
200
225
  }
226
+ const optOut = installKind === "source-checkout"
227
+ ? " If it is a fork you maintain, set D2L_NO_UPDATE_CHECK=1 to turn off upstream update notices."
228
+ : "";
229
+ const text = [headline, update, `${reloadStep(installKind, transport, to, update !== "")}${optOut}`]
230
+ .filter(Boolean)
231
+ .join(" ");
201
232
  // Preserve lastShownAt when the text is unchanged, so a periodic re-check
202
233
  // does not reset the throttle and start repeating the same line.
203
234
  state = state?.text === text ? state : { text, lastShownAt: null };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.0.7",
3
+ "version": "5.0.9",
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",