brightspace-mcp-server 3.7.8 → 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
@@ -6,7 +6,7 @@ Talk to your Brightspace courses with AI. Ask about grades, due dates, quizzes,
6
6
 
7
7
  This is an [MCP (Model Context Protocol)](https://modelcontextprotocol.io) server that connects your AI to D2L Brightspace so it can pull your grades, assignments, syllabus, and course content on demand.
8
8
 
9
- Connects to D2L Brightspace. Automatic login supports Purdue's Microsoft Entra flow and SUNY campus selection. Other schools need a compatible automated sign-in flow; unsupported login pages return an actionable error.
9
+ Connects to D2L Brightspace. Automatic login supports Purdue's Microsoft Entra flow, SUNY campus selection, Western University, and TU Delft NetID via SURFconext. Other schools need a compatible automated sign-in flow; unsupported login pages return an actionable error.
10
10
 
11
11
  <p align="center">
12
12
  <img src="https://raw.githubusercontent.com/RohanMuppa/brightspace-mcp-server/main/docs/how-it-works.svg" alt="Architecture diagram" width="100%">
@@ -28,7 +28,7 @@ Paste this into Claude Code, Cursor, Windsurf, Copilot, Codex, or any AI coding
28
28
  ```
29
29
  Install brightspace-mcp-server for me by following
30
30
  https://github.com/RohanMuppa/brightspace-mcp-server/blob/main/LLMs.md
31
- (use --purdue if I'm at Purdue, or --suny if I'm at a SUNY campus).
31
+ (use --purdue at Purdue, --suny at SUNY, or --tudelft at TU Delft).
32
32
  ```
33
33
 
34
34
  **Option 2: Run it yourself**
@@ -50,6 +50,14 @@ you're at and skips SUNY's campus picker when you sign in:
50
50
  npx -y brightspace-mcp-server@latest setup --suny
51
51
  ```
52
52
 
53
+ TU Delft students can use `--tudelft` to select the Brightspace URL and NetID login:
54
+
55
+ ```bash
56
+ npx -y brightspace-mcp-server@latest setup --tudelft
57
+ ```
58
+
59
+ Use your NetID rather than your student email address. The TU Delft flow is headless NetID username and password sign-in only, including automatic re-authentication when the saved session expires; it does not support MFA or any other interactive step. If your account requires one, [open an issue](https://github.com/RohanMuppa/brightspace-mcp-server/issues) — that tenant isn't supported yet.
60
+
53
61
  The wizard saves your password in the native credential store and asks how you complete MFA. Authentication can wait for approval or number matching, prompt in the terminal for a code from Google Authenticator or another app, or open a visible browser for other interactive methods. The wizard can configure Claude Desktop, Cursor, Codex Desktop and CLI, and Claude Code when they are installed. Restart your AI client when it finishes.
54
62
 
55
63
  Any other D2L school: run `setup` without a flag and paste your Brightspace URL (for example `https://yourschool.brightspace.com`).
@@ -127,5 +135,9 @@ The number in that response can go stale if the request times out or you tap Den
127
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 |
128
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 |
129
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
+
130
142
 
131
143
  Licensed under the MIT License.
@@ -6,6 +6,7 @@
6
6
  export { BrowserAuth } from "./browser-auth.js";
7
7
  export { PurdueSSOFlow } from "./purdue-sso.js";
8
8
  export { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
9
+ export { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
9
10
  export { createSSOFlow } from "./sso-flow.js";
10
11
  export { TokenManager } from "./token-manager.js";
11
12
  export { SessionStore } from "./session-store.js";
@@ -6,6 +6,7 @@
6
6
  import { PurdueSSOFlow } from "./purdue-sso.js";
7
7
  import { SunySSOFlow, isSunyBrightspace } from "./suny-sso.js";
8
8
  import { WesternSSOFlow, isWesternBrightspace } from "./western-sso.js";
9
+ import { TUDelftSSOFlow, isTUDelftBrightspace } from "./tudelft-sso.js";
9
10
  import { BrowserAuthError } from "../utils/errors.js";
10
11
  import { AUTH_COMMAND } from "../utils/commands.js";
11
12
  export class UnsupportedAuthenticationError extends BrowserAuthError {
@@ -44,6 +45,9 @@ export function createSSOFlow(config, requestMfaCode, onMfaChallenge) {
44
45
  requestMfaCode,
45
46
  onMfaChallenge,
46
47
  };
48
+ if (isTUDelftBrightspace(config.baseUrl)) {
49
+ return new TUDelftSSOFlow(credentials);
50
+ }
47
51
  if (isSunyBrightspace(config.baseUrl)) {
48
52
  return new SunySSOFlow({ ...credentials, campus: config.campus });
49
53
  }
@@ -0,0 +1,144 @@
1
+ /** TU Delft NetID login via SURFconext. Licensed under MIT; see LICENSE. */
2
+ import { UnsupportedAuthenticationError } from "./sso-flow.js";
3
+ import { BrowserAuthError } from "../utils/errors.js";
4
+ import { log } from "../utils/logger.js";
5
+ const BRIGHTSPACE_HOST = "brightspace.tudelft.nl";
6
+ const NETID_HOST = "login.tudelft.nl";
7
+ const SURFCONEXT_HOST = "engine.surfconext.nl";
8
+ const LOGIN_TIMEOUT_MS = 60_000;
9
+ // Give the login page time to settle after submission before trusting any
10
+ // alert banner as a rejection: a 1 second margin, or two polls, whichever a
11
+ // given test/runtime environment can observe.
12
+ const REJECTION_SETTLE_MS = 1_000;
13
+ const REJECTION_SETTLE_POLLS = 2;
14
+ const REJECTION_TEXT_PATTERN = /invalid|incorrect|rejected|wrong|failed|onjuist|ongeldig/i;
15
+ export function isTUDelftBrightspace(baseUrl) {
16
+ try {
17
+ return new URL(baseUrl).hostname.toLowerCase() === BRIGHTSPACE_HOST;
18
+ }
19
+ catch {
20
+ return false;
21
+ }
22
+ }
23
+ async function firstVisible(page, selectors) {
24
+ for (const selector of selectors) {
25
+ const locator = page.locator(selector).first();
26
+ if (await locator.isVisible().catch(() => false))
27
+ return locator;
28
+ }
29
+ return null;
30
+ }
31
+ function isNetID(url) {
32
+ return url.protocol === "https:" && url.hostname.toLowerCase() === NETID_HOST;
33
+ }
34
+ /**
35
+ * Only a specific error container counts as a rejection on its own. A bare
36
+ * `[role="alert"]` is also common for benign, standing banners (maintenance
37
+ * notices, cookie prompts) that have nothing to do with the credentials just
38
+ * submitted, so it only counts when its text reads like a rejection.
39
+ */
40
+ async function findRejectionAlert(page) {
41
+ const specific = await firstVisible(page, ['.form-error', '.login-error', '.alert-danger', '#error', '[role="alert"].error']);
42
+ if (specific)
43
+ return specific;
44
+ const generic = await firstVisible(page, ['[role="alert"]']);
45
+ if (generic) {
46
+ const text = (await generic.textContent().catch(() => null)) ?? "";
47
+ if (REJECTION_TEXT_PATTERN.test(text))
48
+ return generic;
49
+ }
50
+ return null;
51
+ }
52
+ /** NetID uses a simultaneous username/password form and normally requires no MFA. */
53
+ export class TUDelftSSOFlow {
54
+ config;
55
+ constructor(config) {
56
+ this.config = config;
57
+ }
58
+ hasCredentials() {
59
+ return Boolean(this.config.username && this.config.password);
60
+ }
61
+ async login(page) {
62
+ if (!this.hasCredentials())
63
+ return false;
64
+ log("INFO", "Starting TU Delft NetID sign-in");
65
+ let submitted = false;
66
+ let submittedAt = null;
67
+ let pollsSinceSubmit = 0;
68
+ let consentSubmitted = false;
69
+ const deadline = Date.now() + LOGIN_TIMEOUT_MS;
70
+ try {
71
+ do {
72
+ let current;
73
+ try {
74
+ current = new URL(page.url());
75
+ }
76
+ catch {
77
+ // Not a parseable URL (e.g. a transient navigation state). Give it
78
+ // another poll instead of treating it as a fatal unknown host.
79
+ await page.waitForTimeout(250);
80
+ continue;
81
+ }
82
+ if (current.protocol !== "https:" && current.protocol !== "http:") {
83
+ // about:blank and similar interstitial pages show up between
84
+ // navigations; they're not an identity-provider page to evaluate.
85
+ await page.waitForTimeout(250);
86
+ continue;
87
+ }
88
+ if (current.origin === `https://${BRIGHTSPACE_HOST}` && /^\/d2l\/home(?:\/|$)/.test(current.pathname)) {
89
+ return true;
90
+ }
91
+ if (isNetID(current)) {
92
+ if (!submitted) {
93
+ const username = await firstVisible(page, ['input#username', 'input[name="username"]', 'input[name="j_username"]']);
94
+ const password = await firstVisible(page, ['input#password', 'input[name="password"]', 'input[name="j_password"]']);
95
+ const submit = await firstVisible(page, ['#submit_button', 'button[name="_eventId_proceed"]', 'button[type="submit"]', 'input[type="submit"]']);
96
+ if (username && password && submit) {
97
+ await username.fill(this.config.username);
98
+ // Recheck after the account field: a navigation must never send
99
+ // the password to a different origin using a stale locator.
100
+ if (!isNetID(new URL(page.url()))) {
101
+ throw new UnsupportedAuthenticationError("TU Delft sign-in left the trusted NetID page before password entry.");
102
+ }
103
+ await password.fill(this.config.password);
104
+ await submit.click();
105
+ submitted = true;
106
+ submittedAt = Date.now();
107
+ }
108
+ }
109
+ else {
110
+ pollsSinceSubmit++;
111
+ const settled = pollsSinceSubmit >= REJECTION_SETTLE_POLLS
112
+ || (submittedAt !== null && Date.now() - submittedAt >= REJECTION_SETTLE_MS);
113
+ // Recheck the host: the alert lookup is async and must not act on
114
+ // a page that has since navigated away from NetID.
115
+ if (settled && isNetID(new URL(page.url())) && await findRejectionAlert(page)) {
116
+ throw new UnsupportedAuthenticationError("TU Delft NetID sign-in was rejected. Check the saved NetID and password.");
117
+ }
118
+ }
119
+ }
120
+ else if (current.protocol === "https:" && current.hostname.toLowerCase() === SURFCONEXT_HOST) {
121
+ // A user's first visit may require consent to share information with
122
+ // Brightspace. No credentials are entered on SURFconext itself.
123
+ if (!consentSubmitted) {
124
+ const consent = await firstVisible(page, ['#consent_accept', 'button[name="confirm"]', 'input[name="confirm"]', 'input[name="yes"]']);
125
+ if (consent) {
126
+ await consent.click();
127
+ consentSubmitted = true;
128
+ }
129
+ }
130
+ }
131
+ else if (current.origin !== `https://${BRIGHTSPACE_HOST}`) {
132
+ throw new UnsupportedAuthenticationError("TU Delft sign-in reached an unsupported identity-provider page. No credentials were entered there.");
133
+ }
134
+ await page.waitForTimeout(250);
135
+ } while (Date.now() < deadline);
136
+ throw new UnsupportedAuthenticationError("TU Delft NetID sign-in did not reach Brightspace within 60 seconds. Check the saved credentials or retry with D2L_HEADLESS=false for an interactive sign-in.");
137
+ }
138
+ catch (error) {
139
+ if (error instanceof BrowserAuthError)
140
+ throw error;
141
+ throw new UnsupportedAuthenticationError("TU Delft NetID sign-in could not complete automatically.", error);
142
+ }
143
+ }
144
+ }
package/build/setup.js CHANGED
@@ -24,6 +24,13 @@ const dim = (s) => `\x1b[2m${s}\x1b[0m`;
24
24
  const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
25
25
  const thisDir = path.dirname(fileURLToPath(import.meta.url));
26
26
  export const SCHOOL_PRESETS = {
27
+ tudelft: {
28
+ name: "TU Delft",
29
+ baseUrl: "https://brightspace.tudelft.nl",
30
+ usernameLabel: "TU Delft NetID",
31
+ mfaNote: "NetID sign-in normally runs headlessly without MFA.",
32
+ usernameHint: "Use your NetID, not your student email address.",
33
+ },
27
34
  purdue: {
28
35
  name: "Purdue University",
29
36
  baseUrl: "https://purdue.brightspace.com",
@@ -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.8",
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",