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
|
|
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.
|
|
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",
|