brightspace-mcp-server 3.8.13 → 3.8.14
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 +1 -0
- package/build/api/paginate.js +32 -1
- package/build/utils/html-converter.js +61 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -151,5 +151,6 @@ Run it from your home folder — macOS blocks `npx` from Documents, Desktop, or
|
|
|
151
151
|
|
|
152
152
|
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.
|
|
153
153
|
|
|
154
|
+
When course content or announcements are converted to markdown, `javascript:`/`data:` links are rendered as plain text (the link itself is dropped, not followed) and D2L's per-session query parameters (`d2lSessionVal`, `d2lSecureSessionVal`, and the cache-busting `_`) are stripped from any remaining links and images before they reach the assistant.
|
|
154
155
|
|
|
155
156
|
Licensed under the MIT License.
|
package/build/api/paginate.js
CHANGED
|
@@ -78,16 +78,47 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
|
|
|
78
78
|
}
|
|
79
79
|
return objects;
|
|
80
80
|
}
|
|
81
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* A Next value resolved to a request path, or null when it should not be
|
|
83
|
+
* followed.
|
|
84
|
+
*
|
|
85
|
+
* Adapted from JhostinAleck/brightspace-mcp (MIT): beyond dropping an
|
|
86
|
+
* absolute Next's host (the client always prefixes its own configured base
|
|
87
|
+
* URL, so a hostile/garbled Next cannot redirect requests elsewhere), anything
|
|
88
|
+
* -- absolute or server-relative -- whose path falls outside `/d2l/api/` is
|
|
89
|
+
* rejected rather than followed, since that is the only space these paginated
|
|
90
|
+
* endpoints legitimately point back into.
|
|
91
|
+
*/
|
|
82
92
|
function nextPath(firstPath, next) {
|
|
83
93
|
if (/^https?:\/\//i.test(next)) {
|
|
84
94
|
const url = new URL(next);
|
|
95
|
+
if (!isApiPath(url.pathname)) {
|
|
96
|
+
log("INFO", "Pagination stopped: Next link left the D2L API", {
|
|
97
|
+
firstPath,
|
|
98
|
+
nextPath: url.pathname,
|
|
99
|
+
});
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
85
102
|
return `${url.pathname}${url.search}`;
|
|
86
103
|
}
|
|
87
104
|
// A Next that begins with a slash is already a path, not a bookmark.
|
|
88
105
|
// Appending it as one would ask for a page that does not exist.
|
|
89
106
|
if (next.startsWith("/")) {
|
|
107
|
+
const nextPathOnly = next.split("?")[0] ?? next;
|
|
108
|
+
if (!isApiPath(nextPathOnly)) {
|
|
109
|
+
log("INFO", "Pagination stopped: Next link left the D2L API", {
|
|
110
|
+
firstPath,
|
|
111
|
+
// Log only the path, not the query string: a rejected server-relative
|
|
112
|
+
// Next can carry a `d2lSessionVal=` token in its query.
|
|
113
|
+
nextPath: nextPathOnly,
|
|
114
|
+
});
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
90
117
|
return next;
|
|
91
118
|
}
|
|
92
119
|
return withBookmark(firstPath, next);
|
|
93
120
|
}
|
|
121
|
+
/** Whether a Next path stays inside the D2L API namespace these endpoints page through. */
|
|
122
|
+
function isApiPath(pathname) {
|
|
123
|
+
return pathname.startsWith("/d2l/api/");
|
|
124
|
+
}
|
|
@@ -60,9 +60,70 @@ function getTurndownService() {
|
|
|
60
60
|
return body ? `\n\n${body}\n\n` : "";
|
|
61
61
|
},
|
|
62
62
|
});
|
|
63
|
+
// D2L appends per-session query params (d2lSessionVal, d2lSecureSessionVal,
|
|
64
|
+
// a `_` cache-buster) to in-content hrefs. Left alone, the markdown we hand
|
|
65
|
+
// the model echoes a live session token into the chat transcript. These
|
|
66
|
+
// two rules are unshifted ahead of Turndown's built-in link/image rules
|
|
67
|
+
// (addRule always inserts at the front of the rule list), so they run
|
|
68
|
+
// first and the defaults never see the raw attribute.
|
|
69
|
+
turndownService.addRule("linkSessionTokens", {
|
|
70
|
+
filter: (node) => node.nodeName === "A" && !!node.getAttribute("href"),
|
|
71
|
+
replacement: (content, node) => {
|
|
72
|
+
const href = node.getAttribute("href") ?? "";
|
|
73
|
+
// javascript:/data: hrefs aren't content links; drop the link but
|
|
74
|
+
// keep whatever text was inside it.
|
|
75
|
+
if (isUnsafeHref(href))
|
|
76
|
+
return content;
|
|
77
|
+
const cleaned = stripSessionParams(href).replace(/([()])/g, "\\$1");
|
|
78
|
+
const title = node.getAttribute("title");
|
|
79
|
+
const titlePart = title ? ` "${title.replace(/"/g, '\\"')}"` : "";
|
|
80
|
+
return `[${content}](${cleaned}${titlePart})`;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
turndownService.addRule("imageSessionTokens", {
|
|
84
|
+
filter: "img",
|
|
85
|
+
replacement: (_content, node) => {
|
|
86
|
+
const el = node;
|
|
87
|
+
const alt = el.getAttribute("alt") ?? "";
|
|
88
|
+
const src = stripSessionParams(el.getAttribute("src") ?? "");
|
|
89
|
+
const title = el.getAttribute("title");
|
|
90
|
+
const titlePart = title ? ` "${title}"` : "";
|
|
91
|
+
return src ? `` : "";
|
|
92
|
+
},
|
|
93
|
+
});
|
|
63
94
|
}
|
|
64
95
|
return turndownService;
|
|
65
96
|
}
|
|
97
|
+
const SESSION_QUERY_PARAMS = new Set(["d2lsessionval", "d2lsecuresessionval", "_"]);
|
|
98
|
+
/**
|
|
99
|
+
* Strip D2L's per-session query params (`d2lSessionVal`, `d2lSecureSessionVal`,
|
|
100
|
+
* and the `_` cache-buster) from a URL. Everything else about the URL --
|
|
101
|
+
* scheme, path, other params -- is left untouched, including a relative href,
|
|
102
|
+
* which is returned as-is apart from this filtering.
|
|
103
|
+
*/
|
|
104
|
+
function stripSessionParams(url) {
|
|
105
|
+
const queryStart = url.indexOf("?");
|
|
106
|
+
if (queryStart < 0)
|
|
107
|
+
return url;
|
|
108
|
+
const earlyHash = url.indexOf("#");
|
|
109
|
+
if (earlyHash >= 0 && earlyHash < queryStart) {
|
|
110
|
+
// The "?" falls inside the fragment (e.g. "#frag?x=1"), not a real query
|
|
111
|
+
// string -- nothing to strip, and treating it as one would mangle the
|
|
112
|
+
// fragment.
|
|
113
|
+
return url;
|
|
114
|
+
}
|
|
115
|
+
const hashStart = url.indexOf("#", queryStart);
|
|
116
|
+
const query = url.slice(queryStart + 1, hashStart < 0 ? undefined : hashStart);
|
|
117
|
+
const hash = hashStart < 0 ? "" : url.slice(hashStart);
|
|
118
|
+
const kept = query
|
|
119
|
+
.split("&")
|
|
120
|
+
.filter((param) => param !== "" && !SESSION_QUERY_PARAMS.has(param.split("=")[0].toLowerCase()));
|
|
121
|
+
return url.slice(0, queryStart) + (kept.length > 0 ? `?${kept.join("&")}` : "") + hash;
|
|
122
|
+
}
|
|
123
|
+
/** True for `javascript:`/`data:` hrefs -- never a legitimate content link. */
|
|
124
|
+
function isUnsafeHref(href) {
|
|
125
|
+
return /^\s*(javascript|data):/i.test(href);
|
|
126
|
+
}
|
|
66
127
|
/** A cell's text, flattened so one table cell stays one table cell. */
|
|
67
128
|
function cellText(content) {
|
|
68
129
|
return content
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.14",
|
|
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",
|