brightspace-mcp-server 3.7.10 → 3.7.12

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
@@ -99,7 +99,7 @@ There is nothing to log into first. Ask for your grades and the sign-in happens
99
99
 
100
100
  Returning the next day normally requires no action. The server renews short-lived API tokens over HTTPS using the saved Brightspace session. If that session ends, a browser restores your saved Microsoft session and tries silent SSO. Approval and code-based modes stay headless; when an automatic run needs a code, run the auth command below to enter it securely in the terminal.
101
101
 
102
- If visible-browser mode is configured, the window stays open for up to five minutes so you can finish credentials and MFA manually when automatic sign-in cannot continue. Rerunning setup preserves your previous hidden or visible choice as the prompt default.
102
+ If visible-browser mode is configured, the auth command opens a window that stays open for up to five minutes so you can finish credentials and MFA manually when automatic sign-in cannot continue. Background recovery during a tool call always runs headless so it never opens a browser over your work; set `D2L_HEADLESS=false` in the server's environment to let it open one too. Rerunning setup preserves your previous hidden or visible choice as the prompt default.
103
103
 
104
104
  Your school's policy controls when MFA is required. There is no local 24-hour cutoff, and the server no longer discards browser state after one hour. A network outage preserves the saved session and returns a temporary error.
105
105
 
@@ -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.
@@ -111,7 +111,8 @@ function forwardLines(stream, emit) {
111
111
  * re-authenticate when the current session has expired.
112
112
  *
113
113
  * The child inherits the parent's resolved environment and working directory,
114
- * so both processes read the same account configuration and .env file.
114
+ * so both processes read the same account configuration and .env file. It
115
+ * runs headless unless D2L_HEADLESS is set explicitly.
115
116
  *
116
117
  * run() settles as soon as the child reports an MFA challenge (with or
117
118
  * without a number to display) rather than waiting for the child to exit —
@@ -241,9 +242,13 @@ export class AuthRunner {
241
242
  const child = spawn(process.execPath, // use the same Node binary
242
243
  [this.scriptPath, "--automatic"], {
243
244
  cwd: process.cwd(),
244
- env: { ...process.env },
245
+ // Background recovery never opens a browser over the user's work
246
+ // unless D2L_HEADLESS explicitly asks for one; the saved
247
+ // visible-browser preference applies to manual sign-in only.
248
+ env: { D2L_HEADLESS: "true", ...process.env },
245
249
  stdio: ["ignore", "pipe", "pipe"],
246
250
  detached: process.platform !== "win32",
251
+ windowsHide: true,
247
252
  });
248
253
  let timedOut = false;
249
254
  // Whether the caller-facing promise above (resolve/reject) has
@@ -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.12",
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",