brightspace-mcp-server 3.9.19 → 3.9.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/build/tools/download-file.js +218 -70
- package/build/tools/get-announcements.js +92 -27
- package/build/tools/get-calendar-events.js +25 -3
- package/build/tools/get-my-grades.js +65 -7
- package/build/tools/get-roster.js +121 -78
- package/build/tools/get-upcoming-due-dates.js +34 -4
- package/build/tools/schemas.js +2 -2
- package/build/tools/tool-helpers.js +34 -8
- package/package.json +1 -1
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
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
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
|
|
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
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllItems } from "../api/paginate.js";
|
|
8
8
|
import { GetAnnouncementsSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { matchesModifiedSince } from "../utils/modified-since.js";
|
|
@@ -97,22 +97,53 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
97
97
|
const cutoff = modifiedSince ? new Date(modifiedSince) : null;
|
|
98
98
|
// Single course case
|
|
99
99
|
if (courseId) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
100
|
+
try {
|
|
101
|
+
const newsItems = await fetchCourseNews(apiClient, courseId);
|
|
102
|
+
// Drop drafts, then map to clean objects
|
|
103
|
+
const published = newsItems.filter(isPublishedNewsItem).map(mapNewsItem);
|
|
104
|
+
const matched = cutoff
|
|
105
|
+
? published.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
|
|
106
|
+
: published;
|
|
107
|
+
const announcements = matched.sort(newestFirst).slice(0, count);
|
|
108
|
+
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements for course ${courseId}`);
|
|
109
|
+
return toolResponse(modifiedSince
|
|
110
|
+
? {
|
|
111
|
+
announcements,
|
|
112
|
+
modifiedSince,
|
|
113
|
+
returned: announcements.length,
|
|
114
|
+
filteredOut: published.length - matched.length,
|
|
115
|
+
}
|
|
116
|
+
: announcements);
|
|
117
|
+
}
|
|
118
|
+
catch (error) {
|
|
119
|
+
// A pending sign-in is not an empty course: the route never
|
|
120
|
+
// answered, so the result says so instead of reporting zero
|
|
121
|
+
// announcements as if that were a real measurement.
|
|
122
|
+
if (isAuthUnavailable(error)) {
|
|
123
|
+
log("DEBUG", `get_announcements: sign-in pending for course ${courseId}`, error);
|
|
124
|
+
const notice = "Sign-in to Brightspace is still in progress, so announcements for this course " +
|
|
125
|
+
`could not be fetched yet. ${authPendingNotice(error)} Call get_announcements again ` +
|
|
126
|
+
"once sign-in finishes.";
|
|
127
|
+
// With modifiedSince the response is already an object (that is
|
|
128
|
+
// the shape those callers already get), so authPending/notice
|
|
129
|
+
// join the rest of the JSON there. Without it the response is
|
|
130
|
+
// always a bare array — a shape change would break every
|
|
131
|
+
// existing caller — so the empty array stays content[0] and the
|
|
132
|
+
// notice becomes a second content block instead.
|
|
133
|
+
if (modifiedSince) {
|
|
134
|
+
return toolResponse({
|
|
135
|
+
announcements: [],
|
|
136
|
+
modifiedSince,
|
|
137
|
+
returned: 0,
|
|
138
|
+
filteredOut: 0,
|
|
139
|
+
authPending: true,
|
|
140
|
+
notice,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return toolResponseWithNotice([], notice);
|
|
114
144
|
}
|
|
115
|
-
|
|
145
|
+
throw error;
|
|
146
|
+
}
|
|
116
147
|
}
|
|
117
148
|
// All courses case
|
|
118
149
|
// First, fetch enrolled courses. isActive=true is the configured
|
|
@@ -152,13 +183,24 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
152
183
|
log("DEBUG", `get_announcements: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
|
|
153
184
|
return [];
|
|
154
185
|
}
|
|
186
|
+
// A pending sign-in only means this course's route never
|
|
187
|
+
// answered — it says nothing about the other courses, whose
|
|
188
|
+
// requests may already have gone out independently. Mark this
|
|
189
|
+
// one rather than failing the whole call and losing every
|
|
190
|
+
// course that *did* answer.
|
|
191
|
+
if (isAuthUnavailable(error)) {
|
|
192
|
+
log("DEBUG", `get_announcements: sign-in pending for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`);
|
|
193
|
+
return { authPending: true, courseId: item.OrgUnit.Id, authError: error };
|
|
194
|
+
}
|
|
155
195
|
throw error; // Re-throw other errors
|
|
156
196
|
}
|
|
157
197
|
});
|
|
158
198
|
const results = await Promise.allSettled(announcementPromises);
|
|
159
|
-
const
|
|
199
|
+
const settled = results
|
|
160
200
|
.filter((r) => r.status === "fulfilled")
|
|
161
|
-
.
|
|
201
|
+
.map((r) => r.value);
|
|
202
|
+
const pending = settled.filter((v) => !Array.isArray(v) && v?.authPending === true);
|
|
203
|
+
const allAnnouncements = settled.filter((v) => Array.isArray(v)).flat();
|
|
162
204
|
const allMatched = cutoff
|
|
163
205
|
? allAnnouncements.filter((a) => matchesModifiedSince(a.lastModified, cutoff))
|
|
164
206
|
: allAnnouncements;
|
|
@@ -166,15 +208,38 @@ export function registerGetAnnouncements(server, apiClient, config) {
|
|
|
166
208
|
const announcements = allMatched
|
|
167
209
|
.sort(newestFirst)
|
|
168
210
|
.slice(0, count);
|
|
169
|
-
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
211
|
+
log("INFO", `get_announcements: Retrieved ${announcements.length} announcements (out of ${allAnnouncements.length} total ` +
|
|
212
|
+
`across ${enrollmentItems.length} courses${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
|
|
213
|
+
// With modifiedSince the response is already an object (the shape
|
|
214
|
+
// those callers already get), so authPending/unavailableCourseIds/
|
|
215
|
+
// notice join the rest of the JSON there. Without it the response is
|
|
216
|
+
// always a bare array — a shape change would break every existing
|
|
217
|
+
// caller — so a pending course adds a second content block carrying
|
|
218
|
+
// the notice instead of changing content[0].
|
|
219
|
+
if (!modifiedSince) {
|
|
220
|
+
if (pending.length === 0)
|
|
221
|
+
return toolResponse(announcements);
|
|
222
|
+
return toolResponseWithNotice(announcements, "Sign-in to Brightspace is still in progress, so announcements for " +
|
|
223
|
+
`${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
|
|
224
|
+
`fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
|
|
225
|
+
"sign-in finishes.");
|
|
226
|
+
}
|
|
227
|
+
const response = {
|
|
228
|
+
announcements,
|
|
229
|
+
modifiedSince,
|
|
230
|
+
returned: announcements.length,
|
|
231
|
+
filteredOut: allAnnouncements.length - allMatched.length,
|
|
232
|
+
};
|
|
233
|
+
if (pending.length > 0) {
|
|
234
|
+
response.authPending = true;
|
|
235
|
+
response.unavailableCourseIds = pending.map((c) => c.courseId);
|
|
236
|
+
response.notice =
|
|
237
|
+
"Sign-in to Brightspace is still in progress, so announcements for " +
|
|
238
|
+
`${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
|
|
239
|
+
`fetched yet. ${authPendingNotice(pending[0].authError)} Call get_announcements again once ` +
|
|
240
|
+
"sign-in finishes.";
|
|
241
|
+
}
|
|
242
|
+
return toolResponse(response);
|
|
178
243
|
}
|
|
179
244
|
catch (error) {
|
|
180
245
|
return sanitizeError(error);
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Licensed under MIT — see LICENSE file for details.
|
|
5
5
|
*/
|
|
6
6
|
import { GetCalendarEventsSchema } from "./schemas.js";
|
|
7
|
-
import { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
|
|
7
|
+
import { toolResponse, toolResponseWithNotice, errorResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
8
8
|
import { log } from "../utils/logger.js";
|
|
9
9
|
import { resolveCourses } from "./resolve-courses.js";
|
|
10
10
|
import { fetchCourseCalendarEvents } from "./calendar-events.js";
|
|
@@ -31,17 +31,39 @@ export function registerGetCalendarEvents(server, apiClient, config) {
|
|
|
31
31
|
}
|
|
32
32
|
const courses = await resolveCourses(apiClient, config, courseId);
|
|
33
33
|
const results = await Promise.allSettled(courses.map((course) => fetchCourseCalendarEvents(apiClient, config.baseUrl, course, windowStart, windowEnd)));
|
|
34
|
+
// A pending sign-in only means that course's route never answered —
|
|
35
|
+
// it says nothing about the other courses, whose requests may already
|
|
36
|
+
// have gone out independently. Collect it rather than failing the
|
|
37
|
+
// whole call and losing every course that *did* answer.
|
|
38
|
+
const pendingCourseIds = [];
|
|
39
|
+
let firstAuthError = null;
|
|
34
40
|
const events = results
|
|
35
41
|
.flatMap((result, i) => {
|
|
36
42
|
if (result.status === "fulfilled")
|
|
37
43
|
return result.value;
|
|
44
|
+
if (isAuthUnavailable(result.reason)) {
|
|
45
|
+
pendingCourseIds.push(courses[i].id);
|
|
46
|
+
firstAuthError ??= result.reason;
|
|
47
|
+
log("DEBUG", `get_calendar_events: sign-in pending for course ${courses[i].id}`, result.reason);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
38
50
|
log("DEBUG", `get_calendar_events: skipping course ${courses[i].id} after fetch failure`, result.reason);
|
|
39
51
|
return [];
|
|
40
52
|
})
|
|
41
53
|
.filter((event) => includeGenerated || !event.generatedFrom)
|
|
42
54
|
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime());
|
|
43
|
-
log("INFO", `get_calendar_events: Retrieved ${events.length} events across ${courses.length} courses`
|
|
44
|
-
|
|
55
|
+
log("INFO", `get_calendar_events: Retrieved ${events.length} events across ${courses.length} courses` +
|
|
56
|
+
(pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : ""));
|
|
57
|
+
// The response is always a bare array, pending sign-in or not — a
|
|
58
|
+
// shape change would break every existing caller. A pending course
|
|
59
|
+
// instead adds a second content block carrying the notice, which
|
|
60
|
+
// names which course ids are unavailable.
|
|
61
|
+
if (pendingCourseIds.length === 0) {
|
|
62
|
+
return toolResponse(events);
|
|
63
|
+
}
|
|
64
|
+
return toolResponseWithNotice(events, "Sign-in to Brightspace is still in progress, so calendar events for " +
|
|
65
|
+
`${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
|
|
66
|
+
`yet. ${authPendingNotice(firstAuthError)} Call get_calendar_events again once sign-in finishes.`);
|
|
45
67
|
}
|
|
46
68
|
catch (error) {
|
|
47
69
|
return sanitizeError(error);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllItems } from "../api/paginate.js";
|
|
8
8
|
import { GetMyGradesSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, sanitizeError, errorResponse, isAuthUnavailable, authPendingNotice, } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
import { applyCourseFilter } from "../utils/course-filter.js";
|
|
12
12
|
import { gradebookUrl } from "../utils/deep-links.js";
|
|
@@ -45,6 +45,23 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
45
45
|
return errorResponse("Your institution restricts grade API access for this course. " +
|
|
46
46
|
`View your grades directly in Brightspace: ${url}`);
|
|
47
47
|
}
|
|
48
|
+
// A pending sign-in is not an empty gradebook: the route never
|
|
49
|
+
// answered, so the result says so instead of reporting zero grades
|
|
50
|
+
// as if that were a real measurement. The envelope stays a
|
|
51
|
+
// success — existing callers that only read `grades` keep
|
|
52
|
+
// working — with authPending/notice added for callers that want
|
|
53
|
+
// to tell "no grades" apart from "couldn't check".
|
|
54
|
+
if (isAuthUnavailable(error)) {
|
|
55
|
+
log("DEBUG", `get_my_grades: sign-in pending for course ${courseId}`, error);
|
|
56
|
+
return toolResponse({
|
|
57
|
+
courseId,
|
|
58
|
+
grades: [],
|
|
59
|
+
authPending: true,
|
|
60
|
+
notice: "Sign-in to Brightspace is still in progress, so grades for this course " +
|
|
61
|
+
`could not be fetched yet. ${authPendingNotice(error)} Call get_my_grades again ` +
|
|
62
|
+
"once sign-in finishes.",
|
|
63
|
+
});
|
|
64
|
+
}
|
|
48
65
|
throw error;
|
|
49
66
|
}
|
|
50
67
|
// Map to clean objects
|
|
@@ -120,6 +137,20 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
120
137
|
gradeUrl: gradebookUrl(config.baseUrl, item.OrgUnit.Id),
|
|
121
138
|
};
|
|
122
139
|
}
|
|
140
|
+
// A pending sign-in only means this course's route never
|
|
141
|
+
// answered — it says nothing about the other courses, whose
|
|
142
|
+
// requests may already have gone out independently. Mark this one
|
|
143
|
+
// rather than failing the whole call and losing every course that
|
|
144
|
+
// *did* answer.
|
|
145
|
+
if (isAuthUnavailable(error)) {
|
|
146
|
+
log("DEBUG", `get_my_grades: sign-in pending for course ${item.OrgUnit.Id} (${item.OrgUnit.Name})`);
|
|
147
|
+
return {
|
|
148
|
+
courseId: item.OrgUnit.Id,
|
|
149
|
+
courseName: item.OrgUnit.Name,
|
|
150
|
+
authPending: true,
|
|
151
|
+
authError: error,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
123
154
|
throw error; // Re-throw other errors
|
|
124
155
|
}
|
|
125
156
|
});
|
|
@@ -127,15 +158,42 @@ export function registerGetMyGrades(server, apiClient, config) {
|
|
|
127
158
|
const settled = results
|
|
128
159
|
.filter((r) => r.status === "fulfilled")
|
|
129
160
|
.map((r) => r.value);
|
|
130
|
-
const
|
|
131
|
-
.filter((r) => !r.restricted)
|
|
132
|
-
.map(({ restricted: _restricted, ...rest }) => rest);
|
|
161
|
+
const pending = settled.filter((c) => c.authPending);
|
|
133
162
|
const restrictedCourses = settled
|
|
134
|
-
.filter((
|
|
163
|
+
.filter((c) => c.restricted)
|
|
135
164
|
.map(({ restricted: _restricted, ...rest }) => rest);
|
|
165
|
+
const courses = settled
|
|
166
|
+
.filter((c) => !c.authPending && !c.restricted)
|
|
167
|
+
.map(({ courseId, courseName, grades }) => ({ courseId, courseName, grades }));
|
|
136
168
|
log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses, ` +
|
|
137
|
-
`${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled
|
|
138
|
-
|
|
169
|
+
`${restrictedCourses.length} restricted (out of ${enrollmentItems.length} enrolled` +
|
|
170
|
+
`${pending.length > 0 ? `, ${pending.length} pending sign-in` : ""})`);
|
|
171
|
+
// Pending courses keep `grades: []` so callers that read
|
|
172
|
+
// `courses[i].grades` still get an array; authPending marks it as
|
|
173
|
+
// unchecked rather than empty. `restrictedCourses` is always present
|
|
174
|
+
// (even empty) so callers can rely on the field existing.
|
|
175
|
+
const response = {
|
|
176
|
+
courses: [
|
|
177
|
+
...courses,
|
|
178
|
+
...pending.map(({ courseId, courseName }) => ({
|
|
179
|
+
courseId,
|
|
180
|
+
courseName,
|
|
181
|
+
grades: [],
|
|
182
|
+
authPending: true,
|
|
183
|
+
})),
|
|
184
|
+
],
|
|
185
|
+
restrictedCourses,
|
|
186
|
+
};
|
|
187
|
+
if (pending.length > 0) {
|
|
188
|
+
response.authPending = true;
|
|
189
|
+
response.unavailableCourseIds = pending.map((c) => c.courseId);
|
|
190
|
+
response.notice =
|
|
191
|
+
"Sign-in to Brightspace is still in progress, so grades for " +
|
|
192
|
+
`${pending.length} course(s) (${pending.map((c) => c.courseId).join(", ")}) could not be ` +
|
|
193
|
+
`fetched yet. ${authPendingNotice(pending[0].authError)} Call get_my_grades again once ` +
|
|
194
|
+
"sign-in finishes.";
|
|
195
|
+
}
|
|
196
|
+
return toolResponse(response);
|
|
139
197
|
}
|
|
140
198
|
catch (error) {
|
|
141
199
|
return sanitizeError(error);
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { fetchAllObjects } from "../api/paginate.js";
|
|
8
8
|
import { GetRosterSchema, } from "./schemas.js";
|
|
9
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
9
|
+
import { toolResponse, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
10
10
|
import { log } from "../utils/logger.js";
|
|
11
11
|
// Purdue-specific role IDs. These are institution-specific values.
|
|
12
12
|
// If using at another institution, you may need to adjust these.
|
|
@@ -55,91 +55,134 @@ export function registerGetRoster(server, apiClient) {
|
|
|
55
55
|
log("DEBUG", "get_roster tool called", { args });
|
|
56
56
|
// Parse and validate input
|
|
57
57
|
const { courseId, includeStudents, searchTerm, limit } = GetRosterSchema.parse(args);
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
58
|
+
try {
|
|
59
|
+
const allUsers = [];
|
|
60
|
+
let authFailure;
|
|
61
|
+
let roleFallbackUsed = false;
|
|
62
|
+
if (!includeStudents) {
|
|
63
|
+
// Fetch instructors and TAs in parallel
|
|
64
|
+
const [instructorResult, taResult] = await Promise.allSettled([
|
|
65
|
+
fetchClasslistUsers(apiClient, courseId, {
|
|
66
|
+
roleId: INSTRUCTOR_ROLE_ID,
|
|
67
|
+
searchTerm,
|
|
68
|
+
}),
|
|
69
|
+
fetchClasslistUsers(apiClient, courseId, {
|
|
70
|
+
roleId: TA_ROLE_ID,
|
|
71
|
+
searchTerm,
|
|
72
|
+
}),
|
|
73
|
+
]);
|
|
74
|
+
// A pending sign-in means that route can't be trusted to have
|
|
75
|
+
// actually asked Brightspace anything — unlike a role group that
|
|
76
|
+
// is genuinely empty, which is a real measurement. Note it rather
|
|
77
|
+
// than quietly reporting half the roster as the whole answer, but
|
|
78
|
+
// keep whichever route *did* answer instead of discarding it: a
|
|
79
|
+
// roster that lost its TAs to a pending sign-in should still show
|
|
80
|
+
// the instructor it already has.
|
|
81
|
+
authFailure = [instructorResult, taResult].find((r) => r.status === "rejected" && isAuthUnavailable(r.reason));
|
|
82
|
+
// Merge results
|
|
83
|
+
if (instructorResult.status === "fulfilled") {
|
|
84
|
+
allUsers.push(...instructorResult.value);
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
log("WARN", "get_roster: Failed to fetch instructors", {
|
|
88
|
+
error: instructorResult.reason,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
if (taResult.status === "fulfilled") {
|
|
92
|
+
allUsers.push(...taResult.value);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
log("WARN", "get_roster: Failed to fetch TAs", {
|
|
96
|
+
error: taResult.reason,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// Purdue's role IDs found nobody — on a non-Purdue tenant they
|
|
100
|
+
// likely mean a different role, or none. Fall back to one
|
|
101
|
+
// unfiltered classlist fetch and match on the role display name
|
|
102
|
+
// the tenant itself reports instead of an institution-specific ID.
|
|
103
|
+
// A genuine failure of this fetch is intentionally not caught
|
|
104
|
+
// here: it should surface as a tool error rather than silently
|
|
105
|
+
// producing an empty staff list.
|
|
106
|
+
if (allUsers.length === 0) {
|
|
107
|
+
const everyone = await fetchClasslistUsers(apiClient, courseId, {
|
|
108
|
+
searchTerm,
|
|
109
|
+
});
|
|
110
|
+
allUsers.push(...everyone.filter(isTeachingRoleByName));
|
|
111
|
+
roleFallbackUsed = true;
|
|
112
|
+
}
|
|
83
113
|
}
|
|
84
114
|
else {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
});
|
|
115
|
+
// Fetch all users
|
|
116
|
+
allUsers.push(...(await fetchClasslistUsers(apiClient, courseId, { searchTerm })));
|
|
88
117
|
}
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
if (
|
|
97
|
-
|
|
98
|
-
|
|
118
|
+
// A very large roster would swamp the response, so it is capped. The
|
|
119
|
+
// cap is reported in the payload rather than only in a log line the
|
|
120
|
+
// model never sees: a 340 person lecture used to look like a 100
|
|
121
|
+
// person one, with nothing to say otherwise.
|
|
122
|
+
const total = allUsers.length;
|
|
123
|
+
const truncated = total > limit;
|
|
124
|
+
const kept = truncated ? allUsers.slice(0, limit) : allUsers;
|
|
125
|
+
if (truncated) {
|
|
126
|
+
log("WARN", "get_roster: Result set exceeds the limit, truncating", {
|
|
127
|
+
total,
|
|
128
|
+
returned: kept.length,
|
|
99
129
|
});
|
|
100
|
-
allUsers.push(...everyone.filter(isTeachingRoleByName));
|
|
101
|
-
roleFallbackUsed = true;
|
|
102
130
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const truncated = total > limit;
|
|
114
|
-
const kept = truncated ? allUsers.slice(0, limit) : allUsers;
|
|
115
|
-
if (truncated) {
|
|
116
|
-
log("WARN", "get_roster: Result set exceeds the limit, truncating", {
|
|
131
|
+
// Map to clean output
|
|
132
|
+
const users = kept.map((user) => ({
|
|
133
|
+
name: user.DisplayName,
|
|
134
|
+
email: user.Email || null,
|
|
135
|
+
role: user.ClasslistRoleDisplayName,
|
|
136
|
+
}));
|
|
137
|
+
log("INFO", `get_roster: Retrieved ${users.length} users for course ${courseId}` +
|
|
138
|
+
(authFailure ? " (sign-in pending for one route)" : ""));
|
|
139
|
+
return toolResponse({
|
|
140
|
+
courseId,
|
|
117
141
|
total,
|
|
118
|
-
returned:
|
|
142
|
+
returned: users.length,
|
|
143
|
+
truncated,
|
|
144
|
+
...(truncated
|
|
145
|
+
? { note: `Showing ${users.length} of ${total}. Raise the limit argument to see more.` }
|
|
146
|
+
: {}),
|
|
147
|
+
...(roleFallbackUsed
|
|
148
|
+
? {
|
|
149
|
+
roleFilter: "No users matched the default instructor/TA role IDs, so staff were found by matching role display names (instructor, professor, lecturer, teaching assistant, coordinator, grader, TA) instead.",
|
|
150
|
+
}
|
|
151
|
+
: {}),
|
|
152
|
+
users,
|
|
153
|
+
...(authFailure
|
|
154
|
+
? {
|
|
155
|
+
authPending: true,
|
|
156
|
+
notice: "Sign-in to Brightspace is still in progress, so part of the roster for this " +
|
|
157
|
+
`course could not be fetched yet. ${authPendingNotice(authFailure.reason)} Call ` +
|
|
158
|
+
"get_roster again once sign-in finishes.",
|
|
159
|
+
}
|
|
160
|
+
: {}),
|
|
119
161
|
});
|
|
120
162
|
}
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
163
|
+
catch (error) {
|
|
164
|
+
// A pending sign-in is not an empty roster: the route never
|
|
165
|
+
// answered, so the result says so instead of reporting zero users
|
|
166
|
+
// as if that were a real measurement. The envelope stays a success
|
|
167
|
+
// — existing callers that only read `users` keep working — with
|
|
168
|
+
// authPending/notice added for callers that want to tell "empty
|
|
169
|
+
// roster" apart from "couldn't check".
|
|
170
|
+
if (isAuthUnavailable(error)) {
|
|
171
|
+
log("DEBUG", `get_roster: sign-in pending for course ${courseId}`, error);
|
|
172
|
+
return toolResponse({
|
|
173
|
+
courseId,
|
|
174
|
+
total: 0,
|
|
175
|
+
returned: 0,
|
|
176
|
+
truncated: false,
|
|
177
|
+
users: [],
|
|
178
|
+
authPending: true,
|
|
179
|
+
notice: "Sign-in to Brightspace is still in progress, so the roster for this course " +
|
|
180
|
+
`could not be fetched yet. ${authPendingNotice(error)} Call get_roster again once ` +
|
|
181
|
+
"sign-in finishes.",
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
143
186
|
}
|
|
144
187
|
catch (error) {
|
|
145
188
|
return sanitizeError(error);
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { DEFAULT_CACHE_TTLS } from "../api/index.js";
|
|
7
7
|
import { GetUpcomingDueDatesSchema, } from "./schemas.js";
|
|
8
|
-
import { toolResponse, sanitizeError } from "./tool-helpers.js";
|
|
8
|
+
import { toolResponse, toolResponseWithNotice, sanitizeError, isAuthUnavailable, authPendingNotice } from "./tool-helpers.js";
|
|
9
9
|
import { log } from "../utils/logger.js";
|
|
10
10
|
import { assignmentUrl, quizUrl, discussionUrl } from "../utils/deep-links.js";
|
|
11
11
|
import { dueIn } from "../utils/due-in.js";
|
|
@@ -35,6 +35,8 @@ async function fetchDiscussionDueTopics(apiClient, courseId) {
|
|
|
35
35
|
topics.push(...unwrapList(forumTopics));
|
|
36
36
|
}
|
|
37
37
|
catch (error) {
|
|
38
|
+
if (isAuthUnavailable(error))
|
|
39
|
+
throw error;
|
|
38
40
|
log("DEBUG", `get_upcoming_due_dates: failed to fetch topics for forum ${forum.ForumId} in course ${courseId}`, error);
|
|
39
41
|
}
|
|
40
42
|
}
|
|
@@ -79,6 +81,12 @@ async function fetchCourseDueItems(apiClient, baseUrl, course, from, to) {
|
|
|
79
81
|
fetchDiscussionDueTopics(apiClient, course.id),
|
|
80
82
|
fetchCourseCalendarEvents(apiClient, baseUrl, course, from, to),
|
|
81
83
|
]);
|
|
84
|
+
// Each route may fail on its own without costing the others, but a failed
|
|
85
|
+
// sign-in means none of them answered: report that, not an empty course.
|
|
86
|
+
for (const result of [dropboxResult, quizResult, discussionResult, calendarResult]) {
|
|
87
|
+
if (result.status === "rejected" && isAuthUnavailable(result.reason))
|
|
88
|
+
throw result.reason;
|
|
89
|
+
}
|
|
82
90
|
const items = [];
|
|
83
91
|
if (dropboxResult.status === "fulfilled") {
|
|
84
92
|
for (const folder of unwrapList(dropboxResult.value)) {
|
|
@@ -180,9 +188,21 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
|
180
188
|
log("DEBUG", `get_upcoming_due_dates: querying ${courses.length} course(s), window=${daysAhead} days`);
|
|
181
189
|
// Fetch every course in parallel; the API client rate limits itself
|
|
182
190
|
const results = await Promise.allSettled(courses.map((course) => fetchCourseDueItems(apiClient, config.baseUrl, course, now, windowEnd)));
|
|
183
|
-
|
|
191
|
+
// A pending sign-in only means that course's routes never answered —
|
|
192
|
+
// it says nothing about the other courses, whose requests may already
|
|
193
|
+
// have gone out independently. Collect it rather than failing the
|
|
194
|
+
// whole call and losing every course that *did* answer.
|
|
195
|
+
const pendingCourseIds = [];
|
|
196
|
+
let firstAuthError = null;
|
|
197
|
+
const items = results.flatMap((result, i) => {
|
|
184
198
|
if (result.status === "fulfilled")
|
|
185
199
|
return result.value;
|
|
200
|
+
if (isAuthUnavailable(result.reason)) {
|
|
201
|
+
pendingCourseIds.push(courses[i].id);
|
|
202
|
+
firstAuthError ??= result.reason;
|
|
203
|
+
log("DEBUG", `get_upcoming_due_dates: sign-in pending for course ${courses[i].id}`, result.reason);
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
186
206
|
log("DEBUG", "get_upcoming_due_dates: skipping course after fetch failure", result.reason);
|
|
187
207
|
return [];
|
|
188
208
|
});
|
|
@@ -193,8 +213,18 @@ export function registerGetUpcomingDueDates(server, apiClient, config) {
|
|
|
193
213
|
return Number.isFinite(due) && due >= now && due <= windowEnd;
|
|
194
214
|
})
|
|
195
215
|
.sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
|
|
196
|
-
log("INFO", `get_upcoming_due_dates: Retrieved ${upcoming.length} items across ${courses.length} courses`
|
|
197
|
-
|
|
216
|
+
log("INFO", `get_upcoming_due_dates: Retrieved ${upcoming.length} items across ${courses.length} courses` +
|
|
217
|
+
(pendingCourseIds.length > 0 ? ` (${pendingCourseIds.length} pending sign-in)` : ""));
|
|
218
|
+
// The response is always a bare array, pending sign-in or not — a
|
|
219
|
+
// shape change would break every existing caller. A pending course
|
|
220
|
+
// instead adds a second content block carrying the notice, which
|
|
221
|
+
// names which course ids are unavailable.
|
|
222
|
+
if (pendingCourseIds.length === 0) {
|
|
223
|
+
return toolResponse(upcoming);
|
|
224
|
+
}
|
|
225
|
+
return toolResponseWithNotice(upcoming, "Sign-in to Brightspace is still in progress, so upcoming due dates for " +
|
|
226
|
+
`${pendingCourseIds.length} course(s) (${pendingCourseIds.join(", ")}) could not be fetched ` +
|
|
227
|
+
`yet. ${authPendingNotice(firstAuthError)} Call get_upcoming_due_dates again once sign-in finishes.`);
|
|
198
228
|
}
|
|
199
229
|
catch (error) {
|
|
200
230
|
return sanitizeError(error);
|
package/build/tools/schemas.js
CHANGED
|
@@ -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,52 @@ 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
|
|
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
|
-
|
|
38
|
+
return withUpdateNotice([
|
|
25
39
|
{
|
|
26
40
|
type: "text",
|
|
27
41
|
text: JSON.stringify(data),
|
|
28
42
|
},
|
|
29
|
-
];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
43
|
+
]);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Wrap data as an MCP-compatible tool result, with a notice appended as a
|
|
47
|
+
* second content block.
|
|
48
|
+
*
|
|
49
|
+
* Some callers already depend on content[0] being a specific shape (a bare
|
|
50
|
+
* array, for instance) and must not see that shape change just because there
|
|
51
|
+
* is something to tell the user — a pending sign-in, say. This keeps
|
|
52
|
+
* content[0] exactly what toolResponse(data) alone would have produced and
|
|
53
|
+
* puts `notice` in content[1], ahead of (and separate from) any update notice
|
|
54
|
+
* toolResponse would itself append.
|
|
55
|
+
*/
|
|
56
|
+
export function toolResponseWithNotice(data, notice) {
|
|
57
|
+
const result = toolResponse(data);
|
|
58
|
+
result.content.push({ type: "text", text: notice });
|
|
59
|
+
return result;
|
|
34
60
|
}
|
|
35
61
|
/**
|
|
36
62
|
* Wrap error message as MCP-compatible tool result
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brightspace-mcp-server",
|
|
3
|
-
"version": "3.9.
|
|
3
|
+
"version": "3.9.21",
|
|
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",
|