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.
@@ -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
- if (challengeVisible && !challenged) {
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, once per login and never in a loop.
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(describeAttachment),
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({
@@ -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 (for submission/feedback file downloads)."),
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 an announcement attachment's file ID (with newsId)."),
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.4",
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",