brightspace-mcp-server 3.7.10 → 3.7.11
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
|
@@ -135,5 +135,9 @@ The number in that response can go stale if the request times out or you tap Den
|
|
|
135
135
|
| Calendar | "When is my midterm?" · "What's on my calendar this week?" · "Is lab cancelled on Thursday?" — reads exams, labs, review sessions, and deadlines instructors put only on the course calendar |
|
|
136
136
|
| Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, graded discussion topics (any topic with a due date), and course calendar events such as exams and labs |
|
|
137
137
|
|
|
138
|
+
`get_course_content` reports `isAvailable`, `availabilityStatus` (`available`, `not_yet_open`, `ended`, `hidden`, or `locked`), `availabilityMessage`, `startDate`, and `endDate` for modules and topics. The effective release window includes restrictions inherited from enclosing modules.
|
|
139
|
+
|
|
140
|
+
When a content-file download returns 403 or 404, `download_file` checks topic metadata and the course table of contents. Confirmed restrictions return `{ success: false, available: false, reason, message, startDate, endDate }` so the assistant can explain when content opens or why it has closed. Unexplained 404s and server/network failures retain their original errors.
|
|
141
|
+
|
|
138
142
|
|
|
139
143
|
Licensed under the MIT License.
|
|
@@ -0,0 +1,50 @@
|
|
|
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
|
+
const ISO_8601_PATTERN = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?)?$/;
|
|
7
|
+
function validDate(value) {
|
|
8
|
+
return typeof value === "string" &&
|
|
9
|
+
ISO_8601_PATTERN.test(value.trim()) &&
|
|
10
|
+
Number.isFinite(Date.parse(value))
|
|
11
|
+
? value
|
|
12
|
+
: null;
|
|
13
|
+
}
|
|
14
|
+
function limitDate(own, parent, latest) {
|
|
15
|
+
if (!own)
|
|
16
|
+
return parent;
|
|
17
|
+
if (!parent)
|
|
18
|
+
return own;
|
|
19
|
+
return (Date.parse(own) > Date.parse(parent)) === latest ? own : parent;
|
|
20
|
+
}
|
|
21
|
+
/** Dates explain a scheduled lock more precisely than the generic IsLocked flag. */
|
|
22
|
+
export function contentAvailability(item, now = new Date(), parent) {
|
|
23
|
+
const ownStart = validDate(item.ModuleStartDate) ?? validDate(item.StartDate) ?? validDate(item.StartDateTime);
|
|
24
|
+
const ownEnd = validDate(item.ModuleEndDate) ?? validDate(item.EndDate) ?? validDate(item.EndDateTime);
|
|
25
|
+
const startDate = limitDate(ownStart, parent?.startDate ?? null, true);
|
|
26
|
+
const endDate = limitDate(ownEnd, parent?.endDate ?? null, false);
|
|
27
|
+
let availabilityStatus = "available";
|
|
28
|
+
let availabilityMessage = "Available";
|
|
29
|
+
if (item.IsHidden === true || parent?.availabilityStatus === "hidden") {
|
|
30
|
+
availabilityStatus = "hidden";
|
|
31
|
+
availabilityMessage = "This content is hidden by the instructor.";
|
|
32
|
+
}
|
|
33
|
+
else if (endDate && Date.parse(endDate) <= now.getTime()) {
|
|
34
|
+
availabilityStatus = "ended";
|
|
35
|
+
availabilityMessage = `This content's availability window has ended (closed at ${endDate}).`;
|
|
36
|
+
}
|
|
37
|
+
else if (item.IsLocked === true || parent?.availabilityStatus === "locked") {
|
|
38
|
+
availabilityStatus = "locked";
|
|
39
|
+
availabilityMessage = "This content is locked by the instructor or an enclosing module.";
|
|
40
|
+
}
|
|
41
|
+
else if (startDate && endDate && Date.parse(startDate) >= Date.parse(endDate)) {
|
|
42
|
+
availabilityStatus = "locked";
|
|
43
|
+
availabilityMessage = "The content and its enclosing modules have no overlapping availability window.";
|
|
44
|
+
}
|
|
45
|
+
else if (startDate && Date.parse(startDate) > now.getTime()) {
|
|
46
|
+
availabilityStatus = "not_yet_open";
|
|
47
|
+
availabilityMessage = `This content has not been released by the instructor yet (available from ${startDate}).`;
|
|
48
|
+
}
|
|
49
|
+
return { isAvailable: availabilityStatus === "available", availabilityStatus, availabilityMessage, startDate, endDate };
|
|
50
|
+
}
|
|
@@ -3,9 +3,11 @@
|
|
|
3
3
|
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
|
+
import { ApiError } from "../api/index.js";
|
|
6
7
|
import { DownloadFileSchema } from "./schemas.js";
|
|
7
8
|
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
8
9
|
import { log } from "../utils/logger.js";
|
|
10
|
+
import { checkTopicAvailability } from "./topic-availability.js";
|
|
9
11
|
// Path containment and magic-byte checks belong to secureDownload, which both
|
|
10
12
|
// download paths below go through; importing them here only made it look as
|
|
11
13
|
// though this file validated anything itself.
|
|
@@ -19,7 +21,7 @@ import path from "node:path";
|
|
|
19
21
|
export function registerDownloadFile(server, apiClient) {
|
|
20
22
|
server.registerTool("download_file", {
|
|
21
23
|
title: "Download File",
|
|
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.",
|
|
24
|
+
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. 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).",
|
|
23
25
|
inputSchema: DownloadFileSchema,
|
|
24
26
|
}, async (args) => {
|
|
25
27
|
try {
|
|
@@ -103,8 +105,20 @@ async function downloadContentFile(apiClient, courseId, topicId, downloadPath, c
|
|
|
103
105
|
log("INFO", `Downloading content file: courseId=${courseId}, topicId=${topicId}`);
|
|
104
106
|
// Build download URL using D2L API path helper
|
|
105
107
|
const apiPath = apiClient.le(courseId, `/content/topics/${topicId}/file`);
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
+
// Unreleased files can return 404 as well as 403. Only explain a release
|
|
109
|
+
// restriction when the metadata supports it; keep other failures intact.
|
|
110
|
+
let response;
|
|
111
|
+
try {
|
|
112
|
+
response = await apiClient.getRaw(apiPath);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
if (error instanceof ApiError && (error.status === 403 || error.status === 404)) {
|
|
116
|
+
const unavailable = await checkTopicAvailability(apiClient, courseId, topicId, error.status);
|
|
117
|
+
if (unavailable)
|
|
118
|
+
return toolResponse(unavailable);
|
|
119
|
+
}
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
108
122
|
// Check Content-Length BEFORE downloading body (prevent memory exhaustion)
|
|
109
123
|
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
110
124
|
if (contentLength > MAX_FILE_SIZE) {
|
|
@@ -9,6 +9,7 @@ import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
|
9
9
|
import { convertHtmlToMarkdown } from "../utils/html-converter.js";
|
|
10
10
|
import { matchesModifiedSince } from "../utils/modified-since.js";
|
|
11
11
|
import { log } from "../utils/logger.js";
|
|
12
|
+
import { contentAvailability } from "./content-availability.js";
|
|
12
13
|
// Topic type mapping
|
|
13
14
|
const TOPIC_TYPE_MAP = {
|
|
14
15
|
1: 'file',
|
|
@@ -39,13 +40,32 @@ function matchesTypeFilter(item, filter) {
|
|
|
39
40
|
* recursion would never come back.
|
|
40
41
|
*/
|
|
41
42
|
const MAX_CONTENT_DEPTH = 12;
|
|
43
|
+
/**
|
|
44
|
+
* Availability fields are additive, matching the isHidden/isLocked convention
|
|
45
|
+
* above: an available item carries none of them (absence = available), and a
|
|
46
|
+
* restricted one omits startDate/endDate when there is no actual bound
|
|
47
|
+
* rather than emitting a null.
|
|
48
|
+
*/
|
|
49
|
+
function availabilityFields(availability) {
|
|
50
|
+
if (availability.isAvailable)
|
|
51
|
+
return {};
|
|
52
|
+
const { isAvailable, availabilityStatus, availabilityMessage, startDate, endDate } = availability;
|
|
53
|
+
return {
|
|
54
|
+
isAvailable,
|
|
55
|
+
availabilityStatus,
|
|
56
|
+
availabilityMessage,
|
|
57
|
+
...(startDate ? { startDate } : {}),
|
|
58
|
+
...(endDate ? { endDate } : {}),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
42
61
|
/**
|
|
43
62
|
* Recursively build the content tree with progress tracking.
|
|
44
63
|
*/
|
|
45
|
-
async function buildContentTree(apiClient, courseId, modules, progressMap, typeFilter, maxDepth, currentDepth = 0) {
|
|
64
|
+
async function buildContentTree(apiClient, courseId, modules, progressMap, typeFilter, maxDepth, currentDepth = 0, parentAvailability, now = new Date()) {
|
|
46
65
|
const tree = [];
|
|
47
66
|
const depthLimit = Math.min(maxDepth ?? MAX_CONTENT_DEPTH, MAX_CONTENT_DEPTH);
|
|
48
67
|
for (const item of modules) {
|
|
68
|
+
const availability = contentAvailability(item, now, parentAvailability);
|
|
49
69
|
if (item.Type === 0) {
|
|
50
70
|
// Module: fetch children recursively (unless the depth limit is reached)
|
|
51
71
|
let processedChildren = [];
|
|
@@ -67,7 +87,7 @@ async function buildContentTree(apiClient, courseId, modules, progressMap, typeF
|
|
|
67
87
|
if (!Array.isArray(children)) {
|
|
68
88
|
children = Array.isArray(item.Structure) ? item.Structure : [];
|
|
69
89
|
}
|
|
70
|
-
processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1);
|
|
90
|
+
processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1, availability, now);
|
|
71
91
|
}
|
|
72
92
|
else if (currentDepth >= MAX_CONTENT_DEPTH) {
|
|
73
93
|
log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
|
|
@@ -82,6 +102,7 @@ async function buildContentTree(apiClient, courseId, modules, progressMap, typeF
|
|
|
82
102
|
...(item.ModuleDueDate ? { dueDate: item.ModuleDueDate } : {}),
|
|
83
103
|
...(item.IsHidden ? { isHidden: item.IsHidden } : {}),
|
|
84
104
|
...(item.IsLocked ? { isLocked: item.IsLocked } : {}),
|
|
105
|
+
...availabilityFields(availability),
|
|
85
106
|
lastModified: item.LastModifiedDate ?? null,
|
|
86
107
|
children: processedChildren,
|
|
87
108
|
});
|
|
@@ -102,6 +123,7 @@ async function buildContentTree(apiClient, courseId, modules, progressMap, typeF
|
|
|
102
123
|
title: item.Title,
|
|
103
124
|
...(item.IsHidden ? { isHidden: item.IsHidden } : {}),
|
|
104
125
|
...(item.IsLocked ? { isLocked: item.IsLocked } : {}),
|
|
126
|
+
...availabilityFields(availability),
|
|
105
127
|
...(item.DueDate ? { dueDate: item.DueDate } : {}),
|
|
106
128
|
lastModified: item.LastModifiedDate ?? null,
|
|
107
129
|
isCompleted: topicProgress?.IsRead ?? false,
|
|
@@ -188,7 +210,7 @@ function countModules(tree) {
|
|
|
188
210
|
export function registerGetCourseContent(server, apiClient) {
|
|
189
211
|
server.registerTool("get_course_content", {
|
|
190
212
|
title: "Get Course Content",
|
|
191
|
-
description: "Fetch the content tree for a course showing modules, topics, files, and links. Use this when the user asks about course materials, lecture slides, uploaded files, content structure, or what's in a course module. Use moduleTitle to filter to a specific module (e.g. 'Labs', 'Staff', 'Homeworks') instead of fetching the entire tree. Use maxDepth to limit recursion depth for a table-of-contents view.",
|
|
213
|
+
description: "Fetch the content tree for a course showing modules, topics, files, and links. Use this when the user asks about course materials, lecture slides, uploaded files, content structure, or what's in a course module. Use moduleTitle to filter to a specific module (e.g. 'Labs', 'Staff', 'Homeworks') instead of fetching the entire tree. Use maxDepth to limit recursion depth for a table-of-contents view. A module or topic that isn't currently available carries isAvailable/availabilityStatus/availabilityMessage (and startDate/endDate when known) explaining why; absence of these fields means it's available now.",
|
|
192
214
|
inputSchema: GetCourseContentSchema,
|
|
193
215
|
}, async (args) => {
|
|
194
216
|
try {
|
|
@@ -0,0 +1,94 @@
|
|
|
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 { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
|
+
import { contentAvailability } from "./content-availability.js";
|
|
8
|
+
import { log } from "../utils/logger.js";
|
|
9
|
+
function record(value) {
|
|
10
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
|
|
11
|
+
}
|
|
12
|
+
function matchesTopic(value, topicId) {
|
|
13
|
+
return [value.TopicId, value.Identifier, value.Id].some(id => (typeof id === "number" || (typeof id === "string" && /^\d+$/.test(id))) && Number(id) === topicId);
|
|
14
|
+
}
|
|
15
|
+
/** A bounded iterative traversal also tolerates incomplete TOC responses. */
|
|
16
|
+
function findTopic(toc, topicId, now) {
|
|
17
|
+
const modules = record(toc)?.Modules;
|
|
18
|
+
if (!Array.isArray(modules))
|
|
19
|
+
return null;
|
|
20
|
+
const queue = modules.map(module => ({ module }));
|
|
21
|
+
const visited = new Set();
|
|
22
|
+
for (let index = 0; index < queue.length && index < 10_000; index++) {
|
|
23
|
+
const entry = queue[index];
|
|
24
|
+
const module = record(entry.module);
|
|
25
|
+
if (!module || visited.has(module))
|
|
26
|
+
continue;
|
|
27
|
+
visited.add(module);
|
|
28
|
+
const availability = contentAvailability(module, now, entry.parent);
|
|
29
|
+
if (Array.isArray(module.Topics)) {
|
|
30
|
+
for (const raw of module.Topics) {
|
|
31
|
+
const topic = record(raw);
|
|
32
|
+
if (topic && matchesTopic(topic, topicId)) {
|
|
33
|
+
return { metadata: topic, availability: contentAvailability(topic, now, availability) };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (Array.isArray(module.Modules)) {
|
|
38
|
+
for (const nested of module.Modules)
|
|
39
|
+
queue.push({ module: nested, parent: availability });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
/** Explain only restrictions backed by topic/TOC metadata; preserve unexplained download errors. */
|
|
45
|
+
export async function checkTopicAvailability(client, courseId, topicId, httpStatus) {
|
|
46
|
+
if (httpStatus !== 403 && httpStatus !== 404)
|
|
47
|
+
return null;
|
|
48
|
+
const now = new Date();
|
|
49
|
+
let direct = null;
|
|
50
|
+
try {
|
|
51
|
+
const raw = record(await client.get(client.le(courseId, `/content/topics/${topicId}`), {
|
|
52
|
+
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
53
|
+
}));
|
|
54
|
+
if (raw && matchesTopic(raw, topicId))
|
|
55
|
+
direct = raw;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (!(error instanceof ApiError) || (error.status !== 403 && error.status !== 404)) {
|
|
59
|
+
log("DEBUG", "Topic availability lookup failed unexpectedly; preserving the download error", error);
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
let fromToc = null;
|
|
64
|
+
try {
|
|
65
|
+
const toc = await client.get(client.le(courseId, "/content/toc"), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
66
|
+
fromToc = findTopic(toc, topicId, now);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
log("DEBUG", "TOC availability lookup failed; using direct topic metadata if present", error);
|
|
70
|
+
}
|
|
71
|
+
if (!direct && !fromToc)
|
|
72
|
+
return null;
|
|
73
|
+
const metadata = direct ?? fromToc.metadata;
|
|
74
|
+
// A link or quiz has no file endpoint even after release. Preserve that
|
|
75
|
+
// failure instead of promising that its file will become downloadable.
|
|
76
|
+
const isNonFile = (item) => Boolean(item && (item.TopicType === 2 || item.TopicType === 3 ||
|
|
77
|
+
(typeof item.TypeIdentifier === "string" && ["link", "quiz", "discussion", "dropbox", "assignment"].includes(item.TypeIdentifier.toLowerCase()))));
|
|
78
|
+
if (isNonFile(direct) || isNonFile(fromToc?.metadata))
|
|
79
|
+
return null;
|
|
80
|
+
const availability = direct ? contentAvailability(direct, now, fromToc?.availability) : fromToc.availability;
|
|
81
|
+
const title = typeof metadata.Title === "string" ? metadata.Title
|
|
82
|
+
: typeof fromToc?.metadata.Title === "string" ? fromToc.metadata.Title : undefined;
|
|
83
|
+
// Additive only: if the metadata itself says the topic is available, it
|
|
84
|
+
// doesn't explain the download failure — keep the original error instead
|
|
85
|
+
// of manufacturing a misleading "restricted" response.
|
|
86
|
+
if (availability.isAvailable)
|
|
87
|
+
return null;
|
|
88
|
+
return {
|
|
89
|
+
success: false, available: false, courseId, topicId, ...(title ? { title } : {}),
|
|
90
|
+
reason: availability.availabilityStatus,
|
|
91
|
+
startDate: availability.startDate, endDate: availability.endDate,
|
|
92
|
+
message: availability.availabilityMessage,
|
|
93
|
+
};
|
|
94
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.11",
|
|
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",
|