brightspace-mcp-server 5.0.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.
|
@@ -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
|
|
133
|
-
const
|
|
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
|
-
/**
|
|
185
|
-
|
|
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
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "5.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",
|