brightspace-d2l-mcp 1.0.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.
Files changed (58) hide show
  1. package/LICENSE +24 -0
  2. package/README.md +269 -0
  3. package/build/api/cache.js +49 -0
  4. package/build/api/client.js +345 -0
  5. package/build/api/errors.js +48 -0
  6. package/build/api/index.js +12 -0
  7. package/build/api/paginate.js +70 -0
  8. package/build/api/rate-limiter.js +52 -0
  9. package/build/api/retry.js +39 -0
  10. package/build/api/types.js +11 -0
  11. package/build/api/version-discovery.js +43 -0
  12. package/build/auth/authenticator.js +146 -0
  13. package/build/auth/browser.js +82 -0
  14. package/build/auth/index.js +6 -0
  15. package/build/auth/login.js +227 -0
  16. package/build/auth/session-store.js +46 -0
  17. package/build/auth/token-manager.js +150 -0
  18. package/build/auth/token-mint.js +75 -0
  19. package/build/cli/clients.js +344 -0
  20. package/build/cli/main.js +162 -0
  21. package/build/cli/school.js +74 -0
  22. package/build/cli/setup.js +189 -0
  23. package/build/cli/ui.js +39 -0
  24. package/build/index.js +13 -0
  25. package/build/server.js +175 -0
  26. package/build/tools/download-file.js +167 -0
  27. package/build/tools/get-announcements.js +116 -0
  28. package/build/tools/get-assignment-files.js +159 -0
  29. package/build/tools/get-assignments.js +343 -0
  30. package/build/tools/get-classlist-emails.js +38 -0
  31. package/build/tools/get-course-content.js +175 -0
  32. package/build/tools/get-discussions.js +192 -0
  33. package/build/tools/get-my-courses.js +42 -0
  34. package/build/tools/get-my-grades.js +93 -0
  35. package/build/tools/get-roster.js +99 -0
  36. package/build/tools/get-syllabus.js +154 -0
  37. package/build/tools/get-todo.js +61 -0
  38. package/build/tools/get-upcoming-due-dates.js +138 -0
  39. package/build/tools/index.js +18 -0
  40. package/build/tools/schemas.js +91 -0
  41. package/build/tools/submission-status.js +71 -0
  42. package/build/tools/tool-helpers.js +62 -0
  43. package/build/types/index.js +2 -0
  44. package/build/utils/atomic-write.js +66 -0
  45. package/build/utils/config.js +60 -0
  46. package/build/utils/course-filter.js +24 -0
  47. package/build/utils/deep-links.js +13 -0
  48. package/build/utils/download-errors.js +14 -0
  49. package/build/utils/download-helpers.js +73 -0
  50. package/build/utils/errors.js +31 -0
  51. package/build/utils/file-validator.js +121 -0
  52. package/build/utils/html-converter.js +20 -0
  53. package/build/utils/logger.js +69 -0
  54. package/build/utils/paths.js +15 -0
  55. package/build/utils/pdf-extractor.js +18 -0
  56. package/build/utils/version.js +18 -0
  57. package/build/utils/zip-extract.js +139 -0
  58. package/package.json +104 -0
@@ -0,0 +1,154 @@
1
+ import { ApiError, DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetSyllabusSchema } from "./schemas.js";
3
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
4
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
5
+ import { secureDownload } from "../utils/download-helpers.js";
6
+ import { MAX_FILE_SIZE } from "../utils/file-validator.js";
7
+ import { extractPdfText } from "../utils/pdf-extractor.js";
8
+ import { log } from "../utils/logger.js";
9
+ import path from "node:path";
10
+ import fs from "node:fs/promises";
11
+ export function registerGetSyllabus(server, apiClient) {
12
+ server.registerTool("get_syllabus", {
13
+ title: "Get Course Syllabus",
14
+ description: "Fetch the syllabus/overview text and optional attachment for a course. Returns the course overview description as markdown. If downloadPath is provided, also downloads the syllabus attachment (e.g. PDF). IMPORTANT: You MUST ask the user where they want to save the file before calling this tool with a downloadPath.",
15
+ inputSchema: GetSyllabusSchema,
16
+ }, async (args) => {
17
+ try {
18
+ log("DEBUG", "get_syllabus tool called", { args });
19
+ const { courseId, downloadPath } = GetSyllabusSchema.parse(args);
20
+ // Validate downloadPath if provided
21
+ if (downloadPath !== undefined) {
22
+ if (!path.isAbsolute(downloadPath)) {
23
+ return errorResponse("Download path must be an absolute path (e.g., /Users/username/Downloads on Mac or C:\\Users\\username\\Downloads on Windows)");
24
+ }
25
+ try {
26
+ const stats = await fs.stat(downloadPath);
27
+ if (!stats.isDirectory()) {
28
+ return errorResponse(`Download path is not a directory: ${downloadPath}`);
29
+ }
30
+ }
31
+ catch (error) {
32
+ if (error?.code === "ENOENT") {
33
+ return errorResponse(`Download directory does not exist: ${downloadPath}`);
34
+ }
35
+ throw error;
36
+ }
37
+ }
38
+ // Fetch overview text
39
+ let overview = null;
40
+ try {
41
+ overview = await apiClient.get(apiClient.le(courseId, "/overview"), { ttl: DEFAULT_CACHE_TTLS.courseContent });
42
+ }
43
+ catch (error) {
44
+ if (error instanceof ApiError && error.status === 404) {
45
+ return toolResponse({
46
+ courseId,
47
+ description: null,
48
+ hasAttachment: false,
49
+ message: "No syllabus/overview found for this course.",
50
+ });
51
+ }
52
+ throw error;
53
+ }
54
+ // Convert description HTML to markdown
55
+ const description = overview?.Description?.Html
56
+ ? convertHtmlToMarkdown(overview.Description.Html)
57
+ : null;
58
+ // Always attempt to fetch the attachment so we can extract PDF text
59
+ let attachmentBuffer = null;
60
+ let attachmentFilename = "syllabus";
61
+ let hasAttachment = false;
62
+ try {
63
+ const response = await apiClient.getRaw(apiClient.le(courseId, "/overview/attachment"));
64
+ if (response.ok) {
65
+ hasAttachment = true;
66
+ // Check Content-Length before downloading body
67
+ const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
68
+ if (contentLength > MAX_FILE_SIZE) {
69
+ return errorResponse(`Attachment too large (${Math.round(contentLength / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
70
+ }
71
+ // Get filename from Content-Disposition header
72
+ const disposition = response.headers.get("Content-Disposition") ?? "";
73
+ const match = disposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/);
74
+ if (match?.[1]) {
75
+ attachmentFilename = match[1].replace(/['"]/g, "");
76
+ }
77
+ // Download body as buffer
78
+ attachmentBuffer = Buffer.from(await response.arrayBuffer());
79
+ if (attachmentBuffer.length > MAX_FILE_SIZE) {
80
+ return errorResponse(`Attachment too large (${Math.round(attachmentBuffer.length / 1024 / 1024)}MB). Maximum allowed: ${MAX_FILE_SIZE / 1024 / 1024}MB`);
81
+ }
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error instanceof ApiError && error.status === 404) {
86
+ hasAttachment = false;
87
+ }
88
+ else {
89
+ log("DEBUG", "Could not fetch syllabus attachment", error);
90
+ }
91
+ }
92
+ // Extract text from PDF attachment if available
93
+ let syllabusText = null;
94
+ let totalPages;
95
+ if (attachmentBuffer && attachmentFilename.toLowerCase().endsWith(".pdf")) {
96
+ const extracted = await extractPdfText(attachmentBuffer);
97
+ if (extracted) {
98
+ syllabusText = extracted.text;
99
+ totalPages = extracted.totalPages;
100
+ }
101
+ }
102
+ // Save to disk if downloadPath provided
103
+ let download;
104
+ if (downloadPath && attachmentBuffer) {
105
+ try {
106
+ const result = await secureDownload({
107
+ targetDir: downloadPath,
108
+ filename: attachmentFilename,
109
+ data: attachmentBuffer,
110
+ });
111
+ log("INFO", `Syllabus attachment downloaded: ${result.path} (${result.size} bytes)`);
112
+ download = {
113
+ success: true,
114
+ filePath: result.path,
115
+ fileSize: result.size,
116
+ mimeType: result.mime,
117
+ };
118
+ }
119
+ catch (error) {
120
+ log("ERROR", "Failed to save syllabus attachment", error);
121
+ download = {
122
+ success: false,
123
+ error: "Failed to save attachment to disk.",
124
+ };
125
+ }
126
+ }
127
+ else if (downloadPath && !attachmentBuffer) {
128
+ download = {
129
+ success: false,
130
+ error: "No attachment found for this course's syllabus.",
131
+ };
132
+ }
133
+ log("INFO", `get_syllabus: Retrieved overview for course ${courseId}`);
134
+ // Build response
135
+ const result = { courseId, description };
136
+ if (syllabusText) {
137
+ result.syllabusText = syllabusText;
138
+ if (totalPages)
139
+ result.totalPages = totalPages;
140
+ }
141
+ else {
142
+ result.hasAttachment = hasAttachment;
143
+ }
144
+ if (download) {
145
+ result.download = download;
146
+ }
147
+ return toolResponse(result);
148
+ }
149
+ catch (error) {
150
+ return sanitizeError(error);
151
+ }
152
+ });
153
+ }
154
+ //# sourceMappingURL=get-syllabus.js.map
@@ -0,0 +1,61 @@
1
+ import { GetTodoSchema } from "./schemas.js";
2
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
3
+ import { collectDueItems } from "./get-upcoming-due-dates.js";
4
+ const DAY = 86_400_000;
5
+ const DONE = new Set(["submitted", "graded"]);
6
+ const TO_DO = new Set(["not_submitted", "draft"]);
7
+ const LATER = new Set(["not_open_yet"]);
8
+ function hoursUntil(item, now) {
9
+ return Math.round((new Date(item.dueDate).getTime() - now) / 3_600_000);
10
+ }
11
+ export function buildTodo(unsorted, now = Date.now()) {
12
+ const annotate = (item) => ({ ...item, hoursUntilDue: hoursUntil(item, now) });
13
+ const due = (item) => new Date(item.dueDate).getTime();
14
+ const items = [...unsorted].sort((a, b) => due(a) - due(b));
15
+ const overdue = items.filter((i) => TO_DO.has(i.submissionStatus) && due(i) < now).map(annotate);
16
+ const toDo = items.filter((i) => TO_DO.has(i.submissionStatus) && due(i) >= now).map(annotate);
17
+ const notOpenYet = items.filter((i) => LATER.has(i.submissionStatus)).map(annotate);
18
+ const checkYourself = items
19
+ .filter((i) => !DONE.has(i.submissionStatus) && !TO_DO.has(i.submissionStatus) && !LATER.has(i.submissionStatus) && due(i) >= now)
20
+ .map(annotate);
21
+ const alreadyDone = items.filter((i) => DONE.has(i.submissionStatus)).map((i) => ({
22
+ title: i.title,
23
+ courseName: i.courseName,
24
+ dueDate: i.dueDate,
25
+ submissionStatus: i.submissionStatus,
26
+ submittedDate: i.submittedDate,
27
+ }));
28
+ return {
29
+ generatedAt: new Date(now).toISOString(),
30
+ guidance: "Recommend from doNow first (soonest due first), then overdue if it can still be submitted. " +
31
+ "Never recommend alreadyDone items. notOpenYet cannot be submitted until its startDate. " +
32
+ "checkYourself items (quizzes, restricted dropboxes) have unknown completion; ask the user. Dates are UTC.",
33
+ doNow: toDo,
34
+ overdue,
35
+ notOpenYet,
36
+ checkYourself,
37
+ alreadyDone,
38
+ };
39
+ }
40
+ export function registerGetTodo(server, apiClient, config) {
41
+ server.registerTool("get_todo", {
42
+ title: "What To Do Now",
43
+ description: "Prioritized to-do list across all courses: unfinished work due soon, recently overdue work, " +
44
+ "work not open yet, items to double-check (quizzes), and work already submitted. " +
45
+ "Use this first when the user asks what to work on, what's left, or what they should do now.",
46
+ inputSchema: GetTodoSchema,
47
+ }, async (args) => {
48
+ try {
49
+ const { daysAhead, includeOverdueDays } = GetTodoSchema.parse(args);
50
+ const items = await collectDueItems(apiClient, config, {
51
+ fromMs: -includeOverdueDays * DAY,
52
+ toMs: daysAhead * DAY,
53
+ });
54
+ return toolResponse(buildTodo(items));
55
+ }
56
+ catch (error) {
57
+ return sanitizeError(error);
58
+ }
59
+ });
60
+ }
61
+ //# sourceMappingURL=get-todo.js.map
@@ -0,0 +1,138 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetUpcomingDueDatesSchema } from "./schemas.js";
3
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
4
+ import { log } from "../utils/logger.js";
5
+ import { applyCourseFilter } from "../utils/course-filter.js";
6
+ import { assignmentUrl, quizUrl } from "../utils/deep-links.js";
7
+ import { fetchSubmissionStatus } from "./submission-status.js";
8
+ function unwrapList(raw) {
9
+ return Array.isArray(raw) ? raw : (raw?.Objects ?? []);
10
+ }
11
+ async function resolveCourses(apiClient, config, courseId) {
12
+ let items = [];
13
+ try {
14
+ const response = await apiClient.get(apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true"), { ttl: DEFAULT_CACHE_TTLS.enrollments });
15
+ items = response.Items ?? [];
16
+ }
17
+ catch (error) {
18
+ if (!courseId)
19
+ throw error;
20
+ log("DEBUG", "due dates: could not fetch enrollments for course name", error);
21
+ }
22
+ if (courseId) {
23
+ const match = items.find((item) => item.OrgUnit.Id === courseId);
24
+ return [{ id: courseId, name: match?.OrgUnit.Name ?? null }];
25
+ }
26
+ const filtered = applyCourseFilter(items.map((item) => ({
27
+ id: item.OrgUnit.Id,
28
+ name: item.OrgUnit.Name,
29
+ code: item.OrgUnit.Code,
30
+ isActive: item.Access.IsActive,
31
+ canAccess: item.Access.CanAccess,
32
+ })), config.courseFilter);
33
+ return filtered.map((course) => ({ id: course.id, name: course.name }));
34
+ }
35
+ async function fetchCourseDueItems(apiClient, baseUrl, course) {
36
+ const [dropboxResult, quizResult] = await Promise.allSettled([
37
+ apiClient.get(apiClient.le(course.id, "/dropbox/folders/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
38
+ apiClient.get(apiClient.le(course.id, "/quizzes/"), { ttl: DEFAULT_CACHE_TTLS.assignments }),
39
+ ]);
40
+ const items = [];
41
+ if (dropboxResult.status === "fulfilled") {
42
+ for (const folder of unwrapList(dropboxResult.value)) {
43
+ if (folder.IsHidden === true || !folder.DueDate)
44
+ continue;
45
+ items.push({
46
+ type: "assignment",
47
+ id: folder.Id,
48
+ title: folder.Name,
49
+ courseId: course.id,
50
+ courseName: course.name,
51
+ dueDate: folder.DueDate,
52
+ startDate: folder.Availability?.StartDate ?? null,
53
+ endDate: folder.Availability?.EndDate ?? null,
54
+ url: assignmentUrl(baseUrl, course.id, folder.Id),
55
+ folder,
56
+ });
57
+ }
58
+ }
59
+ else {
60
+ // 403 for past courses and similar: keep the other courses.
61
+ log("DEBUG", `due dates: failed to fetch dropbox folders for course ${course.id}`, dropboxResult.reason);
62
+ }
63
+ if (quizResult.status === "fulfilled") {
64
+ for (const quiz of unwrapList(quizResult.value)) {
65
+ if (quiz.IsActive === false)
66
+ continue;
67
+ // Many instructors set only an End Date, which is the effective deadline.
68
+ const dueDate = quiz.DueDate ?? quiz.EndDate;
69
+ if (!dueDate)
70
+ continue;
71
+ items.push({
72
+ type: "quiz",
73
+ id: quiz.QuizId,
74
+ title: quiz.Name,
75
+ courseId: course.id,
76
+ courseName: course.name,
77
+ dueDate,
78
+ startDate: quiz.StartDate ?? null,
79
+ endDate: quiz.EndDate ?? null,
80
+ url: quizUrl(baseUrl, course.id, quiz.QuizId),
81
+ });
82
+ }
83
+ }
84
+ else {
85
+ log("DEBUG", `due dates: failed to fetch quizzes for course ${course.id}`, quizResult.reason);
86
+ }
87
+ return items;
88
+ }
89
+ export async function collectDueItems(apiClient, config, options) {
90
+ const now = Date.now();
91
+ const courses = await resolveCourses(apiClient, config, options.courseId);
92
+ const results = await Promise.allSettled(courses.map((course) => fetchCourseDueItems(apiClient, config.baseUrl, course)));
93
+ const items = results.flatMap((result) => {
94
+ if (result.status === "fulfilled")
95
+ return result.value;
96
+ log("DEBUG", "due dates: skipping course after fetch failure", result.reason);
97
+ return [];
98
+ });
99
+ const inWindow = items
100
+ .filter((item) => {
101
+ const due = new Date(item.dueDate).getTime();
102
+ return Number.isFinite(due) && due >= now + options.fromMs && due <= now + options.toMs;
103
+ })
104
+ .sort((a, b) => new Date(a.dueDate).getTime() - new Date(b.dueDate).getTime());
105
+ return Promise.all(inWindow.map(async ({ folder, ...item }) => {
106
+ if (!folder) {
107
+ return { ...item, submitted: null, submissionStatus: "unknown", statusNote: "Brightspace does not show students their quiz attempts.", submittedDate: null };
108
+ }
109
+ const summary = await fetchSubmissionStatus(apiClient, item.courseId, folder);
110
+ return {
111
+ ...item,
112
+ submitted: summary.submitted,
113
+ submissionStatus: summary.submissionStatus,
114
+ ...(summary.statusNote ? { statusNote: summary.statusNote } : {}),
115
+ submittedDate: summary.submission?.submittedDate ?? null,
116
+ };
117
+ }));
118
+ }
119
+ export function registerGetUpcomingDueDates(server, apiClient, config) {
120
+ server.registerTool("get_upcoming_due_dates", {
121
+ title: "Get Upcoming Due Dates",
122
+ description: "Upcoming due dates across all courses, with whether each assignment is already submitted " +
123
+ "(submissionStatus: submitted, graded, draft, not_submitted, not_open_yet, closed, past_due, restricted, unknown). " +
124
+ "Dates are UTC ISO strings; convert to the user's local time. Use for deadlines and what's due.",
125
+ inputSchema: GetUpcomingDueDatesSchema,
126
+ }, async (args) => {
127
+ try {
128
+ const { daysAhead, courseId } = GetUpcomingDueDatesSchema.parse(args);
129
+ const upcoming = await collectDueItems(apiClient, config, { fromMs: 0, toMs: daysAhead * 86_400_000, courseId });
130
+ log("INFO", `get_upcoming_due_dates: ${upcoming.length} items`);
131
+ return toolResponse(upcoming);
132
+ }
133
+ catch (error) {
134
+ return sanitizeError(error);
135
+ }
136
+ });
137
+ }
138
+ //# sourceMappingURL=get-upcoming-due-dates.js.map
@@ -0,0 +1,18 @@
1
+ // Tool registration functions - barrel export
2
+ export { registerGetMyCourses } from "./get-my-courses.js";
3
+ export { registerGetUpcomingDueDates } from "./get-upcoming-due-dates.js";
4
+ export { registerGetMyGrades } from "./get-my-grades.js";
5
+ export { registerGetAnnouncements } from "./get-announcements.js";
6
+ export { registerGetAssignments } from "./get-assignments.js";
7
+ export { registerGetAssignmentFiles } from "./get-assignment-files.js";
8
+ export { registerGetCourseContent } from "./get-course-content.js";
9
+ export { registerDownloadFile } from "./download-file.js";
10
+ export { registerGetClasslistEmails } from "./get-classlist-emails.js";
11
+ export { registerGetRoster } from "./get-roster.js";
12
+ export { registerGetSyllabus } from "./get-syllabus.js";
13
+ export { registerGetDiscussions } from "./get-discussions.js";
14
+ export { registerGetTodo } from "./get-todo.js";
15
+ // Re-export shared helpers and schemas for convenience
16
+ export { toolResponse, errorResponse, sanitizeError } from "./tool-helpers.js";
17
+ export * from "./schemas.js";
18
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,91 @@
1
+ import { z } from "zod";
2
+ export const GetMyCoursesSchema = z.object({
3
+ activeOnly: z
4
+ .boolean()
5
+ .optional()
6
+ .describe("Only return currently active courses. Defaults to the server's configured activeOnly setting (true unless overridden)."),
7
+ });
8
+ export const GetUpcomingDueDatesSchema = z.object({
9
+ daysAhead: z.coerce.number().int().min(1).max(90).default(7).describe("Number of days ahead to look for due dates"),
10
+ courseId: z.coerce.number().int().positive().optional().describe("Filter to a specific course ID"),
11
+ });
12
+ export const GetTodoSchema = z.object({
13
+ daysAhead: z.coerce.number().int().min(1).max(60).default(14).describe("How many days ahead to include"),
14
+ includeOverdueDays: z.coerce.number().int().min(0).max(30).default(3).describe("Also include unfinished work that was due this many days ago"),
15
+ });
16
+ export const GetMyGradesSchema = z.object({
17
+ courseId: z.coerce.number().int().positive().optional().describe("Course ID to get grades for. If omitted, returns grades for all enrolled courses."),
18
+ });
19
+ export const GetAnnouncementsSchema = z.object({
20
+ courseId: z.coerce.number().int().positive().optional().describe("Course ID to get announcements for. If omitted, returns recent announcements across all courses."),
21
+ count: z.coerce.number().int().min(1).max(50).default(10).describe("Maximum number of announcements to return"),
22
+ });
23
+ export const GetAssignmentsSchema = z.object({
24
+ courseId: z.coerce.number().int().positive().optional()
25
+ .describe("Course ID to get assignments for. If omitted, returns assignments for all enrolled courses."),
26
+ });
27
+ export const GetCourseContentSchema = z.object({
28
+ courseId: z.coerce.number().int().positive()
29
+ .describe("Course ID to get content tree for."),
30
+ typeFilter: z.enum(["file", "link", "html", "video", "all"]).default("all").optional()
31
+ .describe("Optional filter to narrow results by content type."),
32
+ moduleTitle: z.string().optional()
33
+ .describe("Case-insensitive substring match on module titles. Only returns modules whose title contains this string (e.g. 'Labs', 'Staff', 'Homeworks'). Children of matching modules are included in full."),
34
+ maxDepth: z.coerce.number().int().min(1).max(10).optional()
35
+ .describe("Limit recursive depth of the content tree. Depth 1 returns top-level modules with direct children only. Useful for getting a table of contents without all nested content."),
36
+ });
37
+ export const GetClasslistEmailsSchema = z.object({
38
+ courseId: z.coerce.number().int().positive()
39
+ .describe("Course ID to get emails for."),
40
+ });
41
+ export const DownloadFileSchema = z.object({
42
+ courseId: z.coerce.number().int().positive()
43
+ .describe("Course ID the file belongs to."),
44
+ topicId: z.coerce.number().int().positive().optional()
45
+ .describe("Content topic ID to download (for course content files)."),
46
+ folderId: z.coerce.number().int().positive().optional()
47
+ .describe("Dropbox folder ID (for submission/feedback file downloads)."),
48
+ fileId: z.coerce.number().int().positive().optional()
49
+ .describe("Specific file ID within a dropbox submission."),
50
+ downloadPath: z.string().min(1)
51
+ .describe("Absolute path to the directory where the file should be saved."),
52
+ customFilename: z.string().max(255).optional()
53
+ .describe("Custom filename for the downloaded file (include extension). If not provided, uses the original filename from Brightspace."),
54
+ });
55
+ export const GetSyllabusSchema = z.object({
56
+ courseId: z.coerce.number().int().positive()
57
+ .describe("Course ID to get syllabus for."),
58
+ downloadPath: z.string().min(1).optional()
59
+ .describe("Absolute path to the directory where the attachment should be saved."),
60
+ });
61
+ export const GetDiscussionsSchema = z.object({
62
+ courseId: z.coerce.number().int().positive()
63
+ .describe("Course ID to get discussion boards for."),
64
+ forumId: z.coerce.number().int().positive().optional()
65
+ .describe("Specific forum ID to get topics and posts for. If omitted, returns all forums."),
66
+ topicId: z.coerce.number().int().positive().optional()
67
+ .describe("Specific topic ID to get posts for. Requires forumId."),
68
+ });
69
+ export const GetAssignmentFilesSchema = z.object({
70
+ courseId: z.coerce.number().int().positive()
71
+ .describe("Course ID whose assignment attachments to look at."),
72
+ folderId: z.coerce.number().int().positive().optional()
73
+ .describe("Assignment (dropbox folder) ID. Omit to list every assignment in the course that has attachments."),
74
+ fileId: z.coerce.number().int().positive().optional()
75
+ .describe("Attachment file ID to read. Requires folderId. Omit to list the files without reading them."),
76
+ extractText: z.boolean().default(true)
77
+ .describe("Extract readable text from the file. Works for PDF, DOCX, XLSX, PPTX, and plain text."),
78
+ maxChars: z.coerce.number().int().positive().max(100000).default(12000)
79
+ .describe("Maximum characters of extracted text to return. The response reports whether it was truncated."),
80
+ });
81
+ export const GetRosterSchema = z.object({
82
+ courseId: z.coerce.number().int().positive()
83
+ .describe("Course ID to get roster for."),
84
+ includeStudents: z.boolean().default(false)
85
+ .describe("Include students in results. Default is instructors and TAs only."),
86
+ searchTerm: z.string().max(200).optional()
87
+ .describe("Optional search term to filter by name."),
88
+ limit: z.coerce.number().int().positive().max(1000).default(100)
89
+ .describe("Maximum users to return. Default 100. The response reports the true total and whether it was truncated."),
90
+ });
91
+ //# sourceMappingURL=schemas.js.map
@@ -0,0 +1,71 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { log } from "../utils/logger.js";
3
+ const STATUS_NAMES = {
4
+ 0: "not_submitted",
5
+ 1: "submitted",
6
+ 2: "draft",
7
+ 3: "graded",
8
+ };
9
+ export function summarizeSubmission(raw) {
10
+ const entries = (Array.isArray(raw) ? raw : raw?.Objects ?? []);
11
+ const entity = entries[0];
12
+ // Tolerate a flat submission list as well as the documented EntityDropbox shape.
13
+ const subs = entity?.Submissions ?? entries.filter((e) => e?.SubmissionDate);
14
+ const latest = subs.reduce((best, s) => (!best || new Date(s.SubmissionDate ?? 0) > new Date(best.SubmissionDate ?? 0) ? s : best), null);
15
+ const submitted = subs.length > 0 || entity?.Status === 1 || entity?.Status === 3;
16
+ const status = entity?.Status !== undefined && entity.Status in STATUS_NAMES
17
+ ? STATUS_NAMES[entity.Status]
18
+ : submitted ? "submitted" : "not_submitted";
19
+ return {
20
+ submitted: status === "draft" ? false : submitted,
21
+ submissionStatus: status,
22
+ submission: latest
23
+ ? {
24
+ submittedDate: latest.SubmissionDate ?? null,
25
+ attemptCount: subs.length,
26
+ files: latest.Files?.map((f) => ({ name: f.FileName, size: f.Size, fileId: f.FileId })) ?? [],
27
+ comment: latest.Comment?.Text || null,
28
+ }
29
+ : null,
30
+ };
31
+ }
32
+ export function restrictedStatus(folder, now = Date.now()) {
33
+ const time = (d) => (d ? new Date(d).getTime() : NaN);
34
+ const start = time(folder.Availability?.StartDate);
35
+ const end = time(folder.Availability?.EndDate);
36
+ const due = time(folder.DueDate);
37
+ let submissionStatus = "restricted";
38
+ let statusNote = "Brightspace does not allow viewing this submission (release conditions or special access).";
39
+ if (start > now) {
40
+ submissionStatus = "not_open_yet";
41
+ statusNote = `Not assigned yet: opens ${folder.Availability.StartDate}.`;
42
+ }
43
+ else if (end < now) {
44
+ submissionStatus = "closed";
45
+ statusNote = `Closed on ${folder.Availability.EndDate}.`;
46
+ }
47
+ else if (due < now) {
48
+ submissionStatus = "past_due";
49
+ statusNote = `Past the due date (${folder.DueDate}).`;
50
+ }
51
+ return { submitted: null, submissionStatus, statusNote, submission: null };
52
+ }
53
+ export async function fetchSubmissionStatus(apiClient, courseId, folder) {
54
+ try {
55
+ const raw = await apiClient.get(apiClient.le(courseId, `/dropbox/folders/${folder.Id}/submissions/mysubmissions/`), { ttl: DEFAULT_CACHE_TTLS.assignments });
56
+ return summarizeSubmission(raw);
57
+ }
58
+ catch (error) {
59
+ const status = error?.status;
60
+ if (status === 404)
61
+ return summarizeSubmission([]);
62
+ if (status === 403)
63
+ return restrictedStatus(folder);
64
+ // Session and network failures are not "unknown status": let the tool report them.
65
+ if (status === 401 || status === undefined)
66
+ throw error;
67
+ log("DEBUG", `Failed to fetch submissions for folder ${folder.Id}`, error);
68
+ return { submitted: null, submissionStatus: "unknown", submission: null };
69
+ }
70
+ }
71
+ //# sourceMappingURL=submission-status.js.map
@@ -0,0 +1,62 @@
1
+ import { ZodError } from "zod";
2
+ import { ApiError, RateLimitError, NetworkError } from "../api/index.js";
3
+ import { TokenRefreshError } from "../api/errors.js";
4
+ import { AuthRequiredError } from "../auth/authenticator.js";
5
+ import { log } from "../utils/logger.js";
6
+ import { LOGIN_COMMAND } from "../utils/paths.js";
7
+ import { DownloadError, isSafeDetail, } from "../utils/download-errors.js";
8
+ export function toolResponse(data) {
9
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
10
+ }
11
+ export function errorResponse(message) {
12
+ return { content: [{ type: "text", text: message }], isError: true };
13
+ }
14
+ const DOWNLOAD_FAILURE_GUIDANCE = {
15
+ unsupportedType: "The file's format is not on the allowed download list. " +
16
+ "Open it from Brightspace in a browser instead.",
17
+ undetectableType: "The file's format could not be identified, so it was not saved. " +
18
+ "This usually means Brightspace returned an error page instead of the file.",
19
+ badFilename: "The name Brightspace gave this file cannot be used on disk. " +
20
+ "Pass customFilename to choose one yourself.",
21
+ pathTraversal: "The name Brightspace gave this file pointed outside the download " +
22
+ "directory and was refused. Pass customFilename to choose one yourself.",
23
+ };
24
+ export function sanitizeError(error) {
25
+ log("ERROR", "Tool error", error);
26
+ if (error instanceof AuthRequiredError) {
27
+ // Messages are written by this package (authenticator.ts), never copied from a remote response.
28
+ return errorResponse(error.message);
29
+ }
30
+ // Checked before NetworkError, which it extends.
31
+ if (error instanceof TokenRefreshError) {
32
+ return errorResponse("Brightspace could not renew your session right now. Your saved login was kept. " +
33
+ "Try again in a few minutes.");
34
+ }
35
+ if (error instanceof DownloadError) {
36
+ const detail = error.detail && isSafeDetail(error.detail) ? ` (detected type: ${error.detail})` : "";
37
+ return errorResponse(`Could not save the file.${detail} ${DOWNLOAD_FAILURE_GUIDANCE[error.kind]}`);
38
+ }
39
+ if (error instanceof ApiError) {
40
+ if (error.status === 404) {
41
+ return errorResponse("Resource not found. The course or item may not exist, or you may not have access.");
42
+ }
43
+ if (error.status === 401) {
44
+ return errorResponse(`Your Brightspace session expired and signing in again did not finish. Run \`${LOGIN_COMMAND}\` in a terminal, then try again.`);
45
+ }
46
+ if (error.status === 403) {
47
+ return errorResponse("Access denied. You may not have permission to access this resource.");
48
+ }
49
+ }
50
+ if (error instanceof RateLimitError) {
51
+ return errorResponse("Rate limited by Brightspace. Please wait a moment and try again.");
52
+ }
53
+ if (error instanceof NetworkError) {
54
+ return errorResponse("Could not connect to Brightspace. Check your internet connection.");
55
+ }
56
+ if (error instanceof ZodError) {
57
+ const issues = error.issues.map((i) => `${i.path.join(".")}: ${i.message}`);
58
+ return errorResponse(`Invalid input: ${issues.join(", ")}`);
59
+ }
60
+ return errorResponse("An unexpected error occurred. Please try again.");
61
+ }
62
+ //# sourceMappingURL=tool-helpers.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,66 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import * as fs from "node:fs/promises";
3
+ import * as fsSync from "node:fs";
4
+ const TRANSIENT_RENAME_ERRORS = new Set(["EPERM", "EBUSY", "EACCES"]);
5
+ const RENAME_ATTEMPTS = 5;
6
+ const RENAME_BASE_DELAY_MS = 50;
7
+ function tempPathFor(target) {
8
+ return `${target}.tmp-${randomBytes(6).toString("hex")}`;
9
+ }
10
+ function isTransient(error) {
11
+ const code = error?.code ?? "";
12
+ return TRANSIENT_RENAME_ERRORS.has(code);
13
+ }
14
+ export async function writeFileAtomic(target, data, options = {}) {
15
+ const { mode, renameImpl = (from, to) => fs.rename(from, to), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), } = options;
16
+ const tmp = tempPathFor(target);
17
+ await fs.writeFile(tmp, data, mode === undefined ? {} : { mode });
18
+ // writeFile's mode is subject to the umask; chmod is not.
19
+ if (mode !== undefined && process.platform !== "win32") {
20
+ await fs.chmod(tmp, mode);
21
+ }
22
+ try {
23
+ for (let attempt = 1;; attempt++) {
24
+ try {
25
+ await renameImpl(tmp, target);
26
+ return;
27
+ }
28
+ catch (error) {
29
+ if (!isTransient(error) || attempt >= RENAME_ATTEMPTS)
30
+ throw error;
31
+ await sleep(RENAME_BASE_DELAY_MS * attempt);
32
+ }
33
+ }
34
+ }
35
+ catch (error) {
36
+ await fs.rm(tmp, { force: true }).catch(() => { });
37
+ throw error;
38
+ }
39
+ }
40
+ export function writeFileAtomicSync(target, data, options = {}) {
41
+ const { mode } = options;
42
+ const tmp = tempPathFor(target);
43
+ fsSync.writeFileSync(tmp, data, mode === undefined ? {} : { mode });
44
+ if (mode !== undefined && process.platform !== "win32") {
45
+ fsSync.chmodSync(tmp, mode);
46
+ }
47
+ try {
48
+ for (let attempt = 1;; attempt++) {
49
+ try {
50
+ fsSync.renameSync(tmp, target);
51
+ return;
52
+ }
53
+ catch (error) {
54
+ if (!isTransient(error) || attempt >= RENAME_ATTEMPTS)
55
+ throw error;
56
+ // A short synchronous pause; there is no event loop to yield to here.
57
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, RENAME_BASE_DELAY_MS * attempt);
58
+ }
59
+ }
60
+ }
61
+ catch (error) {
62
+ fsSync.rmSync(tmp, { force: true });
63
+ throw error;
64
+ }
65
+ }
66
+ //# sourceMappingURL=atomic-write.js.map