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 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.
@@ -78,16 +78,47 @@ export async function fetchAllObjects(apiClient, firstPath, options) {
78
78
  }
79
79
  return objects;
80
80
  }
81
- /** A Next value resolved to a request path. */
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 ? `![${alt}](${src}${titlePart})` : "";
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.13",
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",