brightspace-mcp-server 3.9.6 → 3.9.8

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
@@ -1,5 +1,21 @@
1
1
  # Brightspace MCP Server
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/brightspace-mcp-server.svg)](https://www.npmjs.com/package/brightspace-mcp-server)
4
+ [![npm downloads](https://img.shields.io/npm/dm/brightspace-mcp-server.svg)](https://www.npmjs.com/package/brightspace-mcp-server)
5
+ [![CI](https://img.shields.io/github/actions/workflow/status/RohanMuppa/brightspace-mcp-server/ci.yml?branch=main)](https://github.com/RohanMuppa/brightspace-mcp-server/actions/workflows/ci.yml)
6
+ [![License: MIT](https://img.shields.io/github/license/RohanMuppa/brightspace-mcp-server.svg)](LICENSE)
7
+ [![Node >= 20](https://img.shields.io/node/v/brightspace-mcp-server.svg)](package.json)
8
+
9
+ **Official repo:** github.com/RohanMuppa/brightspace-mcp-server · [npm](https://www.npmjs.com/package/brightspace-mcp-server) — forks of this README are not maintained.
10
+
11
+ **Contents:** [Try It](#try-it) · [Install](#install) · [Supported schools](#supported-schools) ·
12
+ [Session Expired?](#session-expired) · [No browser?](#no-browser-paste-a-session-cookie-or-token) ·
13
+ [Troubleshooting](#troubleshooting) ·
14
+ [Does it work with ChatGPT, Cursor, Windsurf or Claude Code?](#does-it-work-with-chatgpt-cursor-windsurf-or-claude-code) ·
15
+ [Is my password safe?](#is-my-password-safe) · [Does my school work?](#does-my-school-work) ·
16
+ [Why do I have to approve MFA sometimes?](#why-do-i-have-to-approve-mfa-sometimes) ·
17
+ [What You Can Ask About](#what-you-can-ask-about)
18
+
3
19
  > **By [Rohan Muppa](https://github.com/rohanmuppa), ECE @ Purdue**
4
20
 
5
21
  Talk to your Brightspace courses with AI. Ask about grades, due dates, quizzes, announcements, and more. Works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop, Windsurf, and any MCP client.
@@ -115,6 +131,18 @@ Building against this server or opening a PR? See [STABILITY.md](STABILITY.md) f
115
131
 
116
132
  **Running from a source checkout or fork?** When a newer release is published, the server tells a source checkout to `git pull` and `npm run build` rather than to install the npm package. For a fork you maintain, set `D2L_NO_UPDATE_CHECK=1` in the server's environment to turn off upstream update notices.
117
133
 
134
+ ## Supported schools
135
+
136
+ | School | Login flow | Setup preset |
137
+ |--------|-----------|--------------|
138
+ | Purdue | Microsoft Entra (number matching, authenticator code, or visible browser) | `--purdue` |
139
+ | SUNY | Shared tenant with a campus picker | `--suny` |
140
+ | Western University | Microsoft Entra | `--western` |
141
+ | TU Delft | NetID via SURFconext (headless, no MFA) | `--tudelft` |
142
+ | CUNY | CUNY Login, with an authenticator code on every full sign-in | `--cuny` |
143
+ | Leiden University | SURFconext → Microsoft Entra | `--leiden` |
144
+ | Any other D2L school | Paste your Brightspace URL; unsupported login pages fall back to the visible browser | run `setup` with no flag |
145
+
118
146
  ## Session Expired?
119
147
 
120
148
  There's no login step — asking a question signs you in.
@@ -160,6 +188,39 @@ If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.j
160
188
 
161
189
  **Which version am I running:** ask the assistant anything that calls `get_server_info` — it reports the running version, Node runtime, and config/session paths with no network call. If you're on a source checkout instead of the published npm package, that version comes from the local `build/` output, so it only reflects your latest `npm run build`, not what's on npm.
162
190
 
191
+ ## Does it work with ChatGPT, Cursor, Windsurf or Claude Code?
192
+
193
+ Yes — it's a standard MCP server, so it works with Claude Desktop, Claude Code, Cursor, ChatGPT Desktop,
194
+ Windsurf, Codex, and any other MCP client. The setup wizard auto-configures Claude Desktop, Cursor, and
195
+ Codex Desktop/CLI, and uses `claude mcp add` for Claude Code, when it detects them on your machine. Any
196
+ other client just needs the same `npx -y brightspace-mcp-server@latest` command added to its MCP config
197
+ (see "Using a different client?" above for exact steps).
198
+
199
+ ## Is my password safe?
200
+
201
+ Your password is saved in your operating system's native credential store (macOS Keychain, Windows
202
+ Credential Manager, or Linux Secret Service) — never in a plaintext file. Session state on disk is
203
+ encrypted with AES-256-GCM. None of the server's tools submit, edit, or delete anything in your
204
+ courses; they only read grades, assignments, and course content. The second factor itself (an
205
+ approval tap, a number match, or a TOTP code) stays on your phone or authenticator app — the server
206
+ never sees or stores it.
207
+
208
+ ## Does my school work?
209
+
210
+ If your school runs D2L Brightspace, yes. Purdue, SUNY, Western University, TU Delft, CUNY, and Leiden
211
+ have dedicated setup presets with automated sign-in; see [Supported schools](#supported-schools) above
212
+ for which flag to use. Any other Brightspace school works too — run `setup` without a flag and paste
213
+ your school's Brightspace URL, and an unsupported login page falls back to a visible browser window
214
+ you complete by hand.
215
+
216
+ ## Why do I have to approve MFA sometimes?
217
+
218
+ How often you're asked is your school's sign-in frequency setting, not something this server controls —
219
+ the same approval you'd see signing in through a regular browser. Set `D2L_REMEMBER_MFA=true` to have
220
+ the server tick Microsoft's "Don't ask again" box during sign-in so later logins can skip the second
221
+ factor for as long as your school's tenant allows; it's opt-in and off by default, so leave it off on a
222
+ shared machine.
223
+
163
224
  ## What You Can Ask About
164
225
 
165
226
  | Topic | Examples |
@@ -175,6 +236,7 @@ If this is a Docker or other headless setup with no `~/.brightspace-mcp/config.j
175
236
  | Roster | "Who are the TAs for ECE 264?" · "Get me my instructor's email" |
176
237
  | Groups | "Who is in my project group?" · "List my lab group members for this course" |
177
238
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
239
+ | 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
240
  | 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
241
  | 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
242
  | 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
- log("DEBUG", "MCP tools registered (18 tools)");
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 (18 tools, 4 prompts registered)");
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 path = apiClient.le(courseId, "/news/");
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 path = apiClient.le(item.OrgUnit.Id, "/news/");
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 = null;
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 apiClient.get(apiClient.le(courseId, '/content/root/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
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 forumsPath = apiClient.le(courseId, "/discussions/forums/");
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
- const topicsPath = apiClient.le(courseId, `/discussions/forums/${forum.ForumId}/topics/`);
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) {
@@ -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";
@@ -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.6",
3
+ "version": "3.9.8",
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",
@@ -83,6 +83,12 @@
83
83
  "ai",
84
84
  "duo",
85
85
  "sso",
86
- "d2l-brightspace"
86
+ "d2l-brightspace",
87
+ "claude-code",
88
+ "due-dates",
89
+ "suny",
90
+ "tu-delft",
91
+ "cuny",
92
+ "leiden"
87
93
  ]
88
94
  }