brightspace-mcp-server 3.11.4 → 3.11.6
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/build/auth/purdue-sso.js
CHANGED
|
@@ -351,8 +351,11 @@ export class PurdueSSOFlow {
|
|
|
351
351
|
const challengeVisible = number !== null ||
|
|
352
352
|
await page.locator("#idDiv_SAOTCAS_Title").first().isVisible().catch(() => false) ||
|
|
353
353
|
await page.locator("#idDiv_SAOTCC_Title").first().isVisible().catch(() => false);
|
|
354
|
-
|
|
354
|
+
// The checkbox can render after the heading or number. Recheck during
|
|
355
|
+
// the existing poll without delaying the first challenge announcement.
|
|
356
|
+
if (challengeVisible)
|
|
355
357
|
await this.rememberMfaDevice(page);
|
|
358
|
+
if (challengeVisible && !challenged) {
|
|
356
359
|
challenged = true;
|
|
357
360
|
log("WARN", "Waiting up to 5 minutes for Microsoft MFA approval on your device.");
|
|
358
361
|
this.config.onMfaChallenge?.(number);
|
|
@@ -507,13 +510,13 @@ export class PurdueSSOFlow {
|
|
|
507
510
|
return true;
|
|
508
511
|
}
|
|
509
512
|
/**
|
|
510
|
-
* Tick Entra's "Don't ask again" box
|
|
513
|
+
* Tick Entra's "Don't ask again" box once; retry absent controls on later MFA polls.
|
|
511
514
|
* Opt-in: without an explicit true the box is left alone and the outcome
|
|
512
515
|
* is recorded as "off", so get_server_info can say why nothing was ticked.
|
|
513
516
|
* An already-checked box is left alone so this can never untick it.
|
|
514
517
|
*/
|
|
515
518
|
async rememberMfaDevice(page) {
|
|
516
|
-
if (this.rememberMfa)
|
|
519
|
+
if (this.rememberMfa && this.rememberMfa.outcome !== "absent")
|
|
517
520
|
return;
|
|
518
521
|
if (this.config.rememberMfa !== true) {
|
|
519
522
|
this.rememberMfa = { outcome: "off", at: new Date().toISOString() };
|
|
@@ -543,6 +546,8 @@ export class PurdueSSOFlow {
|
|
|
543
546
|
catch {
|
|
544
547
|
outcome = "unknown";
|
|
545
548
|
}
|
|
549
|
+
if (this.rememberMfa?.outcome === outcome)
|
|
550
|
+
return;
|
|
546
551
|
this.rememberMfa = { outcome, at: new Date().toISOString() };
|
|
547
552
|
log("INFO", `Entra remember-MFA checkbox: ${REMEMBER_MFA_LOG[outcome]}`);
|
|
548
553
|
}
|
|
@@ -247,13 +247,16 @@ async function finishDownload(response, originalFilename, downloadPath, customFi
|
|
|
247
247
|
export function registerDownloadFile(server, apiClient) {
|
|
248
248
|
server.registerTool("download_file", {
|
|
249
249
|
title: "Download File",
|
|
250
|
-
description: "Download a file from course content, assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
|
|
250
|
+
description: "Download a file from course content, instructor assignment attachments (source: assignmentAttachment with folderId + fileId), assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
|
|
251
251
|
inputSchema: DownloadFileSchema,
|
|
252
252
|
}, async (args) => {
|
|
253
253
|
try {
|
|
254
254
|
log("DEBUG", "download_file tool called", { args });
|
|
255
255
|
// Parse and validate input
|
|
256
|
-
const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
|
|
256
|
+
const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename, source } = DownloadFileSchema.parse(args);
|
|
257
|
+
if (source !== undefined && (topicId !== undefined || newsId !== undefined || folderId === undefined || fileId === undefined)) {
|
|
258
|
+
return errorResponse("source requires folderId and fileId and cannot be combined with topicId or newsId.");
|
|
259
|
+
}
|
|
257
260
|
// Validate courseId
|
|
258
261
|
validateContentId(courseId);
|
|
259
262
|
// downloadPath is optional: omitting it means "return the file
|
|
@@ -288,7 +291,7 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
288
291
|
// Submission file download
|
|
289
292
|
validateContentId(folderId);
|
|
290
293
|
validateContentId(fileId);
|
|
291
|
-
return await downloadSubmissionFile(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
|
|
294
|
+
return await (source === "assignmentAttachment" ? downloadAssignmentAttachment : downloadSubmissionFile)(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
|
|
292
295
|
}
|
|
293
296
|
else if (newsId !== undefined && fileId !== undefined) {
|
|
294
297
|
// Announcement attachment download
|
|
@@ -305,6 +308,23 @@ export function registerDownloadFile(server, apiClient) {
|
|
|
305
308
|
}
|
|
306
309
|
});
|
|
307
310
|
}
|
|
311
|
+
/** Download an instructor-provided handout, independently of student submissions. */
|
|
312
|
+
async function downloadAssignmentAttachment(apiClient, courseId, folderId, fileId, downloadPath, customFilename) {
|
|
313
|
+
const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"));
|
|
314
|
+
const folders = Array.isArray(raw) ? raw : raw?.Objects ?? [];
|
|
315
|
+
const folder = folders.find((candidate) => candidate.Id === folderId && candidate.IsHidden !== true);
|
|
316
|
+
if (!folder)
|
|
317
|
+
return errorResponse(`No visible assignment with id ${folderId} in course ${courseId}.`);
|
|
318
|
+
const attachments = folder.Attachments ?? [];
|
|
319
|
+
const file = attachments.find((candidate) => candidate.FileId === fileId);
|
|
320
|
+
if (!file)
|
|
321
|
+
return errorResponse(`File ID ${fileId} not found on this assignment. Available files: ${attachments.map((candidate) => `${candidate.FileName} (ID: ${candidate.FileId})`).join(", ")}`);
|
|
322
|
+
if (file.Size > maxFileSize(downloadPath))
|
|
323
|
+
return tooLargeResponse(file.Size, downloadPath);
|
|
324
|
+
const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`));
|
|
325
|
+
const filename = parseContentDispositionFilename(response.headers.get("Content-Disposition") ?? "") ?? file.FileName;
|
|
326
|
+
return finishDownload(response, filename, downloadPath, customFilename, "Assignment attachment");
|
|
327
|
+
}
|
|
308
328
|
/**
|
|
309
329
|
* Extract a filename from a Content-Disposition header.
|
|
310
330
|
*/
|
|
@@ -60,6 +60,7 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
|
|
|
60
60
|
});
|
|
61
61
|
}
|
|
62
62
|
const file = await readAttachment(apiClient, apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`), attachment, extractText, maxChars);
|
|
63
|
+
file.downloadArgs = { courseId, folderId, fileId, source: "assignmentAttachment" };
|
|
63
64
|
return toolResponse({
|
|
64
65
|
courseId,
|
|
65
66
|
folderId,
|
|
@@ -76,7 +77,10 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
|
|
|
76
77
|
folderName: folder.Name,
|
|
77
78
|
dueDate: folder.DueDate,
|
|
78
79
|
url: baseUrl ? assignmentUrl(baseUrl, courseId, folder.Id) : null,
|
|
79
|
-
attachments: (folder.Attachments ?? []).map(
|
|
80
|
+
attachments: (folder.Attachments ?? []).map((attachment) => ({
|
|
81
|
+
...describeAttachment(attachment),
|
|
82
|
+
downloadArgs: { courseId, folderId: folder.Id, fileId: attachment.FileId, source: "assignmentAttachment" },
|
|
83
|
+
})),
|
|
80
84
|
}));
|
|
81
85
|
log("INFO", `get_assignment_files: ${withFiles.length} assignments with attachments in course ${courseId}`);
|
|
82
86
|
return toolResponse({
|
package/build/tools/schemas.js
CHANGED
|
@@ -62,14 +62,16 @@ export const GetClasslistEmailsSchema = z.object({
|
|
|
62
62
|
.describe("Course ID to get emails for."),
|
|
63
63
|
});
|
|
64
64
|
export const DownloadFileSchema = z.object({
|
|
65
|
+
source: z.enum(["submission", "assignmentAttachment"]).optional()
|
|
66
|
+
.describe("For folderId + fileId: assignmentAttachment downloads an instructor-provided handout from get_assignment_files; submission (default) downloads your own submitted file. Do not combine source with topicId or newsId."),
|
|
65
67
|
courseId: z.coerce.number().int().positive()
|
|
66
68
|
.describe("Course ID the file belongs to."),
|
|
67
69
|
topicId: z.coerce.number().int().positive().optional()
|
|
68
70
|
.describe("Content topic ID to download (for course content files)."),
|
|
69
71
|
folderId: z.coerce.number().int().positive().optional()
|
|
70
|
-
.describe("Dropbox folder ID
|
|
72
|
+
.describe("Dropbox folder ID. Downloads your submission by default; set source to assignmentAttachment for an instructor-provided handout."),
|
|
71
73
|
fileId: z.coerce.number().int().positive().optional()
|
|
72
|
-
.describe("Specific file ID within a dropbox submission, or
|
|
74
|
+
.describe("Specific file ID within a dropbox submission, instructor assignment attachment (with source: assignmentAttachment), or announcement attachment (with newsId)."),
|
|
73
75
|
newsId: z.coerce.number().int().positive().optional()
|
|
74
76
|
.describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
|
|
75
77
|
downloadPath: z.string().min(1).optional()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.6",
|
|
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",
|