brightspace-mcp-server 3.9.18 → 3.9.20

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
@@ -66,7 +66,7 @@ School-specific quirks: [docs/sign-in.md](docs/sign-in.md#per-school-notes).
66
66
  | Assignments and rubrics | "What does the lab 4 spec actually ask for?" · "Why did I lose points on the analysis criterion?" |
67
67
  | Quizzes and exams | "Which quizzes close this week?" · "Is there a midterm in the gradebook that isn't on my assignments list?" |
68
68
  | Announcements | "Did any professor post something important today?" · "Read the file attached to today's announcement" |
69
- | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" |
69
+ | Course content | "Find the midterm review slides" · "Download every PDF from Module 5" · "Search this course for office hours" · or just read a file inline instead of saving it |
70
70
  | People | "Who are the TAs for ECE 264?" · "Who is in my project group?" |
71
71
  | Discussions | "Summarize the latest posts in the final project thread" |
72
72
  | Lecture videos | "What did the professor say about pinch-off in Tuesday's recording?" (Kaltura and YouTube) |
@@ -5,23 +5,213 @@
5
5
  */
6
6
  import { ApiError } from "../api/index.js";
7
7
  import { DownloadFileSchema } from "./schemas.js";
8
- import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
8
+ import { toolResponse, sanitizeError, errorResponse, withUpdateNotice } from "./tool-helpers.js";
9
9
  import { log } from "../utils/logger.js";
10
10
  import { checkTopicAvailability } from "./topic-availability.js";
11
- // Path containment and magic-byte checks belong to secureDownload, which both
12
- // download paths below go through; importing them here only made it look as
13
- // though this file validated anything itself.
14
- import { validateContentId, MAX_FILE_SIZE } from "../utils/file-validator.js";
11
+ // Path containment checks belong to secureDownload, which disk-mode downloads
12
+ // go through; importing it here only made it look as though this file
13
+ // validated anything itself. Inline mode never writes to disk, so it calls
14
+ // validateFileType directly to enforce the same magic-byte allowlist.
15
+ import { validateContentId, validateFileType, MAX_FILE_SIZE } from "../utils/file-validator.js";
15
16
  import { secureDownload } from "../utils/download-helpers.js";
17
+ import { extractPdfText } from "../utils/pdf-extractor.js";
18
+ import { officeDocumentText } from "../utils/zip-extract.js";
16
19
  import fs from "node:fs/promises";
17
20
  import path from "node:path";
21
+ /**
22
+ * Maximum bytes of a file we'll embed inline in a tool response. Base64
23
+ * encoding (for images) adds ~33% overhead, so this stays well under typical
24
+ * MCP response-size limits. A file over this cap still works — the caller
25
+ * just needs to pass an absolute `downloadPath` to save it to disk instead.
26
+ */
27
+ const INLINE_MAX_SIZE = 10 * 1024 * 1024; // 10 MB
28
+ /**
29
+ * Maximum bytes of an image specifically that we'll embed inline as an MCP
30
+ * ImageContent block. Base64 encoding adds ~33% overhead, so a file right up
31
+ * against INLINE_MAX_SIZE (10 MB) would base64-encode to ~13.6 MB — comfortably
32
+ * over the model API's ~5 MB per-image limit, which fails the *entire* tool
33
+ * response, not just the image block. A tighter cap here (3.5 MB raw, ~4.7 MB
34
+ * base64) keeps every inlined image under that limit; a larger image falls
35
+ * through to the binary_description_only branch with a note to use
36
+ * downloadPath instead.
37
+ */
38
+ const INLINE_IMAGE_MAX_SIZE = 3.5 * 1024 * 1024; // 3.5 MB
39
+ /**
40
+ * Maximum characters of extracted text (PDF, Office document, or plain text)
41
+ * inlined into a single tool response. ~400,000 characters is roughly 100k
42
+ * tokens — enough for most course documents — and a hard cap keeps one
43
+ * runaway file from dominating the conversation.
44
+ */
45
+ const INLINE_TEXT_MAX_CHARS = 400_000;
46
+ /**
47
+ * MCP ImageContent blocks are passed to Anthropic's vision pipeline, which
48
+ * accepts JPEG, PNG, GIF, and WebP. Any other image mime (SVG, BMP, TIFF)
49
+ * falls back to the generic "can't display inline" path below.
50
+ */
51
+ const INLINE_IMAGE_MIMES = new Set([
52
+ "image/jpeg",
53
+ "image/png",
54
+ "image/gif",
55
+ "image/webp",
56
+ ]);
57
+ /** OOXML formats `officeDocumentText` (zip-extract.ts) knows how to read. */
58
+ const INLINE_OFFICE_MIMES = new Set([
59
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx
60
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx
61
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx
62
+ ]);
63
+ /** Mime types whose bytes are safe to decode as UTF-8 and inline verbatim. */
64
+ const INLINE_TEXT_MIMES = new Set(["text/plain", "text/csv", "text/html", "application/json"]);
65
+ /** Truncate text to `max` characters, noting how much was cut and why. */
66
+ function truncateForInline(text, max) {
67
+ if (text.length <= max)
68
+ return { body: text, truncated: false };
69
+ const remaining = text.length - max;
70
+ return {
71
+ body: text.slice(0, max) +
72
+ `\n\n[...truncated at ${max} characters; ${remaining} more characters exist in the source file. ` +
73
+ "Re-run with an absolute downloadPath to save the full file to disk instead...]",
74
+ truncated: true,
75
+ };
76
+ }
77
+ /**
78
+ * Inline mode: return the file directly in the tool response instead of
79
+ * writing it to disk. This is the right mode inside clients like Claude
80
+ * Desktop, where the MCP server's host filesystem is not the same filesystem
81
+ * its analysis/sandbox tools see — a file `download_file` wrote to disk there
82
+ * was simply unreachable from the rest of the conversation.
83
+ *
84
+ * Deliberately emits only TextContent and, for a handful of image mimes,
85
+ * ImageContent — never an MCP EmbeddedResource. Claude Desktop feeds an
86
+ * EmbeddedResource with mimeType `application/pdf` into Anthropic's document
87
+ * pipeline, which rejects PDFs containing JBIG2-compressed images (common in
88
+ * scanned academic PDFs) and can fail the *entire* tool response, even next
89
+ * to perfectly good TextContent blocks. Extracted text sidesteps that
90
+ * pipeline entirely. (Adapted from LunaParker/brightspace-mcp-server, MIT.)
91
+ */
92
+ async function respondInline(buffer, originalFilename, customFilename) {
93
+ const effectiveFilename = customFilename || originalFilename;
94
+ if (buffer.length > INLINE_MAX_SIZE) {
95
+ return errorResponse(`File too large for inline delivery (${Math.round(buffer.length / 1024 / 1024)}MB, inline max ${INLINE_MAX_SIZE / 1024 / 1024}MB). Provide an absolute downloadPath to save it to disk instead.`);
96
+ }
97
+ // Enforces the same magic-byte allowlist as disk mode (secureDownload calls
98
+ // validateFileType internally); inline mode never writes to disk, so it has
99
+ // to call this itself. Throws DownloadError on an unsupported or
100
+ // undetectable type, handled by the caller's sanitizeError.
101
+ const { mime } = await validateFileType(buffer, undefined, effectiveFilename);
102
+ const metadata = {
103
+ mode: "inline",
104
+ filename: effectiveFilename,
105
+ originalFilename,
106
+ mimeType: mime,
107
+ size: buffer.length,
108
+ };
109
+ const content = [];
110
+ if (mime === "application/pdf") {
111
+ const extracted = await extractPdfText(buffer);
112
+ if (!extracted || !extracted.text) {
113
+ metadata.representation = "pdf_text_extraction_failed";
114
+ metadata.note =
115
+ "No text layer found in this PDF (it may be a scan). Re-run with an absolute downloadPath to save the raw file to disk.";
116
+ content.push({ type: "text", text: JSON.stringify(metadata) });
117
+ return withUpdateNotice(content);
118
+ }
119
+ const { body, truncated } = truncateForInline(extracted.text, INLINE_TEXT_MAX_CHARS);
120
+ metadata.representation = "extracted_text";
121
+ metadata.pages = extracted.totalPages;
122
+ metadata.truncated = truncated;
123
+ content.push({ type: "text", text: JSON.stringify(metadata) });
124
+ content.push({
125
+ type: "text",
126
+ text: `--- Extracted PDF text (${extracted.totalPages} page${extracted.totalPages === 1 ? "" : "s"})${truncated ? ", truncated" : ""} ---\n${body}`,
127
+ });
128
+ }
129
+ else if (INLINE_IMAGE_MIMES.has(mime)) {
130
+ if (buffer.length > INLINE_IMAGE_MAX_SIZE) {
131
+ // Base64-encoding a file this size would exceed the model API's image
132
+ // limit and fail the whole response, so this falls back to the same
133
+ // "can't display inline" path as an unsupported binary format rather
134
+ // than emitting an oversized ImageContent block.
135
+ metadata.representation = "binary_description_only";
136
+ metadata.note = `This image is too large to inline (${Math.round(buffer.length / 1024 / 1024)}MB, inline image max ${INLINE_IMAGE_MAX_SIZE / 1024 / 1024}MB). Re-run download_file with an absolute downloadPath to save it to disk instead.`;
137
+ content.push({ type: "text", text: JSON.stringify(metadata) });
138
+ return withUpdateNotice(content);
139
+ }
140
+ metadata.representation = "image";
141
+ content.push({ type: "text", text: JSON.stringify(metadata) });
142
+ content.push({ type: "image", data: buffer.toString("base64"), mimeType: mime });
143
+ }
144
+ else if (INLINE_OFFICE_MIMES.has(mime)) {
145
+ // Reuses the same zip-based text extraction get_assignment_files and
146
+ // get_announcement_files already rely on (attachment-reader.ts) rather
147
+ // than duplicating it here.
148
+ const text = officeDocumentText(buffer);
149
+ if (!text) {
150
+ metadata.representation = "office_text_extraction_failed";
151
+ metadata.note =
152
+ "No readable text found in this Office document. Re-run with an absolute downloadPath to save the raw file to disk.";
153
+ content.push({ type: "text", text: JSON.stringify(metadata) });
154
+ return withUpdateNotice(content);
155
+ }
156
+ const { body, truncated } = truncateForInline(text, INLINE_TEXT_MAX_CHARS);
157
+ metadata.representation = "extracted_text";
158
+ metadata.truncated = truncated;
159
+ content.push({ type: "text", text: JSON.stringify(metadata) });
160
+ content.push({ type: "text", text: `--- Extracted text (${effectiveFilename}) ---\n${body}` });
161
+ }
162
+ else if (INLINE_TEXT_MIMES.has(mime)) {
163
+ const raw = buffer.toString("utf-8");
164
+ const { body, truncated } = truncateForInline(raw, INLINE_TEXT_MAX_CHARS);
165
+ metadata.representation = "text";
166
+ metadata.truncated = truncated;
167
+ content.push({ type: "text", text: JSON.stringify(metadata) });
168
+ content.push({ type: "text", text: `--- File contents (${effectiveFilename}) ---\n${body}` });
169
+ }
170
+ else {
171
+ // Binary formats we can't meaningfully show in the conversation (zip,
172
+ // legacy .doc/.ppt/.xls, video, audio, svg, ...). Emitting an
173
+ // EmbeddedResource here would put us back in the PDF-rejection failure
174
+ // mode described above, so this just points the caller at disk mode.
175
+ metadata.representation = "binary_description_only";
176
+ metadata.note = `This file type (${mime}) cannot be displayed inline. Re-run download_file with an absolute downloadPath to save it to disk instead.`;
177
+ content.push({ type: "text", text: JSON.stringify(metadata) });
178
+ }
179
+ return withUpdateNotice(content);
180
+ }
181
+ /**
182
+ * Finish a download once the bytes are in hand: write to disk when
183
+ * `downloadPath` is given (unchanged response shape, plus a new `mode: "disk"`
184
+ * field), otherwise return the file inline in the tool response.
185
+ */
186
+ async function finishDownload(buffer, originalFilename, downloadPath, customFilename, sourceLabel) {
187
+ if (downloadPath === undefined) {
188
+ return respondInline(buffer, originalFilename, customFilename);
189
+ }
190
+ const effectiveFilename = customFilename || originalFilename;
191
+ // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
192
+ const result = await secureDownload({
193
+ targetDir: downloadPath,
194
+ filename: effectiveFilename,
195
+ data: buffer,
196
+ });
197
+ log("INFO", `${sourceLabel} downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
198
+ return toolResponse({
199
+ mode: "disk",
200
+ success: true,
201
+ filePath: result.path,
202
+ fileSize: result.size,
203
+ mimeType: result.mime,
204
+ originalFilename,
205
+ message: `File downloaded successfully to ${result.path}`,
206
+ });
207
+ }
18
208
  /**
19
209
  * Register download_file tool
20
210
  */
21
211
  export function registerDownloadFile(server, apiClient) {
22
212
  server.registerTool("download_file", {
23
213
  title: "Download File",
24
- description: "Download a file from course content, assignment submissions, or an announcement's attachments to a local directory. Use this when the user wants to download, save, or get a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool. Never guess or assume a download directory. After identifying the file to download, suggest a clean readable filename to the user (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and ask if they'd like to rename it. Pass their preferred name as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
214
+ description: "Download a file from course content, assignment submissions, or an announcement's attachments. Use this when the user wants a file from Brightspace course content, dropbox submissions, or an announcement (newsId + fileId, from get_announcements). Two response modes: (1) INLINE (default — omit downloadPath): the file comes back directly in the tool response — extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, or a short description for anything else — so it can be read immediately without touching any filesystem. This is the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see a file the MCP server writes to its own host filesystem. (2) DISK (set downloadPath to an absolute path on the HOST filesystem the MCP server runs on): the file is saved there. Ask the user where to save it before using disk mode — never guess a directory. After identifying the file, suggest a clean readable filename (e.g., 'Lecture 7 - Memory Management.pdf' instead of 'L07_CS251_2026SP_v2.pdf') and pass it as customFilename, or omit it to keep the original. If a content-topic download fails because the file isn't released yet, the response explains why when Brightspace's module/topic metadata supports it (not yet open, ended, locked, or hidden).",
25
215
  inputSchema: DownloadFileSchema,
26
216
  }, async (args) => {
27
217
  try {
@@ -30,22 +220,27 @@ export function registerDownloadFile(server, apiClient) {
30
220
  const { courseId, topicId, folderId, newsId, fileId, downloadPath, customFilename } = DownloadFileSchema.parse(args);
31
221
  // Validate courseId
32
222
  validateContentId(courseId);
33
- // Validate download path is absolute
34
- if (!path.isAbsolute(downloadPath)) {
35
- return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)");
36
- }
37
- // Validate download directory exists and is a directory
38
- try {
39
- const stats = await fs.stat(downloadPath);
40
- if (!stats.isDirectory()) {
41
- return errorResponse(`Download path is not a directory: ${downloadPath}`);
223
+ // downloadPath is optional: omitting it means "return the file
224
+ // inline" (see respondInline), so there is nothing on the host
225
+ // filesystem to validate in that case.
226
+ if (downloadPath !== undefined) {
227
+ // Validate download path is absolute
228
+ if (!path.isAbsolute(downloadPath)) {
229
+ return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)");
42
230
  }
43
- }
44
- catch (error) {
45
- if (error?.code === "ENOENT") {
46
- return errorResponse(`Download directory does not exist: ${downloadPath}`);
231
+ // Validate download directory exists and is a directory
232
+ try {
233
+ const stats = await fs.stat(downloadPath);
234
+ if (!stats.isDirectory()) {
235
+ return errorResponse(`Download path is not a directory: ${downloadPath}`);
236
+ }
237
+ }
238
+ catch (error) {
239
+ if (error?.code === "ENOENT") {
240
+ return errorResponse(`Download directory does not exist: ${downloadPath}`);
241
+ }
242
+ throw error;
47
243
  }
48
- throw error;
49
244
  }
50
245
  // Determine download source
51
246
  if (topicId !== undefined) {
@@ -134,24 +329,8 @@ async function downloadContentFile(apiClient, courseId, topicId, downloadPath, c
134
329
  if (buffer.length > MAX_FILE_SIZE) {
135
330
  return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
136
331
  }
137
- // Use custom filename if provided, otherwise use Content-Disposition filename
138
332
  const originalFilename = filename;
139
- const effectiveFilename = customFilename || filename;
140
- // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
141
- const result = await secureDownload({
142
- targetDir: downloadPath,
143
- filename: effectiveFilename,
144
- data: buffer,
145
- });
146
- log("INFO", `File downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
147
- return toolResponse({
148
- success: true,
149
- filePath: result.path,
150
- fileSize: result.size,
151
- mimeType: result.mime,
152
- originalFilename,
153
- message: `File downloaded successfully to ${result.path}`,
154
- });
333
+ return finishDownload(buffer, originalFilename, downloadPath, customFilename, "Content file");
155
334
  }
156
335
  /**
157
336
  * Download a submission/feedback file using folderId + fileId
@@ -203,24 +382,8 @@ async function downloadSubmissionFile(apiClient, courseId, folderId, fileId, dow
203
382
  if (buffer.length > MAX_FILE_SIZE) {
204
383
  return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
205
384
  }
206
- // Use custom filename if provided, otherwise use original submission filename
207
385
  const originalFilename = file.FileName;
208
- const effectiveFilename = customFilename || file.FileName;
209
- // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
210
- const result = await secureDownload({
211
- targetDir: downloadPath,
212
- filename: effectiveFilename,
213
- data: buffer,
214
- });
215
- log("INFO", `Submission file downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
216
- return toolResponse({
217
- success: true,
218
- filePath: result.path,
219
- fileSize: result.size,
220
- mimeType: result.mime,
221
- originalFilename,
222
- message: `File downloaded successfully to ${result.path}`,
223
- });
386
+ return finishDownload(buffer, originalFilename, downloadPath, customFilename, "Submission file");
224
387
  }
225
388
  /**
226
389
  * Download an announcement attachment using newsId + fileId
@@ -252,20 +415,5 @@ async function downloadNewsAttachment(apiClient, courseId, newsId, fileId, downl
252
415
  return errorResponse(`File too large (${Math.round(buffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
253
416
  }
254
417
  const originalFilename = filename;
255
- const effectiveFilename = customFilename || filename;
256
- // Use secureDownload for path traversal prevention, file type validation, and conflict resolution
257
- const result = await secureDownload({
258
- targetDir: downloadPath,
259
- filename: effectiveFilename,
260
- data: buffer,
261
- });
262
- log("INFO", `Announcement attachment downloaded successfully: ${result.path} (${result.size} bytes, ${result.mime})`);
263
- return toolResponse({
264
- success: true,
265
- filePath: result.path,
266
- fileSize: result.size,
267
- mimeType: result.mime,
268
- originalFilename,
269
- message: `File downloaded successfully to ${result.path}`,
270
- });
418
+ return finishDownload(buffer, originalFilename, downloadPath, customFilename, "Announcement attachment");
271
419
  }
@@ -130,6 +130,8 @@ export function registerGetAnnouncements(server, apiClient, config) {
130
130
  code: item.OrgUnit.Code,
131
131
  isActive: item.Access.IsActive,
132
132
  canAccess: item.Access.CanAccess,
133
+ startDate: item.Access.StartDate,
134
+ endDate: item.Access.EndDate,
133
135
  ...item,
134
136
  })), config.courseFilter);
135
137
  // Fetch announcements for each course (handle 403s gracefully)
@@ -457,6 +457,8 @@ export function registerGetAssignments(server, apiClient, config) {
457
457
  code: item.OrgUnit.Code,
458
458
  isActive: item.Access.IsActive,
459
459
  canAccess: item.Access.CanAccess,
460
+ startDate: item.Access.StartDate,
461
+ endDate: item.Access.EndDate,
460
462
  ...item,
461
463
  })), config.courseFilter);
462
464
  // Fetch assignments for each course (handle 403s gracefully)
@@ -42,6 +42,8 @@ export function registerGetMyCourses(server, apiClient, config) {
42
42
  role: item.Access.ClasslistRoleName,
43
43
  isActive: item.Access.IsActive,
44
44
  canAccess: item.Access.CanAccess,
45
+ startDate: item.Access.StartDate,
46
+ endDate: item.Access.EndDate,
45
47
  lastAccessed: item.Access.LastAccessed,
46
48
  })), { ...config.courseFilter, activeOnly });
47
49
  log("INFO", `get_my_courses: Retrieved ${courses.length} courses`);
@@ -77,6 +77,8 @@ export function registerGetMyGrades(server, apiClient, config) {
77
77
  code: item.OrgUnit.Code,
78
78
  isActive: item.Access.IsActive,
79
79
  canAccess: item.Access.CanAccess,
80
+ startDate: item.Access.StartDate,
81
+ endDate: item.Access.EndDate,
80
82
  ...item,
81
83
  })), config.courseFilter);
82
84
  // Fetch grades for each course (handle 403s gracefully)
@@ -41,6 +41,8 @@ export async function resolveCourses(apiClient, config, courseId) {
41
41
  code: item.OrgUnit.Code,
42
42
  isActive: item.Access.IsActive,
43
43
  canAccess: item.Access.CanAccess,
44
+ startDate: item.Access.StartDate,
45
+ endDate: item.Access.EndDate,
44
46
  })), config.courseFilter);
45
47
  return filtered.map((course) => ({ id: course.id, name: course.name }));
46
48
  }
@@ -72,8 +72,8 @@ export const DownloadFileSchema = z.object({
72
72
  .describe("Specific file ID within a dropbox submission, or an announcement attachment's file ID (with newsId)."),
73
73
  newsId: z.coerce.number().int().positive().optional()
74
74
  .describe("Announcement (news item) ID whose attachment to download. Requires fileId."),
75
- downloadPath: z.string().min(1)
76
- .describe("Absolute path to the directory where the file should be saved."),
75
+ downloadPath: z.string().min(1).optional()
76
+ .describe("Absolute path to the directory where the file should be saved on disk. Omit this to receive the file inline in the tool response instead — the right choice in clients like Claude Desktop, whose analysis/sandbox tools cannot see files an MCP server writes to its own host filesystem. Inline mode returns extracted text for PDFs and Office documents, an image block for jpeg/png/gif/webp, and a short description (pointing back to disk mode) for anything else, capped at 10MB and 400,000 characters of extracted text."),
77
77
  customFilename: z.string().max(255).optional()
78
78
  .describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
79
79
  });
@@ -11,26 +11,36 @@ import { log } from "../utils/logger.js";
11
11
  import { AUTH_COMMAND } from "../utils/commands.js";
12
12
  import { getUpdateNotice } from "../utils/update-checker.js";
13
13
  import { DownloadError, isSafeDetail, } from "../utils/download-errors.js";
14
+ /**
15
+ * Append an available-update notice to an already-built content array and
16
+ * wrap it as a CallToolResult. This is the one place an update notice can
17
+ * reach a user regardless of which tool (or which response mode within a
18
+ * tool) they happen to hit, and it is throttled inside getUpdateNotice so a
19
+ * busy session is not spammed. `toolResponse` and any hand-built multi-block
20
+ * result (e.g. download_file's inline mode) both funnel through here so
21
+ * neither path can silently drop the notice.
22
+ */
23
+ export function withUpdateNotice(content) {
24
+ const notice = getUpdateNotice();
25
+ if (notice)
26
+ content.push({ type: "text", text: notice });
27
+ return { content };
28
+ }
14
29
  /**
15
30
  * Wrap data as MCP-compatible tool result.
16
31
  *
17
32
  * Every tool returns through here, which makes it the one place an update
18
33
  * notice can reach a user regardless of which tool they happen to call. The
19
34
  * notice is appended as a separate content block so content[0].text stays a
20
- * pure JSON document for anything parsing it, and it is throttled inside
21
- * getUpdateNotice so a busy session is not spammed.
35
+ * pure JSON document for anything parsing it.
22
36
  */
23
37
  export function toolResponse(data) {
24
- const content = [
38
+ return withUpdateNotice([
25
39
  {
26
40
  type: "text",
27
41
  text: JSON.stringify(data),
28
42
  },
29
- ];
30
- const notice = getUpdateNotice();
31
- if (notice)
32
- content.push({ type: "text", text: notice });
33
- return { content };
43
+ ]);
34
44
  }
35
45
  /**
36
46
  * Wrap error message as MCP-compatible tool result
@@ -67,6 +67,12 @@ export async function loadConfig() {
67
67
  const activeOnly = envBoolean(process.env.D2L_ACTIVE_ONLY, "D2L_ACTIVE_ONLY")
68
68
  ?? store?.activeOnly
69
69
  ?? true;
70
+ // Resolve currentOnly: env > store > default (false). When true, a course
71
+ // only passes the filter if Access.StartDate <= now <= Access.EndDate,
72
+ // matching Brightspace's "Current Courses" widget.
73
+ const currentOnly = envBoolean(process.env.D2L_CURRENT_ONLY, "D2L_CURRENT_ONLY")
74
+ ?? store?.currentOnly
75
+ ?? false;
70
76
  // D2L_ACCESS_TOKEN beats D2L_SESSION_COOKIE beats the normal stored-credential
71
77
  // browser flow; both are validated whenever present regardless of which one
72
78
  // wins, so a typo in the losing variable still fails loudly at startup.
@@ -100,6 +106,7 @@ export async function loadConfig() {
100
106
  includeCourseIds,
101
107
  excludeCourseIds,
102
108
  activeOnly,
109
+ currentOnly,
103
110
  },
104
111
  };
105
112
  }
@@ -4,18 +4,49 @@
4
4
  * Licensed under MIT — see LICENSE file for details.
5
5
  */
6
6
  import { log } from "./logger.js";
7
+ /**
8
+ * True if `now` falls within [startDate, endDate].
9
+ *
10
+ * When both dates are null the course has no enrollment window at all — it's
11
+ * almost certainly an informational org unit (e.g. "Waterloo Campus",
12
+ * "Academic Integrity") rather than a term-bound course, so we return false.
13
+ * A single null side is treated as open-ended (start-only means "not yet
14
+ * ended", end-only means "already started"). A malformed date string is
15
+ * likewise treated as open-ended on that side rather than excluding the
16
+ * course outright.
17
+ *
18
+ * Adapted from LunaParker/brightspace-mcp-server (MIT), src/utils/course-filter.ts.
19
+ */
20
+ function isCurrentlyInWindow(startDate, endDate, now) {
21
+ // Both null → undated org unit, not a current semester course.
22
+ if (!startDate && !endDate)
23
+ return false;
24
+ if (startDate) {
25
+ const start = Date.parse(startDate);
26
+ if (!Number.isNaN(start) && start > now)
27
+ return false;
28
+ }
29
+ if (endDate) {
30
+ const end = Date.parse(endDate);
31
+ if (!Number.isNaN(end) && end < now)
32
+ return false;
33
+ }
34
+ return true;
35
+ }
7
36
  /**
8
37
  * Apply course filtering based on environment variable configuration.
9
38
  *
10
39
  * Filter priority:
11
40
  * 1. activeOnly — exclude inactive courses (default: true)
12
- * 2. includeCourseIds — whitelist (only these courses)
13
- * 3. excludeCourseIds — blacklist (remove these courses)
41
+ * 2. currentOnly — exclude courses outside their Access.StartDate/EndDate
42
+ * window, matching Brightspace's "Current Courses" widget (default: false)
43
+ * 3. includeCourseIds — whitelist (only these courses)
44
+ * 4. excludeCourseIds — blacklist (remove these courses)
14
45
  *
15
46
  * Tool-level courseId params bypass this filter entirely —
16
47
  * if user explicitly requests courseId=X, honor it regardless of config.
17
48
  */
18
- export function applyCourseFilter(courses, config) {
49
+ export function applyCourseFilter(courses, config, now = Date.now()) {
19
50
  let filtered = courses;
20
51
  const originalCount = courses.length;
21
52
  if (config.activeOnly) {
@@ -26,6 +57,9 @@ export function applyCourseFilter(courses, config) {
26
57
  // removes about 70 percent of the requests an all-courses call would make.
27
58
  filtered = filtered.filter(c => c.canAccess !== false);
28
59
  }
60
+ if (config.currentOnly) {
61
+ filtered = filtered.filter(c => isCurrentlyInWindow(c.startDate, c.endDate, now));
62
+ }
29
63
  if (config.includeCourseIds && config.includeCourseIds.length > 0) {
30
64
  filtered = filtered.filter(c => config.includeCourseIds.includes(c.id));
31
65
  }
@@ -35,6 +69,7 @@ export function applyCourseFilter(courses, config) {
35
69
  if (filtered.length !== originalCount) {
36
70
  log("DEBUG", `Course filter: ${originalCount} -> ${filtered.length} courses`, {
37
71
  activeOnly: config.activeOnly,
72
+ currentOnly: config.currentOnly,
38
73
  include: config.includeCourseIds,
39
74
  exclude: config.excludeCourseIds,
40
75
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "brightspace-mcp-server",
3
- "version": "3.9.18",
3
+ "version": "3.9.20",
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",