brightspace-mcp-server 3.5.0 → 3.6.0

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/README.md CHANGED
@@ -114,7 +114,7 @@ Run it from your home folder. On macOS, a terminal that lacks Files and Folders
114
114
  | Quizzes | "Which quizzes close this week?" · "Is Quiz 3 timed, and does it have a grace period?" |
115
115
  | Assignment files | "What does the lab 4 spec actually ask for?" · "Summarize the rubric attached to the project" |
116
116
  | Exams | "Is there a midterm in the gradebook that isn't on my assignments list?" |
117
- | Announcements | "Did any professor post something important today?" · "What did my CS prof announce this week?" · "Any announcements since last Monday?" |
117
+ | Announcements | "Did any professor post something important today?" · "What did my CS prof announce this week?" · "Any announcements since last Monday?" · "Read the file attached to today's announcement" · "Save the rubric my prof attached to that announcement" |
118
118
  | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "What's new in this course since I last checked?" |
119
119
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
120
120
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
package/build/index.js CHANGED
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
16
16
  import { readFileSync } from "node:fs";
17
17
  import { fileURLToPath } from "node:url";
18
18
  import { dirname, resolve } from "node:path";
19
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
19
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
  const PKG_VERSION = (() => {
@@ -106,6 +106,7 @@ else {
106
106
  registerGetAnnouncements(server, apiClient, config);
107
107
  registerGetAssignments(server, apiClient, config);
108
108
  registerGetAssignmentFiles(server, apiClient, config.baseUrl);
109
+ registerGetAnnouncementFiles(server, apiClient);
109
110
  registerGetCourseContent(server, apiClient);
110
111
  registerDownloadFile(server, apiClient);
111
112
  registerGetClasslistEmails(server, apiClient);
@@ -114,11 +115,11 @@ else {
114
115
  registerGetDiscussions(server, apiClient);
115
116
  registerGetVideoTranscript(server, apiClient);
116
117
  registerGetServerInfo(server, config, PKG_VERSION);
117
- log("DEBUG", "MCP tools registered (14 tools)");
118
+ log("DEBUG", "MCP tools registered (15 tools)");
118
119
  // Connect stdio transport
119
120
  const transport = new StdioServerTransport();
120
121
  await server.connect(transport);
121
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (14 tools registered)");
122
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (15 tools registered)");
122
123
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
123
124
  }
124
125
  catch (error) {
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT. See LICENSE file for details.
5
+ */
6
+ import { extractPdfText } from "../utils/pdf-extractor.js";
7
+ import { officeDocumentText } from "../utils/zip-extract.js";
8
+ const KIND_BY_EXTENSION = {
9
+ pdf: "pdf",
10
+ docx: "docx",
11
+ doc: "other",
12
+ xlsx: "xlsx",
13
+ xls: "other",
14
+ pptx: "pptx",
15
+ ppt: "other",
16
+ png: "image",
17
+ jpg: "image",
18
+ jpeg: "image",
19
+ gif: "image",
20
+ webp: "image",
21
+ txt: "text",
22
+ md: "text",
23
+ csv: "text",
24
+ json: "text",
25
+ };
26
+ export function fileKind(fileName) {
27
+ const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
28
+ return KIND_BY_EXTENSION[extension] ?? "other";
29
+ }
30
+ export function describeAttachment(attachment) {
31
+ return {
32
+ fileId: attachment.FileId,
33
+ fileName: attachment.FileName,
34
+ size: attachment.Size,
35
+ kind: fileKind(attachment.FileName),
36
+ };
37
+ }
38
+ /**
39
+ * Read one attachment from the path that serves its bytes. The text is best
40
+ * effort: a scanned PDF or an image yields nothing, and that is reported
41
+ * rather than treated as a failure.
42
+ */
43
+ export async function readAttachment(apiClient, sourcePath, attachment, extract, maxChars) {
44
+ const base = describeAttachment(attachment);
45
+ if (!extract)
46
+ return { ...base, text: null, note: "Text extraction was not requested." };
47
+ const response = await apiClient.getRaw(sourcePath);
48
+ const buffer = Buffer.from(await response.arrayBuffer());
49
+ let text = null;
50
+ let note;
51
+ switch (base.kind) {
52
+ case "pdf": {
53
+ const extracted = await extractPdfText(buffer);
54
+ text = extracted?.text?.trim() || null;
55
+ if (!text)
56
+ note = "No text layer in this PDF. It may be a scan.";
57
+ break;
58
+ }
59
+ case "docx":
60
+ case "xlsx":
61
+ case "pptx": {
62
+ text = officeDocumentText(buffer);
63
+ if (!text)
64
+ note = "No readable text found in this Office document.";
65
+ break;
66
+ }
67
+ case "text": {
68
+ text = buffer.toString("utf-8").trim() || null;
69
+ break;
70
+ }
71
+ default: {
72
+ note = `Cannot extract text from a ${base.kind} file. Use download_file to save it.`;
73
+ }
74
+ }
75
+ const truncated = text !== null && text.length > maxChars;
76
+ return {
77
+ ...base,
78
+ bytes: buffer.length,
79
+ text: truncated ? text.slice(0, maxChars) : text,
80
+ truncated,
81
+ ...(note ? { note } : {}),
82
+ };
83
+ }
@@ -19,13 +19,13 @@ import path from "node:path";
19
19
  export function registerDownloadFile(server, apiClient) {
20
20
  server.registerTool("download_file", {
21
21
  title: "Download File",
22
- description: "Download a file from course content or assignment submissions to a local directory. Use this when the user wants to download, save, or get a file from Brightspace course content or dropbox submissions. IMPORTANT: You MUST ask the user where they want to save the file before calling this tool. Never guess or assume a download directory. After identifying the file to download, suggest a clean readable filename to the user (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and ask if they'd like to rename it. Pass their preferred name as customFilename, or omit it to keep the original.",
22
+ description: "Download a file from course content, assignment submissions, or an announcement's attachments to a local directory. Use this when the user wants to download, save, or get a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool. Never guess or assume a download directory. After identifying the file to download, suggest a clean readable filename to the user (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and ask if they'd like to rename it. Pass their preferred name as customFilename, or omit it to keep the original.",
23
23
  inputSchema: DownloadFileSchema,
24
24
  }, async (args) => {
25
25
  try {
26
26
  log("DEBUG", "download_file tool called", { args });
27
27
  // Parse and validate input
28
- const { courseId, topicId, folderId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
28
+ const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
29
29
  // Validate courseId
30
30
  validateContentId(courseId);
31
31
  // Validate download path is absolute
@@ -57,8 +57,14 @@ export function registerDownloadFile(server, apiClient) {
57
57
  validateContentId(fileId);
58
58
  return await downloadSubmissionFile(apiClient, courseId, folderId, fileId, downloadPath, customFilename);
59
59
  }
60
+ else if (newsId !== undefined && fileId !== undefined) {
61
+ // Announcement attachment download
62
+ validateContentId(newsId);
63
+ validateContentId(fileId);
64
+ return await downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename);
65
+ }
60
66
  else {
61
- return errorResponse("Either topicId (for content files) or both folderId and fileId (for submission files) must be provided");
67
+ return errorResponse("Either topicId (for content files), both folderId and fileId (for submission files), or both newsId and fileId (for announcement attachments) must be provided");
62
68
  }
63
69
  }
64
70
  catch (error) {
@@ -202,3 +208,50 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
202
208
  message: `File downloaded successfully to ${result.path}`,
203
209
  });
204
210
  }
211
+ /**
212
+ * Download an announcement attachment using newsId + fileId
213
+ */
214
+ async function downloadNewsAttachment(apiClient, courseId, newsId, fileId, downloadPath, customFilename) {
215
+ log("INFO", `Downloading announcement attachment: courseId=${courseId}, newsId=${newsId}, fileId=${fileId}`);
216
+ const newsItem = await apiClient.get(apiClient.le(courseId, `/news/${newsId}`));
217
+ const attachments = newsItem?.Attachments ?? [];
218
+ const file = attachments.find((f) => f.FileId === fileId);
219
+ if (!file) {
220
+ return errorResponse(`File ID ${fileId} not found on this announcement. Available files: ${attachments.map((f) => `${f.FileName} (ID: ${f.FileId})`).join(", ")}`);
221
+ }
222
+ if (file.Size > MAX_FILE_SIZE) {
223
+ return errorResponse(`File too large (${Math.round(file.Size / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
224
+ }
225
+ // GET /d2l/api/le/(version)/(orgUnitId)/news/(newsItemId)/attachments/(fileId)
226
+ const response = await apiClient.getRaw(apiClient.le(courseId, `/news/${newsId}/attachments/${fileId}`));
227
+ // Check Content-Length BEFORE downloading body (prevent memory exhaustion)
228
+ const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
229
+ if (contentLength > MAX_FILE_SIZE) {
230
+ return errorResponse(`File too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
231
+ }
232
+ const disposition = response.headers.get("Content-Disposition") ?? "";
233
+ const filename = parseContentDispositionFilename(disposition) ?? file.FileName;
234
+ // Download body as buffer
235
+ const buffer = Buffer.from(await response.arrayBuffer());
236
+ // Double-check actual size
237
+ if (buffer.length > MAX_FILE_SIZE) {
238
+ return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
239
+ }
240
+ const originalFilename = filename;
241
+ const effectiveFilename = customFilename || filename;
242
+ // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
243
+ const result = await secureDownload({
244
+ targetDir: downloadPath,
245
+ filename: effectiveFilename,
246
+ data: buffer,
247
+ });
248
+ log("INFO", `Announcement attachment downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
249
+ return toolResponse({
250
+ success: true,
251
+ filePath: result.path,
252
+ fileSize: result.size,
253
+ mimeType: result.mime,
254
+ originalFilename,
255
+ message: `File downloaded successfully to ${result.path}`,
256
+ });
257
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Purdue Brightspace MCP Server
3
+ * Copyright (c) 2026 Rohan Muppa. All rights reserved.
4
+ * Licensed under MIT. See LICENSE file for details.
5
+ */
6
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { GetAnnouncementFilesSchema } from "./schemas.js";
8
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
+ import { describeAttachment, readAttachment } from "./attachment-reader.js";
10
+ import { effectiveDate, isPublishedNewsItem } from "./get-announcements.js";
11
+ import { log } from "../utils/logger.js";
12
+ /**
13
+ * The files an instructor attached to an announcement: field-notes prompts,
14
+ * a rubric, an updated schedule. They hang off the news item, not course
15
+ * content, so get_course_content never lists them. Same shape as
16
+ * get_assignment_files: list first, then read one file by id.
17
+ */
18
+ /** Every posted announcement in the course, narrowed to one when newsId is given. */
19
+ async function listNews(apiClient, courseId, newsId) {
20
+ const items = await apiClient.get(apiClient.le(courseId, "/news/"), {
21
+ ttl: DEFAULT_CACHE_TTLS.announcements,
22
+ });
23
+ return items
24
+ .filter(isPublishedNewsItem)
25
+ .filter((item) => (newsId === undefined ? true : item.Id === newsId));
26
+ }
27
+ export function registerGetAnnouncementFiles(server, apiClient) {
28
+ server.registerTool("get_announcement_files", {
29
+ title: "Get Announcement Files",
30
+ description: "Read the files an instructor attached to an announcement: prompt questions, a rubric, an updated schedule, slides. Call it with just courseId to see which announcements have attachments, then with newsId and fileId to read one. Use this when the user asks what a file attached to an announcement says. Returns the text itself. Use download_file (newsId + fileId) instead when the user wants the file saved to disk.",
31
+ inputSchema: GetAnnouncementFilesSchema,
32
+ }, async (args) => {
33
+ try {
34
+ log("DEBUG", "get_announcement_files tool called", { args });
35
+ const { courseId, newsId, fileId, extractText, maxChars } = GetAnnouncementFilesSchema.parse(args);
36
+ if (fileId !== undefined && newsId === undefined) {
37
+ return toolResponse({
38
+ courseId,
39
+ error: "newsId is required when fileId is given.",
40
+ });
41
+ }
42
+ const items = await listNews(apiClient, courseId, newsId);
43
+ if (newsId !== undefined && items.length === 0) {
44
+ return toolResponse({
45
+ courseId,
46
+ newsId,
47
+ error: `No announcement with id ${newsId} in course ${courseId}.`,
48
+ });
49
+ }
50
+ // Read one file.
51
+ if (fileId !== undefined) {
52
+ const item = items[0];
53
+ const attachments = item.Attachments ?? [];
54
+ const attachment = attachments.find((a) => a.FileId === fileId);
55
+ if (!attachment) {
56
+ return toolResponse({
57
+ courseId,
58
+ newsId,
59
+ fileId,
60
+ error: `No attachment with id ${fileId} on announcement "${item.Title}".`,
61
+ available: attachments.map(describeAttachment),
62
+ });
63
+ }
64
+ const file = await readAttachment(apiClient, apiClient.le(courseId, `/news/${item.Id}/attachments/${fileId}`), attachment, extractText, maxChars);
65
+ return toolResponse({ courseId, newsId, title: item.Title, file });
66
+ }
67
+ // Discovery: which announcements have files, without downloading any.
68
+ const withFiles = items
69
+ .filter((item) => (item.Attachments ?? []).length > 0)
70
+ .map((item) => ({
71
+ newsId: item.Id,
72
+ title: item.Title,
73
+ date: effectiveDate(item),
74
+ attachments: (item.Attachments ?? []).map(describeAttachment),
75
+ }));
76
+ log("INFO", `get_announcement_files: ${withFiles.length} announcements with attachments in course ${courseId}`);
77
+ return toolResponse({
78
+ courseId,
79
+ announcements: withFiles,
80
+ ...(withFiles.length === 0
81
+ ? { note: "No announcement in this course has an attached file." }
82
+ : {}),
83
+ });
84
+ }
85
+ catch (error) {
86
+ return sanitizeError(error);
87
+ }
88
+ });
89
+ }
@@ -56,6 +56,11 @@ export function newestFirst(a, b) {
56
56
  * Map a raw D2L news item to a clean announcement object.
57
57
  */
58
58
  export function mapNewsItem(item) {
59
+ const attachments = (item.Attachments ?? []).map((file) => ({
60
+ fileId: file.FileId,
61
+ fileName: file.FileName,
62
+ size: file.Size,
63
+ }));
59
64
  return {
60
65
  id: item.Id,
61
66
  title: item.Title,
@@ -64,6 +69,7 @@ export function mapNewsItem(item) {
64
69
  date: effectiveDate(item),
65
70
  isPinned: item.IsPinned,
66
71
  lastModified: item.LastModifiedDate ?? null,
72
+ ...(attachments.length > 0 ? { attachments } : {}),
67
73
  };
68
74
  }
69
75
  /**
@@ -72,7 +78,7 @@ export function mapNewsItem(item) {
72
78
  export function registerGetAnnouncements(server, apiClient, config) {
73
79
  server.registerTool("get_announcements", {
74
80
  title: "Get Announcements",
75
- description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said.",
81
+ description: "Fetch recent announcements from your courses. Can filter to a specific course or get announcements across all courses. Use this when the user asks about announcements, news, updates from instructors, recent posts, or what professors said. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
76
82
  inputSchema: GetAnnouncementsSchema,
77
83
  }, async (args) => {
78
84
  try {
@@ -6,44 +6,14 @@
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
7
  import { GetAssignmentFilesSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
9
- import { extractPdfText } from "../utils/pdf-extractor.js";
10
- import { officeDocumentText } from "../utils/zip-extract.js";
9
+ import { describeAttachment, readAttachment, } from "./attachment-reader.js";
11
10
  import { assignmentUrl } from "../utils/deep-links.js";
12
11
  import { log } from "../utils/logger.js";
12
+ export { fileKind } from "./attachment-reader.js";
13
13
  /** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
14
14
  function unwrapList(raw) {
15
15
  return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
16
16
  }
17
- const KIND_BY_EXTENSION = {
18
- pdf: "pdf",
19
- docx: "docx",
20
- doc: "other",
21
- xlsx: "xlsx",
22
- xls: "other",
23
- pptx: "pptx",
24
- ppt: "other",
25
- png: "image",
26
- jpg: "image",
27
- jpeg: "image",
28
- gif: "image",
29
- webp: "image",
30
- txt: "text",
31
- md: "text",
32
- csv: "text",
33
- json: "text",
34
- };
35
- export function fileKind(fileName) {
36
- const extension = fileName.split(".").pop()?.toLowerCase() ?? "";
37
- return KIND_BY_EXTENSION[extension] ?? "other";
38
- }
39
- function describeAttachment(attachment) {
40
- return {
41
- fileId: attachment.FileId,
42
- fileName: attachment.FileName,
43
- size: attachment.Size,
44
- kind: fileKind(attachment.FileName),
45
- };
46
- }
47
17
  /** Every visible folder in the course that has at least one attachment. */
48
18
  async function listFolders(apiClient, courseId, folderId) {
49
19
  const raw = await apiClient.get(apiClient.le(courseId, "/dropbox/folders/"), {
@@ -53,51 +23,6 @@ async function listFolders(apiClient, courseId, folderId) {
53
23
  .filter((folder) => folder.IsHidden !== true)
54
24
  .filter((folder) => (folderId === undefined ? true : folder.Id === folderId));
55
25
  }
56
- /**
57
- * Read one attachment. The text is best effort: a scanned PDF or an image
58
- * yields nothing, and that is reported rather than treated as a failure.
59
- */
60
- async function readAttachment(apiClient, courseId, folderId, attachment, extract, maxChars) {
61
- const base = describeAttachment(attachment);
62
- if (!extract)
63
- return { ...base, text: null, note: "Text extraction was not requested." };
64
- const response = await apiClient.getRaw(apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${attachment.FileId}`));
65
- const buffer = Buffer.from(await response.arrayBuffer());
66
- let text = null;
67
- let note;
68
- switch (base.kind) {
69
- case "pdf": {
70
- const extracted = await extractPdfText(buffer);
71
- text = extracted?.text?.trim() || null;
72
- if (!text)
73
- note = "No text layer in this PDF. It may be a scan.";
74
- break;
75
- }
76
- case "docx":
77
- case "xlsx":
78
- case "pptx": {
79
- text = officeDocumentText(buffer);
80
- if (!text)
81
- note = "No readable text found in this Office document.";
82
- break;
83
- }
84
- case "text": {
85
- text = buffer.toString("utf-8").trim() || null;
86
- break;
87
- }
88
- default: {
89
- note = `Cannot extract text from a ${base.kind} file. Use download_file to save it.`;
90
- }
91
- }
92
- const truncated = text !== null && text.length > maxChars;
93
- return {
94
- ...base,
95
- bytes: buffer.length,
96
- text: truncated ? text.slice(0, maxChars) : text,
97
- truncated,
98
- ...(note ? { note } : {}),
99
- };
100
- }
101
26
  export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
102
27
  server.registerTool("get_assignment_files", {
103
28
  title: "Get Assignment Files",
@@ -134,7 +59,7 @@ export function registerGetAssignmentFiles(server, apiClient, baseUrl) {
134
59
  available: (folder.Attachments ?? []).map(describeAttachment),
135
60
  });
136
61
  }
137
- const file = await readAttachment(apiClient, courseId, folderId, attachment, extractText, maxChars);
62
+ const file = await readAttachment(apiClient, apiClient.le(courseId, `/dropbox/folders/${folderId}/attachments/${fileId}`), attachment, extractText, maxChars);
138
63
  return toolResponse({
139
64
  courseId,
140
65
  folderId,
@@ -10,6 +10,7 @@ export { registerGetMyGrades } from "./get-my-grades.js";
10
10
  export { registerGetAnnouncements } from "./get-announcements.js";
11
11
  export { registerGetAssignments } from "./get-assignments.js";
12
12
  export { registerGetAssignmentFiles } from "./get-assignment-files.js";
13
+ export { registerGetAnnouncementFiles } from "./get-announcement-files.js";
13
14
  export { registerGetCourseContent } from "./get-course-content.js";
14
15
  export { registerDownloadFile } from "./download-file.js";
15
16
  export { registerGetClasslistEmails } from "./get-classlist-emails.js";
@@ -59,7 +59,9 @@ export const DownloadFileSchema = z.object({
59
59
  folderId: z.coerce.number().int().positive().optional()
60
60
  .describe("Dropbox folder ID (for submission/feedback file downloads)."),
61
61
  fileId: z.coerce.number().int().positive().optional()
62
- .describe("Specific file ID within a dropbox submission."),
62
+ .describe("Specific file ID within a dropbox submission, or an announcement attachment's file ID (with newsId)."),
63
+ newsId: z.coerce.number().int().positive().optional()
64
+ .describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
63
65
  downloadPath: z.string().min(1)
64
66
  .describe("Absolute path to the directory where the file should be saved."),
65
67
  customFilename: z.string().max(255).optional()
@@ -91,6 +93,18 @@ export const GetAssignmentFilesSchema = z.object({
91
93
  maxChars: z.coerce.number().int().positive().max(100000).default(12000)
92
94
  .describe("Maximum characters of extracted text to return. The response reports whether it was truncated."),
93
95
  });
96
+ export const GetAnnouncementFilesSchema = z.object({
97
+ courseId: z.coerce.number().int().positive()
98
+ .describe("Course ID whose announcement attachments to look at."),
99
+ newsId: z.coerce.number().int().positive().optional()
100
+ .describe("Announcement (news item) ID. Omit to list every announcement in the course that has attachments."),
101
+ fileId: z.coerce.number().int().positive().optional()
102
+ .describe("Attachment file ID to read. Requires newsId. Omit to list the files without reading them."),
103
+ extractText: z.boolean().default(true)
104
+ .describe("Extract readable text from the file. Works for PDF, DOCX, XLSX, PPTX, and plain text."),
105
+ maxChars: z.coerce.number().int().positive().max(100000).default(12000)
106
+ .describe("Maximum characters of extracted text to return. The response reports whether it was truncated."),
107
+ });
94
108
  export const GetVideoTranscriptSchema = z.object({
95
109
  courseId: z.coerce.number().int().positive().optional()
96
110
  .describe("Course ID the video belongs to. Required together with topicId unless videoUrl is given directly."),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.5.0",
3
+ "version": "3.6.0",
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",