brightspace-mcp-server 3.11.4 → 3.11.5

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.
@@ -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.5",
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",