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
- // Fetch file using getRaw (returns Response object, not parsed JSON)
107
- const response = await apiClient.getRaw(apiPath);
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.10",
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",