brightspace-mcp-server 3.6.1 → 3.7.0

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
@@ -120,7 +120,8 @@ Run it from your home folder. On macOS, a terminal that lacks Files and Folders
120
120
  | Discussions | "What are people saying in the final project thread?" · "Summarize the latest discussion posts" |
121
121
  | 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 |
122
122
  | Troubleshooting | "Which version of the Brightspace server am I running?" · "Where is my Brightspace config file?" — `get_server_info` reports the version, Node runtime, platform, config and session paths, school URL, and whether a credential is stored, without contacting Brightspace or revealing secrets |
123
- | Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, and graded discussion topics (any topic with a due date) |
123
+ | 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 |
124
+ | Planning | "Build me a study schedule based on my upcoming due dates" · "Which class needs the most attention right now?" — pulls from assignments, quizzes, graded discussion topics (any topic with a due date), and course calendar events such as exams and labs |
124
125
 
125
126
 
126
127
  Licensed under the MIT License.
@@ -12,4 +12,5 @@ export const DEFAULT_CACHE_TTLS = {
12
12
  assignments: 600_000, // 10 min
13
13
  roster: 3_600_000, // 1 hour
14
14
  profile: 3_600_000, // 1 hour
15
+ calendar: 600_000, // 10 min
15
16
  };
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, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
19
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, registerGetClasslistEmails, registerGetRoster, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, } from "./tools/index.js";
20
20
  const __filename = fileURLToPath(import.meta.url);
21
21
  const __dirname = dirname(__filename);
22
22
  const PKG_VERSION = (() => {
@@ -102,6 +102,7 @@ else {
102
102
  // Register MCP tools
103
103
  registerGetMyCourses(server, apiClient, config);
104
104
  registerGetUpcomingDueDates(server, apiClient, config);
105
+ registerGetCalendarEvents(server, apiClient, config);
105
106
  registerGetMyGrades(server, apiClient, config);
106
107
  registerGetAnnouncements(server, apiClient, config);
107
108
  registerGetAssignments(server, apiClient, config);
@@ -115,11 +116,11 @@ else {
115
116
  registerGetDiscussions(server, apiClient);
116
117
  registerGetVideoTranscript(server, apiClient);
117
118
  registerGetServerInfo(server, config, PKG_VERSION);
118
- log("DEBUG", "MCP tools registered (15 tools)");
119
+ log("DEBUG", "MCP tools registered (16 tools)");
119
120
  // Connect stdio transport
120
121
  const transport = new StdioServerTransport();
121
122
  await server.connect(transport);
122
- log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (15 tools registered)");
123
+ log("INFO", "Brightspace MCP Server by Rohan Muppa — running on stdio (16 tools registered)");
123
124
  log("INFO", "Setup: see README.md for MCP client configuration (Claude Desktop, ChatGPT Desktop, Cursor, etc.)");
124
125
  }
125
126
  catch (error) {
@@ -0,0 +1,79 @@
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 { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { fetchAllObjects } from "../api/paginate.js";
8
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
9
+ import { calendarUrl } from "../utils/deep-links.js";
10
+ /**
11
+ * The item a calendar event was generated from. The types an existing tool
12
+ * already reports use that tool's own `type` names, so a caller can match an
13
+ * event to the item it came from.
14
+ */
15
+ const ENTITY_TYPES = {
16
+ "D2L.LE.Dropbox.Dropbox": "assignment",
17
+ "D2L.LE.Quizzing.Quiz": "quiz",
18
+ "D2L.LE.Discussions.DiscussionTopic": "discussion",
19
+ "D2L.LE.Discussions.DiscussionForum": "forum",
20
+ "D2L.LE.Content.ContentObject.ModuleCO": "module",
21
+ "D2L.LE.Content.ContentObject.TopicCO": "topic",
22
+ "D2L.LE.Grades.GradeObject": "grade",
23
+ "D2L.LE.Checklist.ChecklistItem": "checklist",
24
+ "D2L.LE.Survey.Survey": "survey",
25
+ };
26
+ const HOUR_MS = 60 * 60 * 1000;
27
+ function descriptionMarkdown(description) {
28
+ const html = typeof description === "string" ? description : description?.Html || description?.Text || "";
29
+ return convertHtmlToMarkdown(html).markdown.trim();
30
+ }
31
+ /** One raw event as a CalendarEvent, or null when it has no start at all. */
32
+ function toCalendarEvent(raw, baseUrl, course) {
33
+ // All-day events carry their dates in StartDay/EndDay instead.
34
+ const start = raw.StartDateTime ?? raw.StartDay ?? null;
35
+ if (!start)
36
+ return null;
37
+ const end = raw.EndDateTime ?? raw.EndDay ?? null;
38
+ const location = raw.LocationName?.trim();
39
+ const description = descriptionMarkdown(raw.Description);
40
+ const entity = raw.AssociatedEntity;
41
+ return {
42
+ id: raw.CalendarEventId,
43
+ title: raw.Title,
44
+ courseId: course.id,
45
+ courseName: course.name,
46
+ start,
47
+ ...(end ? { end } : {}),
48
+ ...(location ? { location } : {}),
49
+ ...(description ? { description } : {}),
50
+ url: calendarUrl(baseUrl, course.id),
51
+ ...(entity
52
+ ? {
53
+ generatedFrom: {
54
+ type: ENTITY_TYPES[entity.AssociatedEntityType] ?? entity.AssociatedEntityType,
55
+ id: entity.AssociatedEntityId,
56
+ },
57
+ }
58
+ : {}),
59
+ };
60
+ }
61
+ /**
62
+ * Every calendar event in one course that starts inside [from, to] (epoch ms).
63
+ *
64
+ * The request window is widened to whole hours so repeated calls within the
65
+ * cache TTL share one cached response; the exact window is applied here.
66
+ */
67
+ export async function fetchCourseCalendarEvents(apiClient, baseUrl, course, from, to) {
68
+ const requestFrom = new Date(Math.floor(from / HOUR_MS) * HOUR_MS).toISOString();
69
+ const requestTo = new Date(Math.ceil(to / HOUR_MS) * HOUR_MS).toISOString();
70
+ const raw = await fetchAllObjects(apiClient, apiClient.le(course.id, `/calendar/events/myEvents/?startDateTime=${encodeURIComponent(requestFrom)}&endDateTime=${encodeURIComponent(requestTo)}`), { ttl: DEFAULT_CACHE_TTLS.calendar });
71
+ return raw
72
+ .map((event) => toCalendarEvent(event, baseUrl, course))
73
+ .filter((event) => {
74
+ if (!event)
75
+ return false;
76
+ const start = new Date(event.start).getTime();
77
+ return Number.isFinite(start) && start >= from && start <= to;
78
+ });
79
+ }
@@ -0,0 +1,44 @@
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 { GetCalendarEventsSchema } from "./schemas.js";
7
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
8
+ import { log } from "../utils/logger.js";
9
+ import { resolveCourses } from "./resolve-courses.js";
10
+ import { fetchCourseCalendarEvents } from "./calendar-events.js";
11
+ const DEFAULT_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
12
+ /**
13
+ * Register get_calendar_events tool
14
+ */
15
+ export function registerGetCalendarEvents(server, apiClient, config) {
16
+ server.registerTool("get_calendar_events", {
17
+ title: "Get Calendar Events",
18
+ description: "Fetch course calendar events — exams, midterms, labs, recitations, review sessions, schedule changes, and deadlines instructors typed straight onto the calendar — across all your courses or one course, in a time window (default: the next 7 days). Use this when the user asks when an exam is, what's on their calendar, or what's happening this week.",
19
+ inputSchema: GetCalendarEventsSchema,
20
+ }, async (args) => {
21
+ try {
22
+ log("DEBUG", "get_calendar_events tool called", { args });
23
+ const { courseId, from, to, includeGenerated } = GetCalendarEventsSchema.parse(args);
24
+ const windowStart = from ? new Date(from).getTime() : Date.now();
25
+ const windowEnd = to ? new Date(to).getTime() : windowStart + DEFAULT_WINDOW_MS;
26
+ const courses = await resolveCourses(apiClient, config, courseId);
27
+ const results = await Promise.allSettled(courses.map((course) => fetchCourseCalendarEvents(apiClient, config.baseUrl, course, windowStart, windowEnd)));
28
+ const events = results
29
+ .flatMap((result, i) => {
30
+ if (result.status === "fulfilled")
31
+ return result.value;
32
+ log("DEBUG", `get_calendar_events: skipping course ${courses[i].id} after fetch failure`, result.reason);
33
+ return [];
34
+ })
35
+ .filter((event) => includeGenerated || !event.generatedFrom)
36
+ .sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
37
+ log("INFO", `get_calendar_events: Retrieved ${events.length} events across ${courses.length} courses`);
38
+ return toolResponse(events);
39
+ }
40
+ catch (error) {
41
+ return sanitizeError(error);
42
+ }
43
+ });
44
+ }
@@ -4,53 +4,16 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
- import { fetchAllItems } from "../api/paginate.js";
8
7
  import { GetUpcomingDueDatesSchema, } from "./schemas.js";
9
8
  import { toolResponse, sanitizeError } from "./tool-helpers.js";
10
9
  import { log } from "../utils/logger.js";
11
- import { applyCourseFilter } from "../utils/course-filter.js";
12
10
  import { assignmentUrl, quizUrl, discussionUrl } from "../utils/deep-links.js";
11
+ import { resolveCourses } from "./resolve-courses.js";
12
+ import { fetchCourseCalendarEvents } from "./calendar-events.js";
13
13
  /** D2L list endpoints return either a paged { Objects: [...] } or a flat array. */
14
14
  function unwrapList(raw) {
15
15
  return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
16
16
  }
17
- /**
18
- * Resolve which courses to query, and their names.
19
- *
20
- * A tool-level courseId bypasses the configured course filter, but enrollments
21
- * are still fetched so the course can be named.
22
- */
23
- async function resolveCourses(apiClient, config, courseId) {
24
- let items = [];
25
- try {
26
- // isActive=true tracks the configured policy rather than being pinned on:
27
- // a user who set activeOnly:false is asking to see archived courses, and a
28
- // query that withholds them leaves applyCourseFilter nothing to let
29
- // through. Enrollments are paged, so follow the bookmark chain — a long
30
- // enrollment history would otherwise lose every course past the first page,
31
- // and every deadline in those courses with it.
32
- items = await fetchAllItems(apiClient, apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`), { ttl: DEFAULT_CACHE_TTLS.enrollments });
33
- }
34
- catch (error) {
35
- // Without enrollments there is no course list to walk, so only the explicit
36
- // single-course case can continue (with an unnamed course).
37
- if (!courseId)
38
- throw error;
39
- log("DEBUG", "get_upcoming_due_dates: could not fetch enrollments for course name", error);
40
- }
41
- if (courseId) {
42
- const match = items.find((item) => item.OrgUnit.Id === courseId);
43
- return [{ id: courseId, name: match?.OrgUnit.Name ?? null }];
44
- }
45
- const filtered = applyCourseFilter(items.map((item) => ({
46
- id: item.OrgUnit.Id,
47
- name: item.OrgUnit.Name,
48
- code: item.OrgUnit.Code,
49
- isActive: item.Access.IsActive,
50
- canAccess: item.Access.CanAccess,
51
- })), config.courseFilter);
52
- return filtered.map((course) => ({ id: course.id, name: course.name }));
53
- }
54
17
  /**
55
18
  * Collect every graded, dated discussion topic for one course.
56
19
  *
@@ -77,19 +40,42 @@ async function fetchDiscussionDueTopics(apiClient, courseId) {
77
40
  return topics;
78
41
  }
79
42
  /**
80
- * Collect every dated assignment, quiz, and graded discussion topic for one
81
- * course.
43
+ * Calendar events as upcoming items, minus the ones Brightspace generated from
44
+ * an item already in `items` — that item is the better record of the same
45
+ * deadline. Hand-made events (exams, labs) have no source item and always stay.
46
+ */
47
+ function calendarItems(events, items) {
48
+ const listed = new Set(items.map((item) => `${item.type}:${item.id}`));
49
+ return events
50
+ .filter((event) => !event.generatedFrom || !listed.has(`${event.generatedFrom.type}:${event.generatedFrom.id}`))
51
+ .map((event) => ({
52
+ type: "event",
53
+ id: event.id,
54
+ title: event.title,
55
+ courseId: event.courseId,
56
+ courseName: event.courseName,
57
+ dueDate: event.start,
58
+ startDate: null,
59
+ endDate: event.end ?? null,
60
+ ...(event.location ? { location: event.location } : {}),
61
+ url: event.url,
62
+ }));
63
+ }
64
+ /**
65
+ * Collect every dated assignment, quiz, graded discussion topic, and calendar
66
+ * event starting in [from, to] for one course.
82
67
  *
83
68
  * No submissions or attempts: the due date lives on the item itself, which is
84
69
  * what makes this cheap enough to run across all enrolled courses. A
85
70
  * discussion topic counts only when it has a DueDate — an ungraded chat forum
86
71
  * has none and should not clutter the list.
87
72
  */
88
- async function fetchCourseDueItems(apiClient, baseUrl, course) {
89
- const [dropboxResult, quizResult, discussionResult] = await Promise.allSettled([
73
+ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
74
+ const [dropboxResult, quizResult, discussionResult, calendarResult] = await Promise.allSettled([
90
75
  apiClient.get(apiClient.le(course.id, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
91
76
  apiClient.get(apiClient.le(course.id, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
92
77
  fetchDiscussionDueTopics(apiClient, course.id),
78
+ fetchCourseCalendarEvents(apiClient, baseUrl, course, from, to),
93
79
  ]);
94
80
  const items = [];
95
81
  if (dropboxResult.status === "fulfilled") {
@@ -161,6 +147,12 @@ async function fetchCourseDueItems(apiClient, baseUrl, course) {
161
147
  else {
162
148
  log("DEBUG", `get_upcoming_due_dates: failed to fetch discussions for course ${course.id}`, discussionResult.reason);
163
149
  }
150
+ if (calendarResult.status === "fulfilled") {
151
+ items.push(...calendarItems(calendarResult.value, items));
152
+ }
153
+ else {
154
+ log("DEBUG", `get_upcoming_due_dates: failed to fetch calendar events for course ${course.id}`, calendarResult.reason);
155
+ }
164
156
  return items;
165
157
  }
166
158
  /**
@@ -169,7 +161,7 @@ async function fetchCourseDueItems(apiClient, baseUrl, course) {
169
161
  export function registerGetUpcomingDueDates(server, apiClient, config) {
170
162
  server.registerTool("get_upcoming_due_dates", {
171
163
  title: "Get Upcoming Due Dates",
172
- description: "Fetch upcoming due dates across all your courses, derived from the due dates on assignments (dropbox folders), quizzes, and graded discussion topics themselves. Use this when the user asks about deadlines, what's due, upcoming work, or what they need to do this week.",
164
+ description: "Fetch upcoming due dates across all your courses, derived from the due dates on assignments (dropbox folders), quizzes, and graded discussion topics themselves, plus course calendar events such as exams and labs (type: event). Use this when the user asks about deadlines, what's due, upcoming work, or what they need to do this week.",
173
165
  inputSchema: GetUpcomingDueDatesSchema,
174
166
  }, async (args) => {
175
167
  try {
@@ -182,7 +174,7 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
182
174
  const courses = await resolveCourses(apiClient, config, courseId);
183
175
  log("DEBUG", `get_upcoming_due_dates: querying ${courses.length} course(s), window=${daysAhead} days`);
184
176
  // Fetch every course in parallel; the API client rate limits itself
185
- const results = await Promise.allSettled(courses.map((course) => fetchCourseDueItems(apiClient, config.baseUrl, course)));
177
+ const results = await Promise.allSettled(courses.map((course) => fetchCourseDueItems(apiClient, config.baseUrl, course, now, windowEnd)));
186
178
  const items = results.flatMap((result) => {
187
179
  if (result.status === "fulfilled")
188
180
  return result.value;
@@ -6,6 +6,7 @@
6
6
  // Tool registration functions - barrel export
7
7
  export { registerGetMyCourses } from "./get-my-courses.js";
8
8
  export { registerGetUpcomingDueDates } from "./get-upcoming-due-dates.js";
9
+ export { registerGetCalendarEvents } from "./get-calendar-events.js";
9
10
  export { registerGetMyGrades } from "./get-my-grades.js";
10
11
  export { registerGetAnnouncements } from "./get-announcements.js";
11
12
  export { registerGetAssignments } from "./get-assignments.js";
@@ -0,0 +1,46 @@
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 { DEFAULT_CACHE_TTLS } from "../api/index.js";
7
+ import { fetchAllItems } from "../api/paginate.js";
8
+ import { log } from "../utils/logger.js";
9
+ import { applyCourseFilter } from "../utils/course-filter.js";
10
+ /**
11
+ * Resolve which courses to query, and their names.
12
+ *
13
+ * A tool-level courseId bypasses the configured course filter, but enrollments
14
+ * are still fetched so the course can be named.
15
+ */
16
+ export async function resolveCourses(apiClient, config, courseId) {
17
+ let items = [];
18
+ try {
19
+ // isActive=true tracks the configured policy rather than being pinned on:
20
+ // a user who set activeOnly:false is asking to see archived courses, and a
21
+ // query that withholds them leaves applyCourseFilter nothing to let
22
+ // through. Enrollments are paged, so follow the bookmark chain — a long
23
+ // enrollment history would otherwise lose every course past the first page,
24
+ // and every deadline in those courses with it.
25
+ items = await fetchAllItems(apiClient, apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${config.courseFilter.activeOnly ? "&isActive=true" : ""}`), { ttl: DEFAULT_CACHE_TTLS.enrollments });
26
+ }
27
+ catch (error) {
28
+ // Without enrollments there is no course list to walk, so only the explicit
29
+ // single-course case can continue (with an unnamed course).
30
+ if (!courseId)
31
+ throw error;
32
+ log("DEBUG", "resolveCourses: could not fetch enrollments for course name", error);
33
+ }
34
+ if (courseId) {
35
+ const match = items.find((item) => item.OrgUnit.Id === courseId);
36
+ return [{ id: courseId, name: match?.OrgUnit.Name ?? null }];
37
+ }
38
+ const filtered = applyCourseFilter(items.map((item) => ({
39
+ id: item.OrgUnit.Id,
40
+ name: item.OrgUnit.Name,
41
+ code: item.OrgUnit.Code,
42
+ isActive: item.Access.IsActive,
43
+ canAccess: item.Access.CanAccess,
44
+ })), config.courseFilter);
45
+ return filtered.map((course) => ({ id: course.id, name: course.name }));
46
+ }
@@ -22,6 +22,16 @@ export const GetUpcomingDueDatesSchema = z.object({
22
22
  daysAhead: z.coerce.number().int().min(1).max(90).default(7).describe("Number of days ahead to look for due dates"),
23
23
  courseId: z.coerce.number().int().positive().optional().describe("Filter to a specific course ID"),
24
24
  });
25
+ export const GetCalendarEventsSchema = z.object({
26
+ courseId: z.coerce.number().int().positive().optional()
27
+ .describe("Course ID to get calendar events for. If omitted, returns events across all enrolled courses."),
28
+ from: z.string().datetime({ offset: true, message: "from must be an ISO 8601 datetime, e.g. 2026-01-15T00:00:00Z" }).optional()
29
+ .describe("Start of the window, ISO 8601 (e.g. 2026-01-15T00:00:00Z). Defaults to now."),
30
+ to: z.string().datetime({ offset: true, message: "to must be an ISO 8601 datetime, e.g. 2026-01-22T00:00:00Z" }).optional()
31
+ .describe("End of the window, ISO 8601 (e.g. 2026-01-22T00:00:00Z). Defaults to 7 days after from."),
32
+ includeGenerated: z.boolean().default(false)
33
+ .describe("Include the events Brightspace generates from assignment, quiz, and discussion due dates (marked with generatedFrom). Off by default because get_upcoming_due_dates and get_assignments already report those."),
34
+ });
25
35
  export const GetMyGradesSchema = z.object({
26
36
  courseId: z.coerce.number().int().positive().optional().describe("Course ID to get grades for. If omitted, returns grades for all enrolled courses."),
27
37
  });
@@ -46,3 +46,10 @@ export function gradebookUrl(baseUrl, courseId) {
46
46
  export function discussionUrl(baseUrl, courseId, topicId) {
47
47
  return `${trimBaseUrl(baseUrl)}/d2l/lms/discussions/threadlist.d2l?ou=${courseId}&tId=${topicId}`;
48
48
  }
49
+ /**
50
+ * Link to a course's calendar. Calendar events have no per-event student page
51
+ * the API names, so every event in a course shares this one link.
52
+ */
53
+ export function calendarUrl(baseUrl, courseId) {
54
+ return `${trimBaseUrl(baseUrl)}/d2l/le/calendar/${courseId}`;
55
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.6.1",
3
+ "version": "3.7.0",
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",