brightspace-mcp-server 4.0.7 → 5.0.0
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 +32 -2
- package/build/doctor.js +2 -2
- package/build/http-server.js +198 -0
- package/build/index.js +37 -109
- package/build/server.js +118 -0
- package/build/setup/prepare.js +2 -2
- package/build/tools/tool-helpers.js +7 -6
- package/build/utils/process-guard.js +11 -6
- package/build/utils/saved-session.js +2 -2
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
[](https://www.npmjs.com/package/brightspace-mcp-server)
|
|
5
5
|
[](https://github.com/RohanMuppa/brightspace-mcp-server/actions/workflows/ci.yml)
|
|
6
6
|
[](LICENSE)
|
|
7
|
-
[](package.json)
|
|
8
8
|
|
|
9
9
|
> **By [Rohan Muppa](https://github.com/rohanmuppa), ECE @ Purdue**
|
|
10
10
|
|
|
@@ -20,7 +20,7 @@ Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and a
|
|
|
20
20
|
|
|
21
21
|
## Get started in 3 steps
|
|
22
22
|
|
|
23
|
-
1. **Install [Node.js](https://nodejs.org/)** (version
|
|
23
|
+
1. **Install [Node.js](https://nodejs.org/)** (version 22 or newer) if you don't have it. Download, run the installer, done.
|
|
24
24
|
2. **Open a terminal and run the setup:**
|
|
25
25
|
```bash
|
|
26
26
|
npx -y brightspace-mcp-server@latest setup
|
|
@@ -109,6 +109,36 @@ All optional, set in your AI app's MCP `env` config or your shell. The full list
|
|
|
109
109
|
| `D2L_NO_UPDATE_CHECK=1` | Turn off the new-version notice |
|
|
110
110
|
| `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
111
|
|
|
112
|
+
## One server for all your chats (optional)
|
|
113
|
+
|
|
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
|
+
|
|
116
|
+
1. **Make a secret token, once.** Chats must present it, so other programs and web pages can't read your grades. Generate one:
|
|
117
|
+
```bash
|
|
118
|
+
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
|
119
|
+
```
|
|
120
|
+
Save it as a user environment variable named `D2L_HTTP_TOKEN`, where both the server and Codex can see it. On Windows: Start → "Edit environment variables for your account" → New, paste it as the value (apps and terminals opened afterwards see it). On macOS or Linux: add `export D2L_HTTP_TOKEN=<token>` to your shell profile (`~/.zshrc`, `~/.bashrc`); a Codex app launched from the Dock or a desktop menu may not read that profile, so start it from a terminal. The server never prints the token.
|
|
121
|
+
2. **Start the server and leave it running** in a terminal of its own:
|
|
122
|
+
```bash
|
|
123
|
+
npx -y brightspace-mcp-server@latest http
|
|
124
|
+
```
|
|
125
|
+
This needs a release with HTTP support; to try it from a source checkout first, run `npm run build`, then `node build/index.js http`. It listens on `http://127.0.0.1:18765/mcp`, on this computer only. Pick another port with `--port 18766` or `D2L_HTTP_PORT`, and use that same port in the URL in step 3. Start it once per login: chats do not start it, and a second copy on the same port stops at once with "already in use" rather than running alongside the first. Nothing signs in at startup.
|
|
126
|
+
3. **Point Codex at it.** In `~/.codex/config.toml`, replace the `command` and `args` lines of your `[mcp_servers.brightspace]` entry with:
|
|
127
|
+
```toml
|
|
128
|
+
[mcp_servers.brightspace]
|
|
129
|
+
url = "http://127.0.0.1:18765/mcp"
|
|
130
|
+
bearer_token_env_var = "D2L_HTTP_TOKEN"
|
|
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.
|
|
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
|
+
|
|
135
|
+
Things that change:
|
|
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
|
+
- **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
|
+
- **Stopping it:** press Ctrl+C. Questions already running finish first; press Ctrl+C again to stop at once.
|
|
139
|
+
|
|
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
|
+
|
|
112
142
|
## When it asks you to sign in
|
|
113
143
|
|
|
114
144
|
There's no separate login — asking a question signs you in, and it stays signed in on its own most days. When your school wants two-factor again, the number to approve shows up right in the answer; approve it on your phone and the assistant keeps checking on its own — each check waits up to 45 seconds and the original question completes as soon as the sign-in does, without you having to say you've approved it. If your AI client shows tool progress messages, the number appears while the question is still running instead. If it ever gets stuck, run this in a terminal:
|
package/build/doctor.js
CHANGED
|
@@ -79,9 +79,9 @@ function problem(id, message) {
|
|
|
79
79
|
// ── Individual checks ────────────────────────────────────────────────
|
|
80
80
|
function checkNode(deps) {
|
|
81
81
|
if (nodeVersionSupported(deps.nodeVersion)) {
|
|
82
|
-
return pass("node", `Node.js ${deps.nodeVersion} (
|
|
82
|
+
return pass("node", `Node.js ${deps.nodeVersion} (22 or newer)`);
|
|
83
83
|
}
|
|
84
|
-
return problem("node", `Node.js ${deps.nodeVersion} is too old — install Node
|
|
84
|
+
return problem("node", `Node.js ${deps.nodeVersion} is too old — install Node 22 or newer from https://nodejs.org/, then try again.`);
|
|
85
85
|
}
|
|
86
86
|
/**
|
|
87
87
|
* Checks the resolved configuration — config.json AND the same env overrides
|
|
@@ -0,0 +1,198 @@
|
|
|
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 { createServer } from "node:http";
|
|
7
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
8
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
9
|
+
import { createMcpServer } from "./server.js";
|
|
10
|
+
import { readEnvSecret } from "./utils/config.js";
|
|
11
|
+
import { log } from "./utils/logger.js";
|
|
12
|
+
/** The only address the shared server listens on. Never a LAN interface. */
|
|
13
|
+
export const HTTP_HOST = "127.0.0.1";
|
|
14
|
+
export const DEFAULT_HTTP_PORT = 18765;
|
|
15
|
+
export const MCP_PATH = "/mcp";
|
|
16
|
+
/** A tools/call body is a few hundred bytes; this leaves ample room. */
|
|
17
|
+
export const MAX_BODY_BYTES = 1024 * 1024;
|
|
18
|
+
/** 32 characters of hex is 128 bits, which no one guesses. */
|
|
19
|
+
export const MIN_TOKEN_LENGTH = 32;
|
|
20
|
+
const TOKEN_HINT = `Set D2L_HTTP_TOKEN to a random secret of at least ${MIN_TOKEN_LENGTH} characters, for example the output of ` +
|
|
21
|
+
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`;
|
|
22
|
+
/**
|
|
23
|
+
* The bearer token every request must carry. Required: this server answers
|
|
24
|
+
* with private grades and rosters, and any local program (or web page that
|
|
25
|
+
* got past the Host and Origin checks) could otherwise read them.
|
|
26
|
+
*/
|
|
27
|
+
export function resolveHttpToken(env = process.env) {
|
|
28
|
+
const token = readEnvSecret(env.D2L_HTTP_TOKEN, "D2L_HTTP_TOKEN");
|
|
29
|
+
if (!token)
|
|
30
|
+
throw new Error(`D2L_HTTP_TOKEN is not set. ${TOKEN_HINT}`);
|
|
31
|
+
if (token.length < MIN_TOKEN_LENGTH)
|
|
32
|
+
throw new Error(`D2L_HTTP_TOKEN is too short. ${TOKEN_HINT}`);
|
|
33
|
+
return token;
|
|
34
|
+
}
|
|
35
|
+
/** `--port N` or `--port=N`, then D2L_HTTP_PORT, then the default. */
|
|
36
|
+
export function resolveHttpPort(argv = process.argv, env = process.env) {
|
|
37
|
+
let raw;
|
|
38
|
+
for (let i = 0; i < argv.length; i++) {
|
|
39
|
+
if (argv[i] === "--port")
|
|
40
|
+
raw = argv[i + 1] ?? "";
|
|
41
|
+
else if (argv[i].startsWith("--port="))
|
|
42
|
+
raw = argv[i].slice("--port=".length);
|
|
43
|
+
}
|
|
44
|
+
raw ??= env.D2L_HTTP_PORT || undefined;
|
|
45
|
+
if (raw === undefined)
|
|
46
|
+
return DEFAULT_HTTP_PORT;
|
|
47
|
+
const port = /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : NaN;
|
|
48
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
49
|
+
throw new Error(`Invalid port ${JSON.stringify(raw)}: expected a whole number from 1 to 65535.`);
|
|
50
|
+
}
|
|
51
|
+
return port;
|
|
52
|
+
}
|
|
53
|
+
function sendError(res, status, message, headers = {}) {
|
|
54
|
+
if (res.headersSent) {
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
res.writeHead(status, { "Content-Type": "application/json", ...headers });
|
|
59
|
+
res.end(JSON.stringify({ jsonrpc: "2.0", error: { code: status === 400 ? -32700 : -32000, message }, id: null }));
|
|
60
|
+
}
|
|
61
|
+
class BodyTooLarge extends Error {
|
|
62
|
+
}
|
|
63
|
+
/** The request body, refusing anything over MAX_BODY_BYTES as it arrives. */
|
|
64
|
+
function readBody(req) {
|
|
65
|
+
return new Promise((resolve, reject) => {
|
|
66
|
+
const declared = Number(req.headers["content-length"]);
|
|
67
|
+
if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
|
|
68
|
+
reject(new BodyTooLarge());
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const chunks = [];
|
|
72
|
+
let size = 0;
|
|
73
|
+
const onData = (chunk) => {
|
|
74
|
+
size += chunk.length;
|
|
75
|
+
if (size > MAX_BODY_BYTES) {
|
|
76
|
+
req.off("data", onData);
|
|
77
|
+
reject(new BodyTooLarge());
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
chunks.push(chunk);
|
|
81
|
+
};
|
|
82
|
+
req.on("data", onData);
|
|
83
|
+
req.once("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
84
|
+
req.once("error", reject);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Serve MCP over Streamable HTTP on 127.0.0.1, for every client on this
|
|
89
|
+
* computer at once.
|
|
90
|
+
*
|
|
91
|
+
* Each POST gets its own McpServer and stateless transport, as the SDK
|
|
92
|
+
* requires (a stateless transport refuses a second request), but they all
|
|
93
|
+
* share the one runtime: the saved session, the single sign-in, and the API
|
|
94
|
+
* cache and rate limiter. There are no MCP sessions to track. GET (a
|
|
95
|
+
* standalone notification stream) and DELETE (ending a session) have nothing
|
|
96
|
+
* to do in stateless mode and answer 405, which clients treat as "not
|
|
97
|
+
* offered".
|
|
98
|
+
*
|
|
99
|
+
* Before any MCP handling, a request must name this server in Host (a web
|
|
100
|
+
* page reaching 127.0.0.1 through DNS rebinding carries its own host name),
|
|
101
|
+
* carry no Origin other than this server's own (a page in a browser always
|
|
102
|
+
* sends one), and present the bearer token.
|
|
103
|
+
*/
|
|
104
|
+
export function startHttpServer({ runtime, token, port }) {
|
|
105
|
+
const expectedToken = createHash("sha256").update(token).digest();
|
|
106
|
+
const authorized = (header) => {
|
|
107
|
+
const match = /^Bearer[ ]+(\S+)[ ]*$/i.exec(header ?? "");
|
|
108
|
+
return match !== null && timingSafeEqual(createHash("sha256").update(match[1]).digest(), expectedToken);
|
|
109
|
+
};
|
|
110
|
+
let allowedHosts = new Set();
|
|
111
|
+
let allowedOrigins = new Set();
|
|
112
|
+
let closing = false;
|
|
113
|
+
let active = 0;
|
|
114
|
+
const handle = async (req, res) => {
|
|
115
|
+
if (!allowedHosts.has(req.headers.host ?? ""))
|
|
116
|
+
return sendError(res, 403, "Forbidden: unexpected Host header");
|
|
117
|
+
const origin = req.headers.origin;
|
|
118
|
+
if (origin !== undefined && !allowedOrigins.has(origin))
|
|
119
|
+
return sendError(res, 403, "Forbidden: unexpected Origin header");
|
|
120
|
+
const path = (req.url ?? "").split("?")[0];
|
|
121
|
+
if (path !== MCP_PATH)
|
|
122
|
+
return sendError(res, 404, "Not found");
|
|
123
|
+
if (!authorized(req.headers.authorization)) {
|
|
124
|
+
return sendError(res, 401, "Unauthorized: missing or wrong bearer token", { "WWW-Authenticate": "Bearer" });
|
|
125
|
+
}
|
|
126
|
+
if (req.method !== "POST")
|
|
127
|
+
return sendError(res, 405, "Method not allowed", { Allow: "POST" });
|
|
128
|
+
if (closing)
|
|
129
|
+
return sendError(res, 503, "The server is shutting down", { Connection: "close" });
|
|
130
|
+
const contentType = (req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
|
|
131
|
+
if (contentType !== "application/json")
|
|
132
|
+
return sendError(res, 415, "Unsupported Media Type: expected application/json");
|
|
133
|
+
let body;
|
|
134
|
+
try {
|
|
135
|
+
body = JSON.parse(await readBody(req));
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
if (error instanceof BodyTooLarge) {
|
|
139
|
+
// Discard the rest without buffering it. Closing the socket on unread
|
|
140
|
+
// data would reset it before the client reads the 413. Only a client
|
|
141
|
+
// holding the token gets this far, and Node's request timeout bounds
|
|
142
|
+
// a body that never ends.
|
|
143
|
+
req.removeAllListeners("data");
|
|
144
|
+
req.resume();
|
|
145
|
+
return sendError(res, 413, `Request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
146
|
+
}
|
|
147
|
+
return sendError(res, 400, "Parse error: the body is not valid JSON");
|
|
148
|
+
}
|
|
149
|
+
const server = createMcpServer(runtime);
|
|
150
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
151
|
+
res.once("close", () => {
|
|
152
|
+
void transport.close().catch(() => { });
|
|
153
|
+
void server.close().catch(() => { });
|
|
154
|
+
});
|
|
155
|
+
await server.connect(transport);
|
|
156
|
+
await transport.handleRequest(req, res, body);
|
|
157
|
+
};
|
|
158
|
+
const httpServer = createServer((req, res) => {
|
|
159
|
+
active += 1;
|
|
160
|
+
res.once("close", () => {
|
|
161
|
+
active -= 1;
|
|
162
|
+
// A keep-alive connection that just finished its last request would
|
|
163
|
+
// otherwise hold a closing server open until it timed out.
|
|
164
|
+
if (closing)
|
|
165
|
+
setImmediate(() => httpServer.closeIdleConnections());
|
|
166
|
+
});
|
|
167
|
+
handle(req, res).catch((error) => {
|
|
168
|
+
log("ERROR", "Shared HTTP server failed to handle a request", error);
|
|
169
|
+
sendError(res, 500, "Internal server error");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
return new Promise((resolve, reject) => {
|
|
173
|
+
httpServer.once("error", (error) => {
|
|
174
|
+
reject(error.code === "EADDRINUSE"
|
|
175
|
+
? new Error(`Port ${port} on ${HTTP_HOST} is already in use. If the Brightspace HTTP server is already running, ` +
|
|
176
|
+
`connect to it instead of starting another; otherwise choose a different port with --port or D2L_HTTP_PORT.`)
|
|
177
|
+
: error);
|
|
178
|
+
});
|
|
179
|
+
httpServer.listen({ host: HTTP_HOST, port, exclusive: true }, () => {
|
|
180
|
+
const bound = httpServer.address().port;
|
|
181
|
+
allowedHosts = new Set([`${HTTP_HOST}:${bound}`, `localhost:${bound}`]);
|
|
182
|
+
allowedOrigins = new Set([`http://${HTTP_HOST}:${bound}`, `http://localhost:${bound}`]);
|
|
183
|
+
httpServer.on("error", (error) => log("ERROR", "Shared HTTP server error", error));
|
|
184
|
+
resolve({
|
|
185
|
+
url: `http://${HTTP_HOST}:${bound}${MCP_PATH}`,
|
|
186
|
+
port: bound,
|
|
187
|
+
server: httpServer,
|
|
188
|
+
close: () => new Promise((done) => {
|
|
189
|
+
closing = true;
|
|
190
|
+
if (active > 0)
|
|
191
|
+
log("INFO", `Waiting for ${active} request(s) in progress to finish`);
|
|
192
|
+
httpServer.close(() => done());
|
|
193
|
+
httpServer.closeIdleConnections();
|
|
194
|
+
}),
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
}
|
package/build/index.js
CHANGED
|
@@ -6,21 +6,17 @@
|
|
|
6
6
|
*
|
|
7
7
|
* https://github.com/rohanmuppa/brightspace-mcp-server
|
|
8
8
|
*/
|
|
9
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
9
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
10
|
import { enableStdoutGuard, log } from "./utils/logger.js";
|
|
12
11
|
import { guardServerProcess } from "./utils/process-guard.js";
|
|
13
12
|
import { loadConfig } from "./utils/config.js";
|
|
14
13
|
import { setActiveShortcuts } from "./utils/two-factor-shortcuts.js";
|
|
15
|
-
import { TokenManager, AuthRunner } from "./auth/index.js";
|
|
16
|
-
import { D2LApiClient } from "./api/index.js";
|
|
17
14
|
import { startUpdateChecks } from "./utils/update-checker.js";
|
|
18
|
-
import { configureDevActivity
|
|
15
|
+
import { configureDevActivity } from "./utils/dev-activity.js";
|
|
16
|
+
import { createMcpServer, createRuntime } from "./server.js";
|
|
19
17
|
import { readFileSync } from "node:fs";
|
|
20
18
|
import { fileURLToPath } from "node:url";
|
|
21
19
|
import { dirname, resolve } from "node:path";
|
|
22
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
23
|
-
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
24
20
|
const __filename = fileURLToPath(import.meta.url);
|
|
25
21
|
const __dirname = dirname(__filename);
|
|
26
22
|
const PKG_VERSION = (() => {
|
|
@@ -44,12 +40,15 @@ else if (subcommand === 'doctor') {
|
|
|
44
40
|
await import('./doctor.js');
|
|
45
41
|
}
|
|
46
42
|
else {
|
|
47
|
-
// ── MCP Server (default)
|
|
43
|
+
// ── MCP Server (default: stdio; `http`: one shared localhost server) ──
|
|
44
|
+
const http = subcommand === 'http';
|
|
48
45
|
// CRITICAL: Enable stdout guard IMMEDIATELY to prevent corruption of stdio transport
|
|
49
46
|
enableStdoutGuard();
|
|
50
47
|
// A stray exception or a closed pipe must not silently drop the client's
|
|
51
48
|
// transport; see guardServerProcess.
|
|
52
|
-
guardServerProcess();
|
|
49
|
+
guardServerProcess(process, !http);
|
|
50
|
+
/** Set once the HTTP server is listening; shutdown waits on it. */
|
|
51
|
+
let stopHttp;
|
|
53
52
|
async function main() {
|
|
54
53
|
try {
|
|
55
54
|
// Load configuration
|
|
@@ -59,12 +58,6 @@ else {
|
|
|
59
58
|
// who have neither turned on. Booleans only; the key is never passed.
|
|
60
59
|
setActiveShortcuts({ rememberMfa: config.rememberMfa === true, keySaved: Boolean(config.totpUri) });
|
|
61
60
|
log("DEBUG", "Configuration loaded", { sessionDir: config.sessionDir });
|
|
62
|
-
// Create MCP server instance
|
|
63
|
-
const server = new McpServer({
|
|
64
|
-
name: "brightspace",
|
|
65
|
-
version: PKG_VERSION,
|
|
66
|
-
description: "Brightspace MCP Server — by Rohan Muppa (github.com/rohanmuppa/brightspace-mcp-server)",
|
|
67
|
-
}, { capabilities: { logging: {} } });
|
|
68
61
|
log("INFO", "");
|
|
69
62
|
log("INFO", "========================================");
|
|
70
63
|
log("INFO", ` Brightspace MCP Server v${PKG_VERSION}`);
|
|
@@ -72,47 +65,13 @@ else {
|
|
|
72
65
|
log("INFO", " github.com/rohanmuppa/brightspace-mcp-server");
|
|
73
66
|
log("INFO", "========================================");
|
|
74
67
|
log("INFO", "");
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
envSessionCookie: config.envSessionCookie,
|
|
68
|
+
// The one stdio client hears the sign-in's progress lines as logging
|
|
69
|
+
// messages. The HTTP server has many clients and tells none of them;
|
|
70
|
+
// see createRuntime.
|
|
71
|
+
let stdioServer;
|
|
72
|
+
const runtime = createRuntime(config, PKG_VERSION, http ? undefined : (message) => {
|
|
73
|
+
void stdioServer?.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
|
|
82
74
|
});
|
|
83
|
-
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE are opt-in browser-free sign-in
|
|
84
|
-
// escape hatches (Docker, headless Linux, WSL, hardware-key MFA
|
|
85
|
-
// tenants). When either is set, AuthRunner is never created: there is
|
|
86
|
-
// no stored credential to drive a browser login from, and a rejected
|
|
87
|
-
// pasted credential should fail with a clear "paste a fresh one"
|
|
88
|
-
// message rather than spawning auth-cli.
|
|
89
|
-
const envAuthActive = Boolean(config.envAccessToken || config.envSessionCookie);
|
|
90
|
-
const authRunner = envAuthActive
|
|
91
|
-
? undefined
|
|
92
|
-
: new AuthRunner({
|
|
93
|
-
sessionDir: config.sessionDir,
|
|
94
|
-
onProgress: (message) => {
|
|
95
|
-
void server.sendLoggingMessage({ level: "info", logger: "brightspace-auth", data: message }).catch(() => { });
|
|
96
|
-
},
|
|
97
|
-
});
|
|
98
|
-
// Create D2L API Client with auto-reauth support
|
|
99
|
-
const apiClient = new D2LApiClient({
|
|
100
|
-
baseUrl: config.baseUrl,
|
|
101
|
-
tokenManager,
|
|
102
|
-
onAuthExpired: authRunner ? () => authRunner.run(currentMfaAnnouncer()) : undefined,
|
|
103
|
-
authExpiredMessage: config.envAccessToken
|
|
104
|
-
? "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set."
|
|
105
|
-
: config.envSessionCookie
|
|
106
|
-
? "The pasted D2L_SESSION_COOKIE has expired. Copy a fresh d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser and update the environment variable; this server does not fall back to a browser login while D2L_SESSION_COOKIE is set."
|
|
107
|
-
: undefined,
|
|
108
|
-
});
|
|
109
|
-
// Nothing here reaches Brightspace. API versions are discovered by the
|
|
110
|
-
// first request that needs them, and the first request with no saved
|
|
111
|
-
// session authenticates on its own (see D2LApiClient.withAuthentication).
|
|
112
|
-
// A server that signed in at startup would open an MFA prompt on the
|
|
113
|
-
// user's phone every time their editor restarted, whether or not they
|
|
114
|
-
// ever asked about a course, and a tenant that was briefly unreachable
|
|
115
|
-
// would take the whole server down with it.
|
|
116
75
|
// Check for updates now, then every few hours. A stdio server can stay
|
|
117
76
|
// alive for days, so a boot-only check would never see a release. The
|
|
118
77
|
// interval is unref'd and cannot hold the process open.
|
|
@@ -125,54 +84,18 @@ else {
|
|
|
125
84
|
activeOnly: config.courseFilter.activeOnly,
|
|
126
85
|
});
|
|
127
86
|
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
registerGetMyCourses(server, apiClient, config);
|
|
137
|
-
registerGetUpcomingDueDates(server, apiClient, config);
|
|
138
|
-
registerGetCalendarEvents(server, apiClient, config);
|
|
139
|
-
registerGetMyGrades(server, apiClient, config);
|
|
140
|
-
registerGetAnnouncements(server, apiClient, config);
|
|
141
|
-
registerGetAssignments(server, apiClient, config);
|
|
142
|
-
registerGetAssignmentRubric(server, apiClient);
|
|
143
|
-
registerGetAssignmentFiles(server, apiClient, config.baseUrl);
|
|
144
|
-
registerGetAnnouncementFiles(server, apiClient);
|
|
145
|
-
registerGetCourseContent(server, apiClient);
|
|
146
|
-
registerDownloadFile(server, apiClient);
|
|
147
|
-
registerGetClasslistEmails(server, apiClient);
|
|
148
|
-
registerGetRoster(server, apiClient);
|
|
149
|
-
registerGetMyGroups(server, apiClient);
|
|
150
|
-
registerGetSyllabus(server, apiClient);
|
|
151
|
-
registerGetDiscussions(server, apiClient);
|
|
152
|
-
registerGetVideoTranscript(server, apiClient);
|
|
153
|
-
registerGetServerInfo(server, config, PKG_VERSION, apiClient);
|
|
154
|
-
registerSearchCourse(server, apiClient);
|
|
155
|
-
registerSignOut(server, config, apiClient, tokenManager, authRunner);
|
|
156
|
-
// Instructor / TA dropbox tools (read-only) — 403 for a student account.
|
|
157
|
-
registerGetDropboxFolders(server, apiClient);
|
|
158
|
-
registerGetDropboxSubmissions(server, apiClient);
|
|
159
|
-
registerGetDropboxUserSubmissions(server, apiClient);
|
|
160
|
-
registerGetDropboxFeedback(server, apiClient);
|
|
161
|
-
registerGetRubricsForObject(server, apiClient);
|
|
162
|
-
registerDownloadDropboxSubmissionFile(server, apiClient);
|
|
163
|
-
log("DEBUG", "MCP tools registered (26 tools)");
|
|
164
|
-
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
165
|
-
// picker, distinct from tools. Each one is a canned user message that
|
|
166
|
-
// names our own tools by name, so a client with no idea what to ask
|
|
167
|
-
// for still gets a one-click starting point.
|
|
168
|
-
registerWeeklyBriefingPrompt(server);
|
|
169
|
-
registerGradeAuditPrompt(server);
|
|
170
|
-
registerStudyPlannerPrompt(server);
|
|
171
|
-
registerCourseSummaryPrompt(server);
|
|
172
|
-
log("DEBUG", "MCP prompts registered (4 prompts)");
|
|
87
|
+
if (http) {
|
|
88
|
+
const { resolveHttpPort, resolveHttpToken, startHttpServer } = await import("./http-server.js");
|
|
89
|
+
const running = await startHttpServer({ runtime, token: resolveHttpToken(), port: resolveHttpPort() });
|
|
90
|
+
stopHttp = running.close;
|
|
91
|
+
log("INFO", `Brightspace MCP Server by Rohan Muppa — shared HTTP server listening on ${running.url} (26 tools, 4 prompts registered)`);
|
|
92
|
+
log("INFO", "Leave this running; every MCP client on this computer connects to that URL. Press Ctrl+C to stop.");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
173
95
|
// Connect stdio transport
|
|
96
|
+
stdioServer = createMcpServer(runtime);
|
|
174
97
|
const transport = new StdioServerTransport();
|
|
175
|
-
await
|
|
98
|
+
await stdioServer.connect(transport);
|
|
176
99
|
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (26 tools, 4 prompts registered)");
|
|
177
100
|
log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
|
|
178
101
|
}
|
|
@@ -181,14 +104,19 @@ else {
|
|
|
181
104
|
process.exit(1);
|
|
182
105
|
}
|
|
183
106
|
}
|
|
184
|
-
// Graceful shutdown
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
107
|
+
// Graceful shutdown. The HTTP server first answers the calls already in
|
|
108
|
+
// progress (other chats may be mid-call); a second signal stops at once.
|
|
109
|
+
let shuttingDown = false;
|
|
110
|
+
const shutdown = () => {
|
|
111
|
+
if (shuttingDown || !stopHttp) {
|
|
112
|
+
log('INFO', 'Shutting down MCP server');
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
shuttingDown = true;
|
|
116
|
+
log('INFO', 'Shutting down MCP server after the calls in progress finish (press Ctrl+C again to stop now)');
|
|
117
|
+
void stopHttp().then(() => process.exit(0));
|
|
118
|
+
};
|
|
119
|
+
process.on('SIGINT', shutdown);
|
|
120
|
+
process.on('SIGTERM', shutdown);
|
|
193
121
|
main();
|
|
194
122
|
}
|
package/build/server.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
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 { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { TokenManager, AuthRunner } from "./auth/index.js";
|
|
8
|
+
import { D2LApiClient } from "./api/index.js";
|
|
9
|
+
import { trackToolActivity } from "./utils/dev-activity.js";
|
|
10
|
+
import { log } from "./utils/logger.js";
|
|
11
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
|
|
12
|
+
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
13
|
+
/**
|
|
14
|
+
* Build the shared runtime. Nothing here reaches Brightspace: API versions
|
|
15
|
+
* are discovered by the first request that needs them, and the first request
|
|
16
|
+
* with no saved session authenticates on its own (see
|
|
17
|
+
* D2LApiClient.withAuthentication). A server that signed in at startup would
|
|
18
|
+
* open an MFA prompt on the user's phone every time their editor restarted,
|
|
19
|
+
* whether or not they ever asked about a course, and a tenant that was
|
|
20
|
+
* briefly unreachable would take the whole server down with it.
|
|
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).
|
|
26
|
+
*/
|
|
27
|
+
export function createRuntime(config, version, onAuthProgress) {
|
|
28
|
+
const tokenManager = new TokenManager({
|
|
29
|
+
sessionDir: config.sessionDir,
|
|
30
|
+
baseUrl: config.baseUrl,
|
|
31
|
+
tokenTtl: config.tokenTtl,
|
|
32
|
+
envAccessToken: config.envAccessToken,
|
|
33
|
+
envSessionCookie: config.envSessionCookie,
|
|
34
|
+
});
|
|
35
|
+
// D2L_ACCESS_TOKEN / D2L_SESSION_COOKIE are opt-in browser-free sign-in
|
|
36
|
+
// escape hatches (Docker, headless Linux, WSL, hardware-key MFA
|
|
37
|
+
// tenants). When either is set, AuthRunner is never created: there is
|
|
38
|
+
// no stored credential to drive a browser login from, and a rejected
|
|
39
|
+
// pasted credential should fail with a clear "paste a fresh one"
|
|
40
|
+
// message rather than spawning auth-cli.
|
|
41
|
+
const envAuthActive = Boolean(config.envAccessToken || config.envSessionCookie);
|
|
42
|
+
const authRunner = envAuthActive
|
|
43
|
+
? undefined
|
|
44
|
+
: new AuthRunner({ sessionDir: config.sessionDir, onProgress: onAuthProgress });
|
|
45
|
+
// currentMfaAnnouncer() is read inside the tool call that hit the expired
|
|
46
|
+
// session, so the challenge is announced to that call's client only.
|
|
47
|
+
const apiClient = new D2LApiClient({
|
|
48
|
+
baseUrl: config.baseUrl,
|
|
49
|
+
tokenManager,
|
|
50
|
+
onAuthExpired: authRunner ? () => authRunner.run(currentMfaAnnouncer()) : undefined,
|
|
51
|
+
authExpiredMessage: config.envAccessToken
|
|
52
|
+
? "D2L_ACCESS_TOKEN was rejected by Brightspace (expired or invalid). Issue a fresh token and update the environment variable; this server does not fall back to a browser login while D2L_ACCESS_TOKEN is set."
|
|
53
|
+
: config.envSessionCookie
|
|
54
|
+
? "The pasted D2L_SESSION_COOKIE has expired. Copy a fresh d2lSessionVal/d2lSecureSessionVal pair from a logged-in browser and update the environment variable; this server does not fall back to a browser login while D2L_SESSION_COOKIE is set."
|
|
55
|
+
: undefined,
|
|
56
|
+
});
|
|
57
|
+
return { config, version, tokenManager, apiClient, authRunner };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* An MCP server with every tool and prompt registered against the shared
|
|
61
|
+
* runtime. stdio builds one; the HTTP server builds one per request, which is
|
|
62
|
+
* cheap (registration only) and is what the SDK's stateless transport
|
|
63
|
+
* requires. All state worth keeping lives in the runtime, not here.
|
|
64
|
+
*/
|
|
65
|
+
export function createMcpServer(runtime) {
|
|
66
|
+
const { config, version, tokenManager, apiClient, authRunner } = runtime;
|
|
67
|
+
const server = new McpServer({
|
|
68
|
+
name: "brightspace",
|
|
69
|
+
version,
|
|
70
|
+
description: "Brightspace MCP Server — by Rohan Muppa (github.com/rohanmuppa/brightspace-mcp-server)",
|
|
71
|
+
}, { capabilities: { logging: {} } });
|
|
72
|
+
// A client may leave `arguments` off a call to a tool that takes none;
|
|
73
|
+
// SDK 1.26-1.31 reject that. Must run before the first registration.
|
|
74
|
+
acceptOmittedArguments(server);
|
|
75
|
+
// Register MCP tools. Each one, when its client sends a progress token,
|
|
76
|
+
// shows an MFA challenge mid-call and waits for the approval instead of
|
|
77
|
+
// asking to be called again (see relayMfaChallenges).
|
|
78
|
+
relayMfaChallenges(server);
|
|
79
|
+
trackToolActivity(server);
|
|
80
|
+
registerGetMyCourses(server, apiClient, config);
|
|
81
|
+
registerGetUpcomingDueDates(server, apiClient, config);
|
|
82
|
+
registerGetCalendarEvents(server, apiClient, config);
|
|
83
|
+
registerGetMyGrades(server, apiClient, config);
|
|
84
|
+
registerGetAnnouncements(server, apiClient, config);
|
|
85
|
+
registerGetAssignments(server, apiClient, config);
|
|
86
|
+
registerGetAssignmentRubric(server, apiClient);
|
|
87
|
+
registerGetAssignmentFiles(server, apiClient, config.baseUrl);
|
|
88
|
+
registerGetAnnouncementFiles(server, apiClient);
|
|
89
|
+
registerGetCourseContent(server, apiClient);
|
|
90
|
+
registerDownloadFile(server, apiClient);
|
|
91
|
+
registerGetClasslistEmails(server, apiClient);
|
|
92
|
+
registerGetRoster(server, apiClient);
|
|
93
|
+
registerGetMyGroups(server, apiClient);
|
|
94
|
+
registerGetSyllabus(server, apiClient);
|
|
95
|
+
registerGetDiscussions(server, apiClient);
|
|
96
|
+
registerGetVideoTranscript(server, apiClient);
|
|
97
|
+
registerGetServerInfo(server, config, version, apiClient);
|
|
98
|
+
registerSearchCourse(server, apiClient);
|
|
99
|
+
registerSignOut(server, config, apiClient, tokenManager, authRunner);
|
|
100
|
+
// Instructor / TA dropbox tools (read-only) — 403 for a student account.
|
|
101
|
+
registerGetDropboxFolders(server, apiClient);
|
|
102
|
+
registerGetDropboxSubmissions(server, apiClient);
|
|
103
|
+
registerGetDropboxUserSubmissions(server, apiClient);
|
|
104
|
+
registerGetDropboxFeedback(server, apiClient);
|
|
105
|
+
registerGetRubricsForObject(server, apiClient);
|
|
106
|
+
registerDownloadDropboxSubmissionFile(server, apiClient);
|
|
107
|
+
log("DEBUG", "MCP tools registered (26 tools)");
|
|
108
|
+
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
109
|
+
// picker, distinct from tools. Each one is a canned user message that
|
|
110
|
+
// names our own tools by name, so a client with no idea what to ask
|
|
111
|
+
// for still gets a one-click starting point.
|
|
112
|
+
registerWeeklyBriefingPrompt(server);
|
|
113
|
+
registerGradeAuditPrompt(server);
|
|
114
|
+
registerStudyPlannerPrompt(server);
|
|
115
|
+
registerCourseSummaryPrompt(server);
|
|
116
|
+
log("DEBUG", "MCP prompts registered (4 prompts)");
|
|
117
|
+
return server;
|
|
118
|
+
}
|
package/build/setup/prepare.js
CHANGED
|
@@ -98,8 +98,8 @@ export async function runPrepare(args, overrides = {}) {
|
|
|
98
98
|
const steps = [];
|
|
99
99
|
// ── Node ──
|
|
100
100
|
steps.push(nodeVersionSupported(deps.nodeVersion)
|
|
101
|
-
? ok("node", `Node.js ${deps.nodeVersion} (
|
|
102
|
-
: fail("node", `Node.js ${deps.nodeVersion} is too old — install Node
|
|
101
|
+
? ok("node", `Node.js ${deps.nodeVersion} (22 or newer)`)
|
|
102
|
+
: fail("node", `Node.js ${deps.nodeVersion} is too old — install Node 22 or newer from https://nodejs.org/`));
|
|
103
103
|
// ── School ──
|
|
104
104
|
const existing = deps.readExistingConfig();
|
|
105
105
|
let baseUrl;
|
|
@@ -129,13 +129,14 @@ const AUTH_FAILURE_GUIDANCE = {
|
|
|
129
129
|
* auth-runner.ts MFA_NUMBER_MARKER) — a bounded, pre-validated value, not
|
|
130
130
|
* arbitrary child output, so it is safe to interpolate here.
|
|
131
131
|
*/
|
|
132
|
-
const MFA_ALREADY_REPORTED = "Brightspace sign-in is already in progress; another call in
|
|
133
|
-
"approve. Retry this call once the sign-in completes.";
|
|
134
|
-
const AUTOMATIC_ALREADY_REPORTED = "Brightspace sign-in is already in progress, answering its own verification code; another call " +
|
|
135
|
-
"in
|
|
136
|
-
"to approve anything.";
|
|
132
|
+
const MFA_ALREADY_REPORTED = "Brightspace sign-in is already in progress; another call, possibly in another open chat, has the " +
|
|
133
|
+
"number to approve. Retry this call once the sign-in completes.";
|
|
134
|
+
const AUTOMATIC_ALREADY_REPORTED = "Brightspace sign-in is already in progress, answering its own verification code; another call, " +
|
|
135
|
+
"possibly in another open chat, has the details. Retry this call once the sign-in completes. " +
|
|
136
|
+
"Don't ask the user to approve anything.";
|
|
137
137
|
function authFailureMessage(error) {
|
|
138
|
-
// Another call in this parallel batch
|
|
138
|
+
// Another call in this parallel batch (which, behind the shared HTTP
|
|
139
|
+
// server, may be another chat's) already carries the number and the
|
|
139
140
|
// instructions (issue #212). Repeating them in every response made a batch
|
|
140
141
|
// unreadable; one short line is enough for this call.
|
|
141
142
|
if (error.kind === "mfaPending" && error.duplicate)
|
|
@@ -20,14 +20,24 @@ const RECONNECT_HINT = "Restart or reconnect the Brightspace MCP server in your
|
|
|
20
20
|
* the client has already closed ends the process cleanly, since there is no
|
|
21
21
|
* one left to answer; and every other way the process stops says why on
|
|
22
22
|
* stderr, which MCP clients keep as the server's log.
|
|
23
|
+
*
|
|
24
|
+
* The shared HTTP server passes stdio = false: its clients are not on stdin
|
|
25
|
+
* and stdout, so a closed terminal pipe says nothing about them.
|
|
23
26
|
*/
|
|
24
|
-
export function guardServerProcess(proc = process) {
|
|
27
|
+
export function guardServerProcess(proc = process, stdio = true) {
|
|
25
28
|
proc.on("uncaughtException", (error) => {
|
|
26
29
|
log("ERROR", "Uncaught exception; the server is still running", error);
|
|
27
30
|
});
|
|
28
31
|
proc.on("unhandledRejection", (reason) => {
|
|
29
32
|
log("ERROR", "Unhandled promise rejection", reason);
|
|
30
33
|
});
|
|
34
|
+
proc.on("exit", (code) => {
|
|
35
|
+
if (code === 0)
|
|
36
|
+
return;
|
|
37
|
+
log("ERROR", `Brightspace MCP server exited with code ${code}. ${RECONNECT_HINT}`);
|
|
38
|
+
});
|
|
39
|
+
if (!stdio)
|
|
40
|
+
return;
|
|
31
41
|
proc.stdout.on("error", (error) => {
|
|
32
42
|
if (error.code === "EPIPE") {
|
|
33
43
|
log("INFO", "The MCP client closed the connection (stdout pipe closed); shutting down");
|
|
@@ -39,9 +49,4 @@ export function guardServerProcess(proc = process) {
|
|
|
39
49
|
proc.stdin.on("end", () => {
|
|
40
50
|
log("INFO", "The MCP client closed stdin; the server will stop");
|
|
41
51
|
});
|
|
42
|
-
proc.on("exit", (code) => {
|
|
43
|
-
if (code === 0)
|
|
44
|
-
return;
|
|
45
|
-
log("ERROR", `Brightspace MCP server exited with code ${code}. ${RECONNECT_HINT}`);
|
|
46
|
-
});
|
|
47
52
|
}
|
|
@@ -52,8 +52,8 @@ export function sessionDirFor(env, store, baseUrl, username) {
|
|
|
52
52
|
: path.join(os.homedir(), ".d2l-session");
|
|
53
53
|
return accountSessionDirectory(sessionRoot, baseUrl, username);
|
|
54
54
|
}
|
|
55
|
-
/** True for the Node versions this package supports (
|
|
55
|
+
/** True for the Node versions this package supports (22 and newer). */
|
|
56
56
|
export function nodeVersionSupported(version) {
|
|
57
57
|
const major = Number(version.replace(/^v/, "").split(".")[0]);
|
|
58
|
-
return Number.isFinite(major) && major >=
|
|
58
|
+
return Number.isFinite(major) && major >= 22;
|
|
59
59
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
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",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"@modelcontextprotocol/sdk": "^1.26.0",
|
|
31
31
|
"@napi-rs/keyring": "^2.0.0",
|
|
32
32
|
"dotenv": "^17.2.4",
|
|
33
|
-
"file-type": "^
|
|
33
|
+
"file-type": "^22.1.1",
|
|
34
34
|
"playwright": "^1.58.2",
|
|
35
35
|
"sanitize-filename": "^1.6.3",
|
|
36
36
|
"trash": "^9.0.0",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"vitest": "^5.0.3"
|
|
46
46
|
},
|
|
47
47
|
"engines": {
|
|
48
|
-
"node": ">=
|
|
48
|
+
"node": ">=22.0.0"
|
|
49
49
|
},
|
|
50
50
|
"license": "MIT",
|
|
51
51
|
"author": "Rohan Muppa",
|