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 +14 -2
- package/build/auth/index.js +1 -0
- package/build/auth/sso-flow.js +4 -0
- package/build/auth/tudelft-sso.js +144 -0
- package/build/setup.js +7 -0
- package/build/tools/content-availability.js +50 -0
- package/build/tools/download-file.js +17 -3
- package/build/tools/get-course-content.js +25 -3
- package/build/tools/topic-availability.js +94 -0
- package/package.json +1 -1
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
|
|
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
|
|
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.
|
package/build/auth/index.js
CHANGED
|
@@ -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";
|
package/build/auth/sso-flow.js
CHANGED
|
@@ -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
|
-
//
|
|
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",
|