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 +1 -1
- package/build/index.js +4 -3
- package/build/tools/attachment-reader.js +83 -0
- package/build/tools/download-file.js +56 -3
- package/build/tools/get-announcement-files.js +89 -0
- package/build/tools/get-announcements.js +7 -1
- package/build/tools/get-assignment-files.js +3 -78
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +15 -1
- package/package.json +1 -1
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 (
|
|
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 (
|
|
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
|
|
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)
|
|
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 {
|
|
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,
|
package/build/tools/index.js
CHANGED
|
@@ -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";
|
package/build/tools/schemas.js
CHANGED
|
@@ -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.
|
|
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",
|