brightspace-mcp-server 3.9.6 → 3.9.7
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/index.js +4 -3
- package/build/tools/get-announcements.js +11 -8
- package/build/tools/get-course-content.js +25 -9
- package/build/tools/get-discussions.js +22 -8
- package/build/tools/index.js +1 -0
- package/build/tools/schemas.js +8 -0
- package/build/tools/search-course.js +257 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -175,6 +175,7 @@ If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.j
|
|
|
175
175
|
| Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
|
|
176
176
|
| Groups | "Who is in my project group?" · "List my lab group members for this course" |
|
|
177
177
|
| Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
|
|
178
|
+
| Search | "Search this course for the midterm review slides" · "Has anyone posted about office hours?" — searches content, announcements, and discussions by keyword in one call instead of reading the whole content tree |
|
|
178
179
|
| Video transcripts | "What did the professor say about pinch-off in Tuesday's lecture recording?" · "Summarize last week's BoilerCast video" — works for Kaltura and YouTube embeds; other platforms report that they aren't supported yet |
|
|
179
180
|
| Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" · "What timezone are my due dates being computed in?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, whether a credential is stored, the server's local timezone and UTC offset (`localTimezone`, `utcOffsetMinutes`), who the session is signed in as when known (`signedInAs: { uniqueName, displayName }`), what Microsoft remembered (`microsoftSession`: stay-signed-in and its expiry, plus whether "Don't ask again" was ticked, already on, not offered, or left off because `D2L_REMEMBER_MFA` isn't set), and `requests` (lightweight counters: responses by status class, network errors, cache hits/misses, coalesced in-flight joins, and token refreshes), without contacting Brightspace or revealing secrets |
|
|
180
181
|
| Calendar | "When is my midterm?" · "What's on my calendar this week?" · "Is lab cancelled on Thursday?" — reads exams, labs, review sessions, and deadlines instructors put only on the course calendar |
|
package/build/index.js
CHANGED
|
@@ -16,7 +16,7 @@ import { startUpdateChecks } from "./utils/update-checker.js";
|
|
|
16
16
|
import { readFileSync } from "node:fs";
|
|
17
17
|
import { fileURLToPath } from "node:url";
|
|
18
18
|
import { dirname, resolve } from "node:path";
|
|
19
|
-
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
|
|
19
|
+
import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, } from "./tools/index.js";
|
|
20
20
|
import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
|
|
21
21
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
22
|
const __dirname = dirname(__filename);
|
|
@@ -134,7 +134,8 @@ else {
|
|
|
134
134
|
registerGetDiscussions(server, apiClient);
|
|
135
135
|
registerGetVideoTranscript(server, apiClient);
|
|
136
136
|
registerGetServerInfo(server, config, PKG_VERSION, apiClient);
|
|
137
|
-
|
|
137
|
+
registerSearchCourse(server, apiClient);
|
|
138
|
+
log("DEBUG", "MCP tools registered (19 tools)");
|
|
138
139
|
// Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
|
|
139
140
|
// picker, distinct from tools. Each one is a canned user message that
|
|
140
141
|
// names our own tools by name, so a client with no idea what to ask
|
|
@@ -147,7 +148,7 @@ else {
|
|
|
147
148
|
// Connect stdio transport
|
|
148
149
|
const transport = new StdioServerTransport();
|
|
149
150
|
await server.connect(transport);
|
|
150
|
-
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (
|
|
151
|
+
log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (19 tools, 4 prompts registered)");
|
|
151
152
|
log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
|
|
152
153
|
}
|
|
153
154
|
catch (error) {
|
|
@@ -52,6 +52,15 @@ export function newestFirst(a, b) {
|
|
|
52
52
|
return -1;
|
|
53
53
|
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
|
54
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Fetch the raw news items for a course. Shared with search_course so both
|
|
57
|
+
* tools hit the same endpoint (and cache entry) instead of duplicating it.
|
|
58
|
+
*/
|
|
59
|
+
export async function fetchCourseNews(apiClient, courseId) {
|
|
60
|
+
return apiClient.get(apiClient.le(courseId, "/news/"), {
|
|
61
|
+
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
55
64
|
/**
|
|
56
65
|
* Map a raw D2L news item to a clean announcement object.
|
|
57
66
|
*/
|
|
@@ -88,10 +97,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
88
97
|
const cutoff = modifiedSince ? new Date(modifiedSince) : null;
|
|
89
98
|
// Single course case
|
|
90
99
|
if (courseId) {
|
|
91
|
-
const
|
|
92
|
-
const newsItems = await apiClient.get(path, {
|
|
93
|
-
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
94
|
-
});
|
|
100
|
+
const newsItems = await fetchCourseNews(apiClient, courseId);
|
|
95
101
|
// Drop drafts, then map to clean objects
|
|
96
102
|
const published = newsItems.filter(isPublishedNewsItem).map(mapNewsItem);
|
|
97
103
|
const matched = cutoff
|
|
@@ -129,10 +135,7 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
129
135
|
// Fetch announcements for each course (handle 403s gracefully)
|
|
130
136
|
const announcementPromises = filteredEnrollments.map(async (item) => {
|
|
131
137
|
try {
|
|
132
|
-
const
|
|
133
|
-
const newsItems = await apiClient.get(path, {
|
|
134
|
-
ttl: DEFAULT_CACHE_TTLS.announcements,
|
|
135
|
-
});
|
|
138
|
+
const newsItems = await fetchCourseNews(apiClient, item.OrgUnit.Id);
|
|
136
139
|
return newsItems
|
|
137
140
|
.filter(isPublishedNewsItem)
|
|
138
141
|
.map((newsItem) => ({
|
|
@@ -34,6 +34,28 @@ function matchesTypeFilter(item, filter) {
|
|
|
34
34
|
return true;
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Fetch the root content modules for a course. Shared with search_course so
|
|
39
|
+
* both tools walk the exact same tree (and share its cache entry) instead of
|
|
40
|
+
* each hand-rolling the `/content/root/` call.
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchRootContent(apiClient, courseId) {
|
|
43
|
+
return apiClient.get(apiClient.le(courseId, '/content/root/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Fetch a single module's structure, or null when the dedicated endpoint
|
|
47
|
+
* fails (locked module, transient error) so the caller can fall back to
|
|
48
|
+
* whatever structure the parent listing already embedded.
|
|
49
|
+
*/
|
|
50
|
+
export async function fetchModuleStructure(apiClient, courseId, moduleId) {
|
|
51
|
+
try {
|
|
52
|
+
return await apiClient.get(apiClient.le(courseId, `/content/modules/${moduleId}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
53
|
+
}
|
|
54
|
+
catch (e) {
|
|
55
|
+
log('DEBUG', `Failed to fetch children for module ${moduleId}: falling back to the embedded structure`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
37
59
|
/**
|
|
38
60
|
* How deep the tree builder will descend when the caller names no maxDepth.
|
|
39
61
|
* A module structure that lists itself is a cycle, and without a ceiling the
|
|
@@ -61,7 +83,7 @@ function availabilityFields(availability) {
|
|
|
61
83
|
/**
|
|
62
84
|
* Recursively build the content tree with progress tracking.
|
|
63
85
|
*/
|
|
64
|
-
async function buildContentTree(apiClient, courseId, modules, progressMap, typeFilter, maxDepth, currentDepth = 0, parentAvailability, now = new Date()) {
|
|
86
|
+
export async function buildContentTree(apiClient, courseId, modules, progressMap, typeFilter, maxDepth, currentDepth = 0, parentAvailability, now = new Date()) {
|
|
65
87
|
const tree = [];
|
|
66
88
|
const depthLimit = Math.min(maxDepth ?? MAX_CONTENT_DEPTH, MAX_CONTENT_DEPTH);
|
|
67
89
|
for (const item of modules) {
|
|
@@ -77,13 +99,7 @@ async function buildContentTree(apiClient, courseId, modules, progressMap, typeF
|
|
|
77
99
|
// what the parent already handed us. Falling through to an empty list
|
|
78
100
|
// made a locked or erroring module look like an empty one, and under a
|
|
79
101
|
// typeFilter it dropped the module from the tree with no trace.
|
|
80
|
-
let children =
|
|
81
|
-
try {
|
|
82
|
-
children = await apiClient.get(apiClient.le(courseId, `/content/modules/${item.Id}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
|
|
83
|
-
}
|
|
84
|
-
catch (e) {
|
|
85
|
-
log('DEBUG', `Failed to fetch children for module ${item.Id}: falling back to the embedded structure`);
|
|
86
|
-
}
|
|
102
|
+
let children = await fetchModuleStructure(apiClient, courseId, item.Id);
|
|
87
103
|
if (!Array.isArray(children)) {
|
|
88
104
|
children = Array.isArray(item.Structure) ? item.Structure : [];
|
|
89
105
|
}
|
|
@@ -218,7 +234,7 @@ export function registerGetCourseContent(server, apiClient) {
|
|
|
218
234
|
// Parse and validate input
|
|
219
235
|
const { courseId, typeFilter = 'all', moduleTitle, maxDepth, modifiedSince } = GetCourseContentSchema.parse(args);
|
|
220
236
|
// Fetch root modules
|
|
221
|
-
let rootModules = await
|
|
237
|
+
let rootModules = await fetchRootContent(apiClient, courseId);
|
|
222
238
|
// Filter root modules by title if specified
|
|
223
239
|
if (moduleTitle) {
|
|
224
240
|
const searchTerm = moduleTitle.toLowerCase();
|
|
@@ -8,6 +8,26 @@ 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
|
+
/**
|
|
12
|
+
* Fetch a course's discussion forums. Shared with search_course so both
|
|
13
|
+
* tools hit the same endpoint (and cache entry) instead of duplicating it.
|
|
14
|
+
*/
|
|
15
|
+
export async function fetchForums(apiClient, courseId) {
|
|
16
|
+
const forumsPath = apiClient.le(courseId, "/discussions/forums/");
|
|
17
|
+
return apiClient.get(forumsPath, {
|
|
18
|
+
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the topics for a single forum. Shared with search_course, same
|
|
23
|
+
* reasoning as fetchForums above.
|
|
24
|
+
*/
|
|
25
|
+
export async function fetchForumTopics(apiClient, courseId, forumId) {
|
|
26
|
+
const topicsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/`);
|
|
27
|
+
return apiClient.get(topicsPath, {
|
|
28
|
+
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
11
31
|
/**
|
|
12
32
|
* Register get_discussions tool
|
|
13
33
|
*/
|
|
@@ -44,19 +64,13 @@ export function registerGetDiscussions(server, apiClient) {
|
|
|
44
64
|
* Get all forums for a course with their topics (no posts).
|
|
45
65
|
*/
|
|
46
66
|
async function getForumsOverview(apiClient, courseId) {
|
|
47
|
-
const
|
|
48
|
-
const forums = await apiClient.get(forumsPath, {
|
|
49
|
-
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
50
|
-
});
|
|
67
|
+
const forums = await fetchForums(apiClient, courseId);
|
|
51
68
|
const result = [];
|
|
52
69
|
for (const forum of forums) {
|
|
53
70
|
// Fetch topics for each forum
|
|
54
71
|
let topics = [];
|
|
55
72
|
try {
|
|
56
|
-
|
|
57
|
-
topics = await apiClient.get(topicsPath, {
|
|
58
|
-
ttl: DEFAULT_CACHE_TTLS.courseContent,
|
|
59
|
-
});
|
|
73
|
+
topics = await fetchForumTopics(apiClient, courseId, forum.ForumId);
|
|
60
74
|
}
|
|
61
75
|
catch (error) {
|
|
62
76
|
if (error?.status === 403) {
|
package/build/tools/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export { registerGetSyllabus } from "./get-syllabus.js";
|
|
|
22
22
|
export { registerGetDiscussions } from "./get-discussions.js";
|
|
23
23
|
export { registerGetVideoTranscript } from "./get-video-transcript.js";
|
|
24
24
|
export { registerGetServerInfo } from "./get-server-info.js";
|
|
25
|
+
export { registerSearchCourse } from "./search-course.js";
|
|
25
26
|
// Re-export shared helpers and schemas for convenience
|
|
26
27
|
export { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
|
|
27
28
|
export * from "./schemas.js";
|
package/build/tools/schemas.js
CHANGED
|
@@ -137,6 +137,14 @@ export const GetRosterSchema = z.object({
|
|
|
137
137
|
limit: z.coerce.number().int().positive().max(1000).default(100)
|
|
138
138
|
.describe("Maximum users to return. Default 100. The response reports the true total and whether it was truncated."),
|
|
139
139
|
});
|
|
140
|
+
export const SearchCourseSchema = z.object({
|
|
141
|
+
courseId: z.coerce.number().int().positive()
|
|
142
|
+
.describe("Course ID to search within."),
|
|
143
|
+
query: z.string().min(2).max(200)
|
|
144
|
+
.describe("Keyword(s) to search for, e.g. 'midterm review slides' or 'office hours'."),
|
|
145
|
+
limit: z.coerce.number().int().positive().max(50).default(10)
|
|
146
|
+
.describe("Maximum number of results to return, highest scoring first."),
|
|
147
|
+
});
|
|
140
148
|
export const GetAssignmentRubricSchema = z.object({
|
|
141
149
|
courseId: z.coerce.number().int().positive()
|
|
142
150
|
.describe("Course ID the assignment belongs to."),
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Purdue Brightspace MCP Server
|
|
3
|
+
* Copyright (c) 2026 Rohan Muppa. All rights reserved.
|
|
4
|
+
* Licensed under MIT — see LICENSE file for details.
|
|
5
|
+
*/
|
|
6
|
+
import { ApiError } from "../api/index.js";
|
|
7
|
+
import { SearchCourseSchema } from "./schemas.js";
|
|
8
|
+
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
|
+
import { log } from "../utils/logger.js";
|
|
10
|
+
import { fetchRootContent, buildContentTree } from "./get-course-content.js";
|
|
11
|
+
import { fetchCourseNews, isPublishedNewsItem, mapNewsItem } from "./get-announcements.js";
|
|
12
|
+
import { fetchForums, fetchForumTopics } from "./get-discussions.js";
|
|
13
|
+
/** A query term matched in the title outranks the same term only in the body. */
|
|
14
|
+
const TITLE_WEIGHT = 10;
|
|
15
|
+
const BODY_WEIGHT = 1;
|
|
16
|
+
/**
|
|
17
|
+
* Large enough that an entry matching every query term always outranks one
|
|
18
|
+
* matching only some of them, regardless of how those partial matches are
|
|
19
|
+
* distributed between title and body.
|
|
20
|
+
*/
|
|
21
|
+
const ALL_TERMS_BONUS = 1000;
|
|
22
|
+
/**
|
|
23
|
+
* Lowercase, split on anything that isn't a Unicode letter or digit (so
|
|
24
|
+
* accented and non-Latin query terms tokenize the same as their matches
|
|
25
|
+
* instead of being shredded at every diacritic), and drop both empty and
|
|
26
|
+
* single-character residue left over from the split.
|
|
27
|
+
*/
|
|
28
|
+
export function tokenize(text) {
|
|
29
|
+
return text
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
32
|
+
.filter((term) => term.length > 1);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Score one entry against the already-tokenized query. A term counts once,
|
|
36
|
+
* against whichever field it was found in first (title preferred), so a term
|
|
37
|
+
* present in both title and body is not double-counted into a false "both
|
|
38
|
+
* fields matched" signal.
|
|
39
|
+
*/
|
|
40
|
+
export function scoreEntry(entry, queryTerms) {
|
|
41
|
+
if (queryTerms.length === 0)
|
|
42
|
+
return 0;
|
|
43
|
+
const titleTokens = tokenize(entry.title);
|
|
44
|
+
const bodyTokens = tokenize(entry.body);
|
|
45
|
+
let score = 0;
|
|
46
|
+
let matchedTerms = 0;
|
|
47
|
+
for (const term of queryTerms) {
|
|
48
|
+
if (titleTokens.some((t) => t.includes(term))) {
|
|
49
|
+
score += TITLE_WEIGHT;
|
|
50
|
+
matchedTerms++;
|
|
51
|
+
}
|
|
52
|
+
else if (bodyTokens.some((t) => t.includes(term))) {
|
|
53
|
+
score += BODY_WEIGHT;
|
|
54
|
+
matchedTerms++;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (matchedTerms === 0)
|
|
58
|
+
return 0;
|
|
59
|
+
if (matchedTerms === queryTerms.length)
|
|
60
|
+
score += ALL_TERMS_BONUS;
|
|
61
|
+
return score;
|
|
62
|
+
}
|
|
63
|
+
/** First case-insensitive occurrence of any query term in text, or -1. */
|
|
64
|
+
function firstMatchIndex(text, queryTerms) {
|
|
65
|
+
const lower = text.toLowerCase();
|
|
66
|
+
let first = -1;
|
|
67
|
+
for (const term of queryTerms) {
|
|
68
|
+
const idx = lower.indexOf(term);
|
|
69
|
+
if (idx >= 0 && (first === -1 || idx < first))
|
|
70
|
+
first = idx;
|
|
71
|
+
}
|
|
72
|
+
return first;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* A window of at most maxLen characters around matchIndex, with an ellipsis
|
|
76
|
+
* on whichever side(s) were cut — the ellipsis itself counts against maxLen
|
|
77
|
+
* so the result never exceeds it.
|
|
78
|
+
*/
|
|
79
|
+
export function buildSnippet(text, matchIndex, maxLen = 200) {
|
|
80
|
+
if (!text)
|
|
81
|
+
return "";
|
|
82
|
+
if (matchIndex < 0 || text.length <= maxLen) {
|
|
83
|
+
return text.length <= maxLen ? text : text.slice(0, maxLen - 1).trimEnd() + "…";
|
|
84
|
+
}
|
|
85
|
+
const half = Math.floor(maxLen / 2);
|
|
86
|
+
let start = Math.max(0, matchIndex - half);
|
|
87
|
+
let end = start + maxLen;
|
|
88
|
+
if (end > text.length) {
|
|
89
|
+
end = text.length;
|
|
90
|
+
start = Math.max(0, end - maxLen);
|
|
91
|
+
}
|
|
92
|
+
const hasPrefix = start > 0;
|
|
93
|
+
const hasSuffix = end < text.length;
|
|
94
|
+
const budget = maxLen - (hasPrefix ? 1 : 0) - (hasSuffix ? 1 : 0);
|
|
95
|
+
let slice = text.slice(start, end).trim();
|
|
96
|
+
if (slice.length > budget)
|
|
97
|
+
slice = slice.slice(0, budget);
|
|
98
|
+
return (hasPrefix ? "…" : "") + slice + (hasSuffix ? "…" : "");
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Score, snippet, sort, and trim every entry to `limit`. Pure and
|
|
102
|
+
* network-free so ranking can be tested without mocking the API client.
|
|
103
|
+
*/
|
|
104
|
+
export function rankEntries(entries, query, limit) {
|
|
105
|
+
const queryTerms = tokenize(query);
|
|
106
|
+
const results = [];
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
const score = scoreEntry(entry, queryTerms);
|
|
109
|
+
if (score <= 0)
|
|
110
|
+
continue;
|
|
111
|
+
const snippetSource = entry.body.trim() ? entry.body : entry.title;
|
|
112
|
+
const snippet = buildSnippet(snippetSource, firstMatchIndex(snippetSource, queryTerms));
|
|
113
|
+
results.push({
|
|
114
|
+
kind: entry.kind,
|
|
115
|
+
id: entry.id,
|
|
116
|
+
title: entry.title,
|
|
117
|
+
snippet,
|
|
118
|
+
...(entry.moduleTitle ? { moduleTitle: entry.moduleTitle } : {}),
|
|
119
|
+
...(entry.url ? { url: entry.url } : {}),
|
|
120
|
+
score,
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
results.sort((a, b) => b.score - a.score);
|
|
124
|
+
return results.slice(0, limit);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Flatten the content tree built by get_course_content's own buildContentTree
|
|
128
|
+
* into search entries, carrying each topic's immediate parent module title
|
|
129
|
+
* along for context.
|
|
130
|
+
*/
|
|
131
|
+
export function flattenContentTree(tree, moduleTitle) {
|
|
132
|
+
const entries = [];
|
|
133
|
+
for (const item of tree) {
|
|
134
|
+
if (item.type === "module") {
|
|
135
|
+
entries.push({
|
|
136
|
+
kind: "module",
|
|
137
|
+
id: item.id,
|
|
138
|
+
title: item.title,
|
|
139
|
+
body: item.description ?? "",
|
|
140
|
+
...(moduleTitle ? { moduleTitle } : {}),
|
|
141
|
+
});
|
|
142
|
+
entries.push(...flattenContentTree(item.children ?? [], item.title));
|
|
143
|
+
}
|
|
144
|
+
else if (item.type === "topic") {
|
|
145
|
+
const body = [item.description, item.content].filter(Boolean).join("\n\n");
|
|
146
|
+
entries.push({
|
|
147
|
+
kind: "topic",
|
|
148
|
+
id: item.id,
|
|
149
|
+
title: item.title,
|
|
150
|
+
body,
|
|
151
|
+
...(moduleTitle ? { moduleTitle } : {}),
|
|
152
|
+
...(item.url ? { url: item.url } : {}),
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return entries;
|
|
157
|
+
}
|
|
158
|
+
/** A short, safe description of why a source's fetch failed — never the raw error. */
|
|
159
|
+
function describeFailure(error) {
|
|
160
|
+
if (error instanceof ApiError) {
|
|
161
|
+
if (error.status === 403)
|
|
162
|
+
return "no access (403)";
|
|
163
|
+
if (error.status === 404)
|
|
164
|
+
return "not found (404)";
|
|
165
|
+
return `request failed (${error.status})`;
|
|
166
|
+
}
|
|
167
|
+
return "request failed";
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Register search_course tool
|
|
171
|
+
*/
|
|
172
|
+
export function registerSearchCourse(server, apiClient) {
|
|
173
|
+
server.registerTool("search_course", {
|
|
174
|
+
title: "Search Course",
|
|
175
|
+
description: "Search a course's content (modules, topics, file names), announcements, and discussion forums/topics by keyword in a single call, instead of reading the whole content tree. Use this when the user wants to find something specific, e.g. 'find the midterm review slides' or 'did anyone post about office hours'. Results are ranked: a result matching every query term ranks above one matching only some, and within that, a match in the title ranks above one only in the body text. If one source (e.g. discussions) can't be read, it's skipped and named in `note` rather than failing the whole search. This fans out over the entire content tree, announcements, and every discussion forum, so it can be slower than calling a single tool like get_course_content directly.",
|
|
176
|
+
inputSchema: SearchCourseSchema,
|
|
177
|
+
}, async (args) => {
|
|
178
|
+
try {
|
|
179
|
+
log("DEBUG", "search_course tool called", { args });
|
|
180
|
+
const { courseId, query, limit } = SearchCourseSchema.parse(args);
|
|
181
|
+
const entries = [];
|
|
182
|
+
const notes = [];
|
|
183
|
+
// Content: modules and topics
|
|
184
|
+
try {
|
|
185
|
+
const rootModules = await fetchRootContent(apiClient, courseId);
|
|
186
|
+
const tree = await buildContentTree(apiClient, courseId, rootModules, new Map(), "all");
|
|
187
|
+
entries.push(...flattenContentTree(tree));
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
log("DEBUG", `search_course: content fetch failed for course ${courseId}`, error);
|
|
191
|
+
notes.push(`content: ${describeFailure(error)}`);
|
|
192
|
+
}
|
|
193
|
+
// Announcements
|
|
194
|
+
try {
|
|
195
|
+
const newsItems = await fetchCourseNews(apiClient, courseId);
|
|
196
|
+
for (const raw of newsItems.filter(isPublishedNewsItem)) {
|
|
197
|
+
const announcement = mapNewsItem(raw);
|
|
198
|
+
entries.push({
|
|
199
|
+
kind: "announcement",
|
|
200
|
+
id: announcement.id,
|
|
201
|
+
title: announcement.title,
|
|
202
|
+
body: announcement.body,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
log("DEBUG", `search_course: announcements fetch failed for course ${courseId}`, error);
|
|
208
|
+
notes.push(`announcements: ${describeFailure(error)}`);
|
|
209
|
+
}
|
|
210
|
+
// Discussions: forums and their topics
|
|
211
|
+
try {
|
|
212
|
+
const forums = await fetchForums(apiClient, courseId);
|
|
213
|
+
for (const forum of forums) {
|
|
214
|
+
entries.push({
|
|
215
|
+
kind: "discussion",
|
|
216
|
+
id: forum.ForumId,
|
|
217
|
+
title: forum.Name,
|
|
218
|
+
body: forum.Description?.Text ?? "",
|
|
219
|
+
});
|
|
220
|
+
try {
|
|
221
|
+
const topics = await fetchForumTopics(apiClient, courseId, forum.ForumId);
|
|
222
|
+
for (const topic of topics) {
|
|
223
|
+
entries.push({
|
|
224
|
+
kind: "discussion",
|
|
225
|
+
id: topic.TopicId,
|
|
226
|
+
title: topic.Name,
|
|
227
|
+
body: topic.Description?.Text ?? "",
|
|
228
|
+
moduleTitle: forum.Name,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
// One forum's topics failing (e.g. no access) shouldn't drop
|
|
234
|
+
// every other forum — same graceful-degradation rule
|
|
235
|
+
// get_discussions already follows for this exact call.
|
|
236
|
+
log("DEBUG", `search_course: topics fetch failed for forum ${forum.ForumId}`, error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
catch (error) {
|
|
241
|
+
log("DEBUG", `search_course: discussions fetch failed for course ${courseId}`, error);
|
|
242
|
+
notes.push(`discussions: ${describeFailure(error)}`);
|
|
243
|
+
}
|
|
244
|
+
const results = rankEntries(entries, query, limit);
|
|
245
|
+
log("INFO", `search_course: ${results.length} matches for "${query}" in course ${courseId}`);
|
|
246
|
+
return toolResponse({
|
|
247
|
+
courseId,
|
|
248
|
+
query,
|
|
249
|
+
results,
|
|
250
|
+
...(notes.length > 0 ? { note: `Could not search: ${notes.join("; ")}` } : {}),
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
return sanitizeError(error);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.7",
|
|
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",
|