brightspace-mcp-server 4.1.0 → 5.0.1

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
@@ -4,7 +4,7 @@
4
4
  [![npm downloads](https://img.shields.io/npm/dm/brightspace-mcp-server.svg)](https://www.npmjs.com/package/brightspace-mcp-server)
5
5
  [![CI](https://img.shields.io/github/actions/workflow/status/RohanMuppa/brightspace-mcp-server/ci.yml?branch=main)](https://github.com/RohanMuppa/brightspace-mcp-server/actions/workflows/ci.yml)
6
6
  [![License: MIT](https://img.shields.io/github/license/RohanMuppa/brightspace-mcp-server.svg)](LICENSE)
7
- [![Node >= 20](https://img.shields.io/node/v/brightspace-mcp-server.svg)](package.json)
7
+ [![Node >= 22](https://img.shields.io/node/v/brightspace-mcp-server.svg)](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 20 or newer) if you don't have it. Download, run the installer, done.
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
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} (20 or newer)`);
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 20 or newer from https://nodejs.org/, then try again.`);
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
@@ -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} (20 or newer)`)
102
- : fail("node", `Node.js ${deps.nodeVersion} is too old — install Node 20 or newer from https://nodejs.org/`));
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;
@@ -115,6 +115,7 @@ export function registerDownloadDropboxSubmissionFile(server, apiClient) {
115
115
  const result = await secureStreamDownload({
116
116
  targetDir: downloadPath,
117
117
  filename: effectiveFilename,
118
+ originalFilename,
118
119
  body: response.body,
119
120
  maxBytes: DISK_MAX_FILE_SIZE,
120
121
  });
@@ -12,7 +12,7 @@ import { checkTopicAvailability } from "./topic-availability.js";
12
12
  // go through; importing it here only made it look as though this file
13
13
  // validated anything itself. Inline mode never writes to disk, so it calls
14
14
  // validateFileType directly to enforce the same magic-byte allowlist.
15
- import { validateContentId, validateFileType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
15
+ import { validateContentId, validateFileType, nameForType, MAX_FILE_SIZE, DISK_MAX_FILE_SIZE, } from "../utils/file-validator.js";
16
16
  import { secureStreamDownload, readBodyCapped } from "../utils/download-helpers.js";
17
17
  import { DownloadError } from "../utils/download-errors.js";
18
18
  import { AUTH_COMMAND } from "../utils/commands.js";
@@ -114,7 +114,7 @@ async function respondInline(buffer, originalFilename, customFilename) {
114
114
  // validateFileTypeOfFile internally); inline mode never writes to disk, so it has
115
115
  // to call this itself. Throws DownloadError on an unsupported or
116
116
  // undetectable type, handled by the caller's sanitizeError.
117
- const { mime } = await validateFileType(buffer, undefined, effectiveFilename);
117
+ const { mime } = await validateFileType(buffer, undefined, effectiveFilename, originalFilename);
118
118
  const metadata = {
119
119
  mode: "inline",
120
120
  filename: effectiveFilename,
@@ -229,6 +229,7 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
229
229
  const result = await secureStreamDownload({
230
230
  targetDir: downloadPath,
231
231
  filename: effectiveFilename,
232
+ originalFilename,
232
233
  body: response.body,
233
234
  maxBytes: DISK_MAX_FILE_SIZE,
234
235
  });
@@ -241,8 +242,25 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
241
242
  mimeType: result.mime,
242
243
  originalFilename,
243
244
  message: `File downloaded successfully to ${result.path}`,
245
+ ...extensionNote(effectiveFilename, result),
244
246
  });
245
247
  }
248
+ /**
249
+ * When the file was saved under a different extension than requested — a
250
+ * legacy .xls asked for as "Grades.xlsx" (#280) — say why, so the caller
251
+ * picks a reader for the format it really is. The saved extension comes from
252
+ * this package's own legacy Office table, never from the remote name.
253
+ */
254
+ function extensionNote(requestedFilename, saved) {
255
+ const savedExt = path.extname(saved.path).toLowerCase();
256
+ const type = { mime: saved.mime, ext: savedExt.slice(1) };
257
+ if (nameForType(requestedFilename, type) === requestedFilename)
258
+ return {};
259
+ return {
260
+ note: `Saved with the ${savedExt} extension: this is a legacy Office 97-2003 file (${saved.mime}), ` +
261
+ `not the format the requested name implied. Open it with an application that reads ${savedExt} files.`,
262
+ };
263
+ }
246
264
  /**
247
265
  * Register download_file tool
248
266
  */
@@ -9,7 +9,7 @@ import path from "node:path";
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { Readable, Transform } from "node:stream";
11
11
  import { pipeline } from "node:stream/promises";
12
- import { validateDownloadPath, validateFileType, validateFileTypeOfFile, MAX_FILE_SIZE, } from "./file-validator.js";
12
+ import { validateDownloadPath, validateFileType, validateFileTypeOfFile, nameForType, MAX_FILE_SIZE, } from "./file-validator.js";
13
13
  import { DownloadError } from "./download-errors.js";
14
14
  import { log } from "./logger.js";
15
15
  /**
@@ -112,7 +112,7 @@ export async function secureDownload(options) {
112
112
  * @throws DownloadError("tooLarge") once the body passes maxBytes
113
113
  */
114
114
  export async function secureStreamDownload(options) {
115
- const { targetDir, filename, body, maxBytes } = options;
115
+ const { targetDir, filename, originalFilename, body, maxBytes } = options;
116
116
  const safeFilename = path.basename(validateDownloadPath(targetDir, filename));
117
117
  const partialPath = path.join(targetDir, `.download-${randomUUID()}.part`);
118
118
  let size = 0;
@@ -129,8 +129,9 @@ export async function secureStreamDownload(options) {
129
129
  });
130
130
  try {
131
131
  await pipeline(Readable.fromWeb(body), limit, createWriteStream(partialPath, { flags: "wx" }));
132
- const { mime } = await validateFileTypeOfFile(partialPath, undefined, safeFilename);
133
- const finalPath = await publishExclusively(partialPath, targetDir, safeFilename);
132
+ const type = await validateFileTypeOfFile(partialPath, undefined, safeFilename, originalFilename);
133
+ const { mime } = type;
134
+ const finalPath = await publishExclusively(partialPath, targetDir, nameForType(safeFilename, type));
134
135
  log("INFO", `Downloaded file to ${finalPath} (${size} bytes, ${mime})`);
135
136
  return { path: finalPath, size, mime };
136
137
  }
@@ -20,6 +20,11 @@ import { DownloadError } from "./download-errors.js";
20
20
  * magic-byte allowlist exists to prevent. Reconcile against the declared
21
21
  * extension instead, which admits the formats the allowlist already intended
22
22
  * and still refuses everything else.
23
+ *
24
+ * The declared extension is the caller's customFilename when there is one, and
25
+ * that name is only a label: asking for "Grades.xlsx" does not make a legacy
26
+ * .xls OOXML (#280). So Brightspace's own name for the file is consulted too,
27
+ * and either one naming a legacy Office format admits it.
23
28
  */
24
29
  // file-type v21 is ESM-only and costs ~50ms; defer the import to first use
25
30
  // since validateFileType is already async.
@@ -148,7 +153,7 @@ export function validateDownloadPath(baseDir, filename) {
148
153
  * @returns Detected MIME type and extension
149
154
  * @throws Error if file type not allowed
150
155
  */
151
- export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES, filename) {
156
+ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES, filename, originalFilename) {
152
157
  // An empty body is not a text file. It reaches here when a fetch was
153
158
  // truncated or the server answered with nothing, and the UTF-8 fallback
154
159
  // below would otherwise wave it through as text/plain and write a zero-byte
@@ -160,7 +165,7 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
160
165
  const { fileTypeFromBuffer } = await getFileType();
161
166
  const detected = await fileTypeFromBuffer(buffer);
162
167
  if (detected)
163
- return allowDetectedType(detected, allowedTypes, filename);
168
+ return allowDetectedType(detected, allowedTypes, [filename, originalFilename]);
164
169
  return allowTextType(buffer, allowedTypes);
165
170
  }
166
171
  /**
@@ -170,7 +175,7 @@ export async function validateFileType(buffer, allowedTypes = ALLOWED_MIME_TYPES
170
175
  * same UTF-8 and NUL checks a chunk at a time, so plain text, CSV and JSON
171
176
  * are held to the disk download limit rather than to MAX_FILE_SIZE.
172
177
  */
173
- export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MIME_TYPES, filename) {
178
+ export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MIME_TYPES, filename, originalFilename) {
174
179
  const { size } = await fs.stat(filePath);
175
180
  if (size === 0) {
176
181
  throw new DownloadError("undetectableType", "File is empty (0 bytes)");
@@ -178,26 +183,27 @@ export async function validateFileTypeOfFile(filePath, allowedTypes = ALLOWED_MI
178
183
  const { fileTypeFromFile } = await getFileType();
179
184
  const detected = await fileTypeFromFile(filePath);
180
185
  if (detected)
181
- return allowDetectedType(detected, allowedTypes, filename);
186
+ return allowDetectedType(detected, allowedTypes, [filename, originalFilename]);
182
187
  return allowTextTypeOfFile(filePath, allowedTypes);
183
188
  }
184
- /** The allowlist decision for a type file-type recognised from magic bytes. */
185
- function allowDetectedType(detected, allowedTypes, filename) {
189
+ /**
190
+ * The allowlist decision for a type file-type recognised from magic bytes.
191
+ * `names` are the file's candidate names, most authoritative first; only their
192
+ * extensions are read, and only to disambiguate a CFB or XML container.
193
+ */
194
+ function allowDetectedType(detected, allowedTypes, names) {
186
195
  if (detected.mime === XML_MIME) {
187
- const ext = filename ? path.extname(filename).toLowerCase() : "";
188
- const resolved = XML_EXTENSION_MIMES[ext];
189
- if (resolved && allowedTypes.includes(resolved)) {
190
- return { mime: resolved, ext: ext.slice(1) };
191
- }
196
+ const resolved = reconcileByExtension(XML_EXTENSION_MIMES, names, allowedTypes);
197
+ if (resolved)
198
+ return resolved;
192
199
  // Anything else falls through to the allowlist check, which refuses
193
200
  // application/xml the way it always has.
194
201
  }
195
202
  if (detected.mime === CFB_MIME) {
196
- const ext = filename ? path.extname(filename).toLowerCase() : "";
197
- const resolved = CFB_EXTENSION_MIMES[ext];
198
- if (resolved && allowedTypes.includes(resolved)) {
199
- return { mime: resolved, ext: ext.slice(1) };
200
- }
203
+ const resolved = reconcileByExtension(CFB_EXTENSION_MIMES, names, allowedTypes);
204
+ if (resolved)
205
+ return resolved;
206
+ const ext = names[0] ? path.extname(names[0]).toLowerCase() : "";
201
207
  throw new DownloadError("unsupportedType", `Compound File Binary with extension '${ext || "none"}' is not an allowed Office format`, CFB_MIME);
202
208
  }
203
209
  if (!allowedTypes.includes(detected.mime)) {
@@ -205,6 +211,30 @@ function allowDetectedType(detected, allowedTypes, filename) {
205
211
  }
206
212
  return { mime: detected.mime, ext: detected.ext };
207
213
  }
214
+ /** The first of `names` whose extension maps, through `table`, to an allowed type. */
215
+ function reconcileByExtension(table, names, allowedTypes) {
216
+ for (const name of names) {
217
+ const ext = name ? path.extname(name).toLowerCase() : "";
218
+ const resolved = table[ext];
219
+ if (resolved && allowedTypes.includes(resolved)) {
220
+ return { mime: resolved, ext: ext.slice(1) };
221
+ }
222
+ }
223
+ return undefined;
224
+ }
225
+ /**
226
+ * The name to save a validated file under. Normally `filename` unchanged; but
227
+ * a legacy Office file whose requested name does not carry a matching
228
+ * extension (a .xls asked for as "Grades.xlsx", #280) gets its true extension,
229
+ * so nothing opens it expecting a format it is not.
230
+ */
231
+ export function nameForType(filename, type) {
232
+ const isLegacyOffice = Object.values(CFB_EXTENSION_MIMES).includes(type.mime);
233
+ const ext = path.extname(filename);
234
+ if (!isLegacyOffice || CFB_EXTENSION_MIMES[ext.toLowerCase()] === type.mime)
235
+ return filename;
236
+ return `${filename.slice(0, filename.length - ext.length)}.${type.ext}`;
237
+ }
208
238
  /**
209
239
  * Fallback for text-based files with no magic-byte signature: accept if the
210
240
  * buffer decodes as UTF-8 (rejecting binaries and NUL bytes), then sniff HTML.
@@ -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 (20 and newer). */
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 >= 20;
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": "4.1.0",
3
+ "version": "5.0.1",
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": "^21.3.0",
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": ">=20.0.0"
48
+ "node": ">=22.0.0"
49
49
  },
50
50
  "license": "MIT",
51
51
  "author": "Rohan Muppa",