brightspace-mcp-server 3.13.11 → 3.13.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.
@@ -10,6 +10,7 @@ import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable,
10
10
  import { log } from "../utils/logger.js";
11
11
  import { applyCourseFilter } from "../utils/course-filter.js";
12
12
  import { matchesModifiedSince } from "../utils/modified-since.js";
13
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
13
14
  function pageInfo(offset, count, returned, total, unavailableCourseIds = []) {
14
15
  const nextOffset = total !== null && offset + returned < total ? offset + returned : null;
15
16
  return { offset, count, returned, total, nextOffset, truncated: nextOffset !== null,
@@ -73,6 +74,20 @@ export async function fetchCourseNews(apiClient, courseId) {
73
74
  ttl: DEFAULT_CACHE_TTLS.announcements,
74
75
  });
75
76
  }
77
+ /**
78
+ * The announcement text a reader should see. D2L's RichText.Text is only "the
79
+ * plaintext form of the text": it carries no link targets, and the API makes
80
+ * no promise that it is present or equal to Html. The Html is the post as the
81
+ * instructor wrote it, so it leads (as markdown, keeping links, lists and
82
+ * tables); Text is the fallback for a text-only post or Html with nothing
83
+ * readable in it.
84
+ */
85
+ export function newsBodyText(body) {
86
+ if (!body)
87
+ return "";
88
+ const markdown = body.Html ? convertHtmlToMarkdown(body.Html).markdown.trim() : "";
89
+ return markdown || body.Text || "";
90
+ }
76
91
  /**
77
92
  * Map a raw D2L news item to a clean announcement object.
78
93
  */
@@ -85,7 +100,7 @@ export function mapNewsItem(item) {
85
100
  return {
86
101
  id: item.Id,
87
102
  title: item.Title,
88
- body: item.Body?.Text ?? "",
103
+ body: newsBodyText(item.Body),
89
104
  createdBy: item.CreatedBy?.DisplayName ?? "Unknown",
90
105
  date: effectiveDate(item),
91
106
  isPinned: item.IsPinned,
@@ -100,7 +115,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
100
115
  server.registerTool("get_announcements", {
101
116
  title: "Get Announcements",
102
117
  annotations: { readOnlyHint: true },
103
- description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
118
+ description: "Fetch announcements from one course with courseId, or all courses when omitted, newest first. count is 1-50 per page. Each body is the full announcement text as markdown (links kept); pagination.truncated only means more announcements remain, never that a body was cut. Continue with offset= pagination.nextOffset until it is null. Pagination metadata is in a separate JSON text block for array responses, or the pagination field with modifiedSince. A false coverageComplete means some courses were not fetched: retry from offset 0 after recovery. Offsets reflect the current filtered list; new posts can shift pages. Attachments are listed per announcement; fetch them with download_file (newsId + fileId) or read them with get_announcement_files.",
104
119
  inputSchema: GetAnnouncementsSchema,
105
120
  }, async (args) => {
106
121
  try {
@@ -101,6 +101,12 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
101
101
  continue;
102
102
  if (!folder.DueDate)
103
103
  continue;
104
+ // Check the window before resolving the link: a group folder's link
105
+ // costs group-membership requests, wasted on an item the handler's
106
+ // window filter would drop anyway (issue #230).
107
+ const due = new Date(folder.DueDate).getTime();
108
+ if (!Number.isFinite(due) || due < from || due > to)
109
+ continue;
104
110
  const link = await assignmentLink(folder);
105
111
  items.push({
106
112
  type: "assignment",
@@ -15,6 +15,15 @@ function getTurndownService() {
15
15
  turndownService = new TurndownService({
16
16
  headingStyle: "atx",
17
17
  codeBlockStyle: "fenced",
18
+ // Turndown routes any element with no text (an <object>, say) to the
19
+ // blank rule before the rules above are consulted, so an embedded
20
+ // object has to be recognised here to keep its address.
21
+ blankReplacement: (_content, node) => {
22
+ const el = node;
23
+ if (EMBEDDED_TAGS.includes(el.nodeName))
24
+ return embeddedLink(el);
25
+ return node.isBlock ? "\n\n" : "";
26
+ },
18
27
  });
19
28
  // Turndown has no rule for <script>, <style> or <noscript>, so it falls
20
29
  // back to emitting their text: a D2L description carrying an inline
@@ -81,6 +90,14 @@ function getTurndownService() {
81
90
  return `[${content}](${cleaned}${titlePart})`;
82
91
  },
83
92
  });
93
+ // Embedded frames and media carry no text, so Turndown dropped them
94
+ // whole: a post whose details sat in an embedded PDF or video read as if
95
+ // that part did not exist. Keep the address as a link so the model can
96
+ // see that something was embedded and where it lives.
97
+ turndownService.addRule("embeddedContent", {
98
+ filter: ["iframe", "object", "embed", "video", "audio"],
99
+ replacement: (_content, node) => embeddedLink(node),
100
+ });
84
101
  turndownService.addRule("imageSessionTokens", {
85
102
  filter: "img",
86
103
  replacement: (_content, node) => {
@@ -95,6 +112,16 @@ function getTurndownService() {
95
112
  }
96
113
  return turndownService;
97
114
  }
115
+ const EMBEDDED_TAGS = ["IFRAME", "OBJECT", "EMBED", "VIDEO", "AUDIO"];
116
+ /** A link to an embedded frame or media file; empty when it has no usable address. */
117
+ function embeddedLink(el) {
118
+ const raw = el.getAttribute("src") ?? el.getAttribute("data") ?? "";
119
+ if (!raw.trim() || isUnsafeHref(raw))
120
+ return "";
121
+ const label = (el.getAttribute("title") || el.getAttribute("aria-label") || "Embedded content")
122
+ .replace(/[\[\]]/g, "");
123
+ return `[${label}](${stripD2lSessionParams(raw).replace(/([()])/g, "\\$1")})`;
124
+ }
98
125
  /** True for `javascript:`/`data:` hrefs -- never a legitimate content link. */
99
126
  function isUnsafeHref(href) {
100
127
  return /^\s*(javascript|data):/i.test(href);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.13.11",
3
+ "version": "3.13.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",