brightspace-mcp-server 5.1.16 → 5.1.18

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/build/server.js CHANGED
@@ -91,7 +91,7 @@ export function createMcpServer(runtime) {
91
91
  registerGetRoster(server, apiClient);
92
92
  registerGetMyGroups(server, apiClient);
93
93
  registerGetSyllabus(server, apiClient);
94
- registerGetDiscussions(server, apiClient);
94
+ registerGetDiscussions(server, apiClient, config.baseUrl);
95
95
  registerGetVideoTranscript(server, apiClient);
96
96
  registerGetServerInfo(server, config, version, apiClient);
97
97
  registerSearchCourse(server, apiClient);
@@ -8,6 +8,7 @@ import { GetDiscussionsSchema } from "./schemas.js";
8
8
  import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
9
9
  import { convertHtmlToMarkdown } from "../utils/html-converter.js";
10
10
  import { log } from "../utils/logger.js";
11
+ import { discussionUrl } from "../utils/deep-links.js";
11
12
  /**
12
13
  * Fetch a course's discussion forums. Shared with search_course so both
13
14
  * tools hit the same endpoint (and cache entry) instead of duplicating it.
@@ -31,7 +32,7 @@ export async function fetchForumTopics(apiClient, courseId, forumId) {
31
32
  /**
32
33
  * Register get_discussions tool
33
34
  */
34
- export function registerGetDiscussions(server, apiClient) {
35
+ export function registerGetDiscussions(server, apiClient, baseUrl) {
35
36
  server.registerTool("get_discussions", {
36
37
  title: "Get Discussions",
37
38
  annotations: { readOnlyHint: true },
@@ -47,7 +48,7 @@ export function registerGetDiscussions(server, apiClient) {
47
48
  }
48
49
  // Specific topic — get posts
49
50
  if (forumId !== undefined && topicId !== undefined) {
50
- return await getTopicPosts(apiClient, courseId, forumId, topicId);
51
+ return await getTopicPosts(apiClient, courseId, forumId, topicId, baseUrl);
51
52
  }
52
53
  // Specific forum — get its topics + posts
53
54
  if (forumId !== undefined) {
@@ -169,40 +170,82 @@ async function getForumDetail(apiClient, courseId, forumId) {
169
170
  });
170
171
  }
171
172
  /**
172
- * Get all posts for a specific topic.
173
+ * Fetch one topic's metadata. The forum's topic list comes first: it is the
174
+ * route get_discussions already lists topics from (and caches), and some
175
+ * tenants refuse a student the per-topic route for a topic that list shows
176
+ * (#364). The per-topic route is the fallback for a topic the list omits or
177
+ * a list the tenant refuses.
173
178
  */
174
- async function getTopicPosts(apiClient, courseId, forumId, topicId) {
175
- // Fetch topic info
179
+ async function fetchTopic(apiClient, courseId, forumId, topicId) {
180
+ try {
181
+ const listed = (await fetchForumTopics(apiClient, courseId, forumId)).find((t) => t.TopicId === topicId);
182
+ if (listed)
183
+ return listed;
184
+ }
185
+ catch (error) {
186
+ log("DEBUG", `Topic list for forum ${forumId} unavailable, trying topic ${topicId} directly`, error);
187
+ }
176
188
  const topicPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topicId}`);
177
- const topic = await apiClient.get(topicPath, {
189
+ return apiClient.get(topicPath, {
178
190
  ttl: DEFAULT_CACHE_TTLS.courseContent,
179
191
  });
180
- // Fetch posts
192
+ }
193
+ /**
194
+ * Get all posts for a specific topic. A posts read the tenant refuses (403)
195
+ * still returns the topic, with posts null and postsUnavailable naming the
196
+ * failed step, so a permission restriction never reads as "no posts".
197
+ */
198
+ async function getTopicPosts(apiClient, courseId, forumId, topicId, baseUrl) {
199
+ const topic = await fetchTopic(apiClient, courseId, forumId, topicId);
181
200
  const postsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topicId}/posts/`);
182
- const posts = await apiClient.get(postsPath, {
183
- ttl: DEFAULT_CACHE_TTLS.announcements,
184
- });
201
+ let posts;
202
+ try {
203
+ posts = await apiClient.get(postsPath, {
204
+ ttl: DEFAULT_CACHE_TTLS.announcements,
205
+ });
206
+ }
207
+ catch (error) {
208
+ if (error?.status !== 403)
209
+ throw error;
210
+ log("INFO", `get_discussions: Posts for topic ${topicId} in forum ${forumId} refused (403)`);
211
+ return toolResponse({
212
+ courseId,
213
+ forumId,
214
+ topic: formatTopic(topic),
215
+ postCount: null,
216
+ posts: null,
217
+ postsUnavailable: {
218
+ operation: "listPosts",
219
+ status: 403,
220
+ reason: "Brightspace refused this account's request for the topic's posts. The posts may exist; open the topic in a browser to see what this account can read.",
221
+ url: baseUrl ? discussionUrl(baseUrl, courseId, topicId) : null,
222
+ },
223
+ });
224
+ }
185
225
  // Count what is returned: formatPosts drops deleted posts.
186
226
  const formatted = formatPosts(posts);
187
227
  log("INFO", `get_discussions: Retrieved ${formatted.length} posts for topic ${topicId} in forum ${forumId}`);
188
228
  return toolResponse({
189
229
  courseId,
190
230
  forumId,
191
- topic: {
192
- topicId: topic.TopicId,
193
- name: topic.Name,
194
- description: topic.Description?.Html
195
- ? convertHtmlToMarkdown(topic.Description.Html).markdown
196
- : topic.Description?.Text ?? null,
197
- dueDate: topic.DueDate,
198
- isLocked: topic.IsLocked,
199
- mustPostToParticipate: topic.MustPostToParticipate,
200
- scoreOutOf: topic.ScoreOutOf,
201
- },
231
+ topic: formatTopic(topic),
202
232
  postCount: formatted.length,
203
233
  posts: formatted,
204
234
  });
205
235
  }
236
+ function formatTopic(topic) {
237
+ return {
238
+ topicId: topic.TopicId,
239
+ name: topic.Name,
240
+ description: topic.Description?.Html
241
+ ? convertHtmlToMarkdown(topic.Description.Html).markdown
242
+ : topic.Description?.Text ?? null,
243
+ dueDate: topic.DueDate,
244
+ isLocked: topic.IsLocked,
245
+ mustPostToParticipate: topic.MustPostToParticipate,
246
+ scoreOutOf: topic.ScoreOutOf,
247
+ };
248
+ }
206
249
  /**
207
250
  * Format posts into a clean thread structure.
208
251
  */
@@ -6,16 +6,11 @@
6
6
  /**
7
7
  * Deep links into the Brightspace web UI.
8
8
  *
9
- * The dropbox, quiz and gradebook templates were harvested from live
10
- * Brightspace markup: they are the same URLs the course pages themselves link
11
- * to, so they open the item directly.
12
- *
13
- * `discussionUrl` was NOT -- it was written from the shape of the others
14
- * without live markup to check it against, and no one has confirmed it against
15
- * a real tenant since. Treat it as unverified: if a student reports that a
16
- * discussion link lands on the wrong page or a "not found", harvest the real
17
- * URL from a course's own discussion list and fix the template here rather
18
- * than assuming this one is right.
9
+ * Every template here was harvested from live Brightspace markup: they are
10
+ * the same URLs the course pages themselves link to, so they open the item
11
+ * directly. The discussion template was first written from the shape of the
12
+ * others and answered 404 on a real tenant; it now uses the link the course's
13
+ * own Discussions List page gives each topic.
19
14
  */
20
15
  /** Drop trailing slashes so the templates below join cleanly. */
21
16
  function trimBaseUrl(baseUrl) {
@@ -40,15 +35,9 @@ export function contentTopicUrl(baseUrl, courseId, topicId) {
40
35
  export function gradebookUrl(baseUrl, courseId) {
41
36
  return `${trimBaseUrl(baseUrl)}/d2l/lms/grades/my_grades/main.d2l?ou=${courseId}`;
42
37
  }
43
- /**
44
- * Link to the thread list of a discussion topic.
45
- *
46
- * Unverified against a live tenant -- see the note at the top of this file.
47
- * The topic's forum id is available at every call site, so if the real
48
- * template turns out to need one, it can be threaded through.
49
- */
38
+ /** Link to a discussion topic's page, which lists its threads. */
50
39
  export function discussionUrl(baseUrl, courseId, topicId) {
51
- return `${trimBaseUrl(baseUrl)}/d2l/lms/discussions/threadlist.d2l?ou=${courseId}&tId=${topicId}`;
40
+ return `${trimBaseUrl(baseUrl)}/d2l/le/${courseId}/discussions/topics/${topicId}/View`;
52
41
  }
53
42
  /**
54
43
  * Link to a course's calendar. Calendar events have no per-event student page
@@ -7,15 +7,20 @@
7
7
  function codePoint(whole, code) {
8
8
  return code <= 0x10ffff ? String.fromCodePoint(code) : whole;
9
9
  }
10
- /** Decode the character references Brightspace's HTML editor writes into attribute values. */
10
+ const NAMED = { quot: '"', lt: "<", gt: ">", amp: "&" };
11
+ /**
12
+ * Decode the character references Brightspace's HTML editor writes into
13
+ * attribute values, in one pass so a decoded `&` never starts another
14
+ * reference: `&#38;lt;` is the text `&lt;`, not `<` (issue #360).
15
+ */
11
16
  export function decodeEntities(value) {
12
- return value
13
- .replace(/&#x([0-9a-f]+);/gi, (whole, hex) => codePoint(whole, parseInt(hex, 16)))
14
- .replace(/&#(\d+);/g, (whole, dec) => codePoint(whole, parseInt(dec, 10)))
15
- .replace(/&quot;/g, '"')
16
- .replace(/&lt;/g, "<")
17
- .replace(/&gt;/g, ">")
18
- .replace(/&amp;/g, "&");
17
+ return value.replace(/&(?:#[xX]([0-9a-fA-F]+)|#(\d+)|(quot|lt|gt|amp));/g, (whole, hex, dec, name) => {
18
+ if (hex !== undefined)
19
+ return codePoint(whole, parseInt(hex, 16));
20
+ if (dec !== undefined)
21
+ return codePoint(whole, parseInt(dec, 10));
22
+ return NAMED[name];
23
+ });
19
24
  }
20
25
  /**
21
26
  * The attributes of every `<tag>` element in `html`, names lowercased and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "5.1.16",
3
+ "version": "5.1.18",
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",