brightspace-mcp-server 5.0.0 → 5.0.2

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.
@@ -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.
@@ -99,6 +99,10 @@ export function readLtiLaunchPage(html, origin) {
99
99
  const consumerKey = inputs.find(input => input.get("name") === "oauth_consumer_key")?.get("value");
100
100
  const candidates = [
101
101
  ...tagAttributes(html, "form").map(form => form.get("action")),
102
+ // Brightspace's own quickLink page renders the tool as a web component
103
+ // whose lti-launch-url is the tool launch (/d2l/le/lti/…/toolLaunch/…),
104
+ // not a form or an iframe.
105
+ ...tagAttributes(html, "d2l-lti-launch").map(element => element.get("lti-launch-url")),
102
106
  ...tagAttributes(html, "iframe").map(frame => frame.get("src")),
103
107
  ...inputs.map(input => input.get("value")),
104
108
  ].filter((candidate) => Boolean(candidate));
@@ -62,6 +62,16 @@ export function detectVideoPlatform(url) {
62
62
  return "kaltura";
63
63
  return "unknown";
64
64
  }
65
+ /**
66
+ * Kaltura's hosted KAF front ends are named after the school's partner ID:
67
+ * `983291-6.kaf.kaltura.com` is partner 983291 (instance 6). An LTI 1.3
68
+ * launch's target_link_uri points there with only the entry ID in the path,
69
+ * so the host is the one place the partner ID appears.
70
+ */
71
+ function kafHostPartnerId(hostname) {
72
+ const match = /^(\d+)(?:-\d+)?\.kaf\.kaltura\.com$/i.exec(hostname);
73
+ return match ? match[1] : null;
74
+ }
65
75
  /** Kaltura embeds carry the entry and partner ID as query params or path segments; naming varies by embed generator. */
66
76
  export function extractKalturaIds(url) {
67
77
  let parsed;
@@ -85,7 +95,7 @@ export function extractKalturaIds(url) {
85
95
  params.get("pid") ??
86
96
  pathSegmentParam(pathname, "wid") ??
87
97
  pathSegmentParam(pathname, "partner_id");
88
- const partnerId = widParam ? widParam.replace(/^_/, "") : null;
98
+ const partnerId = widParam ? widParam.replace(/^_/, "") : kafHostPartnerId(parsed.hostname);
89
99
  if (!entryId || !partnerId)
90
100
  return null;
91
101
  return { partnerId, entryId };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.0.0",
3
+ "version": "5.0.2",
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",