brightspace-mcp-server 5.1.43 → 5.2.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.
@@ -35,6 +35,13 @@ class Unreachable extends Error {
35
35
  */
36
36
  export async function relaySharedRuntime(transport, { dir, startRuntime, startTimeoutMs = DEFAULT_START_TIMEOUT_MS }) {
37
37
  const disconnected = new AbortController();
38
+ /**
39
+ * Each request is its own POST, so the runtime cannot match a later
40
+ * notifications/cancelled to it. The relay can: it keeps the POST of every
41
+ * request in progress and aborts it when the client cancels that request.
42
+ * The runtime ends the tool's own signal when its HTTP request goes away.
43
+ */
44
+ const inProgress = new Map();
38
45
  let current;
39
46
  let locating;
40
47
  /** The address to use, after `failed` (if given) did not answer. */
@@ -71,6 +78,15 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
71
78
  };
72
79
  const forward = async (message) => {
73
80
  const id = "method" in message && "id" in message ? message.id : undefined;
81
+ if ("method" in message && message.method === "notifications/cancelled") {
82
+ const target = message.params?.requestId;
83
+ if (target !== undefined)
84
+ inProgress.get(target)?.abort();
85
+ }
86
+ const cancelled = new AbortController();
87
+ if (id !== undefined)
88
+ inProgress.set(id, cancelled);
89
+ const signal = AbortSignal.any([disconnected.signal, cancelled.signal]);
74
90
  let answered = false;
75
91
  const reply = (out) => {
76
92
  if (id !== undefined && "id" in out && out.id === id && !("method" in out))
@@ -82,7 +98,7 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
82
98
  for (let attempt = 1;; attempt += 1) {
83
99
  const address = await locate(failed);
84
100
  try {
85
- await post(address, message, disconnected.signal, reply);
101
+ await post(address, message, signal, reply);
86
102
  if (id !== undefined && !answered)
87
103
  throw new Error("the runtime ended the call without answering it");
88
104
  return;
@@ -98,10 +114,17 @@ export async function relaySharedRuntime(transport, { dir, startRuntime, startTi
98
114
  catch (error) {
99
115
  if (disconnected.signal.aborted)
100
116
  return;
117
+ // The client cancelled this request: it is no longer waiting for an answer.
118
+ if (cancelled.signal.aborted)
119
+ return;
101
120
  log("ERROR", "Relaying to the shared runtime failed", error);
102
121
  if (id !== undefined && !answered)
103
122
  reply(failure(id, error));
104
123
  }
124
+ finally {
125
+ if (id !== undefined && inProgress.get(id) === cancelled)
126
+ inProgress.delete(id);
127
+ }
105
128
  };
106
129
  transport.onmessage = (message) => { void forward(message); };
107
130
  transport.onclose = () => disconnected.abort();
package/build/server.js CHANGED
@@ -9,7 +9,7 @@ import { D2LApiClient } from "./api/index.js";
9
9
  import { trackToolActivity } from "./utils/dev-activity.js";
10
10
  import { log } from "./utils/logger.js";
11
11
  import { envCredentialRejectedMessage, ENV_COOKIE_REJECTED } from "./utils/commands.js";
12
- import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
12
+ import { registerGetMyCourses, registerGetUpcomingDueDates, registerGetCalendarEvents, registerGetMyGrades, registerGetAnnouncements, registerGetAssignments, registerGetAssignmentRubric, registerGetAssignmentFiles, registerGetAnnouncementFiles, registerGetCourseContent, registerDownloadFile, COURSE_FILE_NEEDS_COOKIE, registerDownloadCourseFiles, registerGetClasslistEmails, registerGetRoster, registerGetMyGroups, registerGetAvailableGroups, registerGetSyllabus, registerGetDiscussions, registerGetVideoTranscript, registerGetServerInfo, registerSearchCourse, registerSignOut, registerSignIn, registerGetDropboxFolders, registerGetDropboxSubmissions, registerGetDropboxUserSubmissions, registerGetDropboxFeedback, registerGetRubricsForObject, registerDownloadDropboxSubmissionFile, acceptOmittedArguments, relayMfaChallenges, currentMfaAnnouncer, } from "./tools/index.js";
13
13
  import { registerWeeklyBriefingPrompt, registerGradeAuditPrompt, registerStudyPlannerPrompt, registerCourseSummaryPrompt, } from "./prompts/index.js";
14
14
  /**
15
15
  * Build the shared runtime. Nothing here reaches Brightspace: API versions
@@ -85,6 +85,7 @@ export function createMcpServer(runtime) {
85
85
  registerGetAnnouncementFiles(server, apiClient);
86
86
  registerGetCourseContent(server, apiClient);
87
87
  registerDownloadFile(server, apiClient, config.envSessionCookie ? ENV_COOKIE_REJECTED : config.envAccessToken ? COURSE_FILE_NEEDS_COOKIE : undefined);
88
+ registerDownloadCourseFiles(server, apiClient);
88
89
  registerGetClasslistEmails(server, apiClient);
89
90
  registerGetRoster(server, apiClient);
90
91
  registerGetMyGroups(server, apiClient);
@@ -103,7 +104,7 @@ export function createMcpServer(runtime) {
103
104
  registerGetDropboxFeedback(server, apiClient);
104
105
  registerGetRubricsForObject(server, apiClient);
105
106
  registerDownloadDropboxSubmissionFile(server, apiClient);
106
- log("DEBUG", "MCP tools registered (28 tools)");
107
+ log("DEBUG", "MCP tools registered (29 tools)");
107
108
  // Register MCP prompts — surfaced in clients (e.g. Claude Desktop) as a
108
109
  // picker, distinct from tools. Each one is a canned user message that
109
110
  // names our own tools by name, so a client with no idea what to ask
@@ -0,0 +1,61 @@
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 fs from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { log } from "../utils/logger.js";
9
+ /** A partial download older than this belongs to a run that was killed, not one still going. */
10
+ export const PARTIAL_MAX_AGE_MS = 60 * 60 * 1000;
11
+ /** Exactly what secureStreamDownload names its temporary file. */
12
+ const PARTIAL = /^\.download-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.part$/;
13
+ const MAX_VISITED = 50_000;
14
+ const MAX_DEPTH = 64;
15
+ /**
16
+ * Remove the `.download-<uuid>.part` files a killed run (kill -9, power loss)
17
+ * left in the destination tree. Only that exact name, only regular files
18
+ * older than an hour, and nothing is followed through a symbolic link or into
19
+ * a hidden folder. Returns how many were removed.
20
+ */
21
+ export async function removeStalePartials(root, now = Date.now()) {
22
+ let removed = 0;
23
+ let visited = 0;
24
+ const walk = async (dir, depth) => {
25
+ if (depth > MAX_DEPTH || visited > MAX_VISITED)
26
+ return;
27
+ let entries;
28
+ try {
29
+ entries = await fs.readdir(dir, { withFileTypes: true });
30
+ }
31
+ catch {
32
+ return;
33
+ }
34
+ for (const entry of entries) {
35
+ if (++visited > MAX_VISITED)
36
+ return;
37
+ const full = path.join(dir, entry.name);
38
+ if (entry.isDirectory()) {
39
+ if (!entry.name.startsWith("."))
40
+ await walk(full, depth + 1);
41
+ }
42
+ else if (entry.isFile() && PARTIAL.test(entry.name)) {
43
+ try {
44
+ // lstat again: the entry type above could have changed, and a link is never touched.
45
+ const stat = await fs.lstat(full);
46
+ if (stat.isFile() && now - stat.mtimeMs > PARTIAL_MAX_AGE_MS) {
47
+ await fs.rm(full, { force: true });
48
+ removed++;
49
+ }
50
+ }
51
+ catch {
52
+ // Gone already, or not ours to remove.
53
+ }
54
+ }
55
+ }
56
+ };
57
+ await walk(root, 0);
58
+ if (removed > 0)
59
+ log("INFO", `Course sync: removed ${removed} partial download(s) left by an interrupted run`);
60
+ return removed;
61
+ }