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,175 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetCourseContentSchema } from "./schemas.js";
3
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
4
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
5
+ import { log } from "../utils/logger.js";
6
+ // Topic type mapping
7
+ const TOPIC_TYPE_MAP = {
8
+ 1: 'file',
9
+ 2: 'link',
10
+ 3: 'link', // External link
11
+ };
12
+ function matchesTypeFilter(item, filter) {
13
+ switch (filter) {
14
+ case 'file':
15
+ return item.TopicType === 1;
16
+ case 'link':
17
+ return item.TopicType === 2 || item.TopicType === 3;
18
+ case 'html':
19
+ return !!(item.Description?.Html) && item.TopicType !== 1;
20
+ case 'video':
21
+ return (item.TopicType === 2 || item.TopicType === 3) &&
22
+ /youtube|vimeo|kaltura|video/i.test(item.Url ?? '');
23
+ default:
24
+ return true;
25
+ }
26
+ }
27
+ const MAX_CONTENT_DEPTH = 12;
28
+ async function buildContentTree(apiClient, courseId, modules, progressMap, typeFilter, maxDepth, currentDepth = 0) {
29
+ const tree = [];
30
+ const depthLimit = Math.min(maxDepth ?? MAX_CONTENT_DEPTH, MAX_CONTENT_DEPTH);
31
+ for (const item of modules) {
32
+ if (item.Type === 0) {
33
+ // Module: fetch children recursively (unless the depth limit is reached)
34
+ let processedChildren = [];
35
+ if (currentDepth < depthLimit) {
36
+ let children = [];
37
+ try {
38
+ children = await apiClient.get(apiClient.le(courseId, `/content/modules/${item.Id}/structure/`), { ttl: DEFAULT_CACHE_TTLS.courseContent });
39
+ }
40
+ catch (e) {
41
+ log('DEBUG', `Failed to fetch children for module ${item.Id}: skipping`);
42
+ }
43
+ processedChildren = await buildContentTree(apiClient, courseId, children, progressMap, typeFilter, maxDepth, currentDepth + 1);
44
+ }
45
+ else if (currentDepth >= MAX_CONTENT_DEPTH) {
46
+ log('DEBUG', `Content depth ceiling of ${MAX_CONTENT_DEPTH} reached at module ${item.Id}: not descending further`);
47
+ }
48
+ // Only include module if it has matching children (or filter is 'all')
49
+ if (typeFilter === 'all' || processedChildren.length > 0) {
50
+ tree.push({
51
+ type: 'module',
52
+ id: item.Id,
53
+ title: item.Title,
54
+ description: item.Description?.Text ?? null,
55
+ dueDate: item.ModuleDueDate ?? null,
56
+ isHidden: item.IsHidden,
57
+ isLocked: item.IsLocked,
58
+ children: processedChildren,
59
+ });
60
+ }
61
+ }
62
+ else if (item.Type === 1) {
63
+ // Topic: process based on TopicType
64
+ const topicType = TOPIC_TYPE_MAP[item.TopicType ?? 0] ?? 'other';
65
+ // Apply type filter
66
+ if (typeFilter !== 'all' && !matchesTypeFilter(item, typeFilter)) {
67
+ continue;
68
+ }
69
+ const topicProgress = progressMap.get(item.Id);
70
+ const topic = {
71
+ type: 'topic',
72
+ topicType,
73
+ id: item.Id,
74
+ title: item.Title,
75
+ isHidden: item.IsHidden,
76
+ isLocked: item.IsLocked,
77
+ dueDate: item.DueDate ?? null,
78
+ isCompleted: topicProgress?.IsRead ?? false,
79
+ completedDate: topicProgress?.DateCompleted ?? null,
80
+ };
81
+ // Add type-specific content
82
+ if (item.TopicType === 1) {
83
+ // File topic: include description
84
+ topic.description = item.Description?.Text ?? null;
85
+ topic.topicId = item.Id; // Useful for download_file tool
86
+ }
87
+ else if (item.TopicType === 2 || item.TopicType === 3) {
88
+ // Link topic: include URL
89
+ topic.url = item.Url ?? null;
90
+ }
91
+ // HTML content: include body converted to markdown
92
+ if (item.Description?.Html) {
93
+ topic.content = convertHtmlToMarkdown(item.Description.Html);
94
+ }
95
+ tree.push(topic);
96
+ }
97
+ }
98
+ return tree;
99
+ }
100
+ function countTopics(tree) {
101
+ let count = 0;
102
+ for (const item of tree) {
103
+ if (item.type === 'topic') {
104
+ count++;
105
+ }
106
+ else if (item.type === 'module' && item.children) {
107
+ count += countTopics(item.children);
108
+ }
109
+ }
110
+ return count;
111
+ }
112
+ function countModules(tree) {
113
+ let count = 0;
114
+ for (const item of tree) {
115
+ if (item.type === 'module') {
116
+ count++;
117
+ if (item.children) {
118
+ count += countModules(item.children);
119
+ }
120
+ }
121
+ }
122
+ return count;
123
+ }
124
+ export function registerGetCourseContent(server, apiClient) {
125
+ server.registerTool("get_course_content", {
126
+ title: "Get Course Content",
127
+ description: "Fetch the content tree for a course showing modules, topics, files, and links. Use this when the user asks about course materials, lecture slides, uploaded files, content structure, or what's in a course module. Use moduleTitle to filter to a specific module (e.g. 'Labs', 'Staff', 'Homeworks') instead of fetching the entire tree. Use maxDepth to limit recursion depth for a table-of-contents view.",
128
+ inputSchema: GetCourseContentSchema,
129
+ }, async (args) => {
130
+ try {
131
+ log("DEBUG", "get_course_content tool called", { args });
132
+ // Parse and validate input
133
+ const { courseId, typeFilter = 'all', moduleTitle, maxDepth } = GetCourseContentSchema.parse(args);
134
+ // Fetch root modules
135
+ let rootModules = await apiClient.get(apiClient.le(courseId, '/content/root/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
136
+ // Filter root modules by title if specified
137
+ if (moduleTitle) {
138
+ const searchTerm = moduleTitle.toLowerCase();
139
+ rootModules = rootModules.filter(m => m.Title.toLowerCase().includes(searchTerm));
140
+ }
141
+ // Fetch user progress for the course (graceful degradation)
142
+ let progressArray = [];
143
+ try {
144
+ progressArray = await apiClient.get(apiClient.le(courseId, '/content/userprogress/'), { ttl: DEFAULT_CACHE_TTLS.courseContent });
145
+ }
146
+ catch (error) {
147
+ // 404/403 means no progress data available - not an error
148
+ if (error?.status !== 404 && error?.status !== 403) {
149
+ log('DEBUG', `Failed to fetch progress for course ${courseId}`, error);
150
+ }
151
+ }
152
+ // Build progress map for O(1) lookups
153
+ const progressMap = new Map();
154
+ for (const p of progressArray) {
155
+ progressMap.set(p.ContentObjectId, p);
156
+ }
157
+ // Recursively build content tree
158
+ const contentTree = await buildContentTree(apiClient, courseId, rootModules, progressMap, typeFilter, maxDepth);
159
+ const topicCount = countTopics(contentTree);
160
+ const moduleCount = countModules(contentTree);
161
+ log("INFO", `get_course_content: Retrieved ${moduleCount} modules and ${topicCount} topics for course ${courseId} (filter: ${typeFilter})`);
162
+ return toolResponse({
163
+ courseId,
164
+ typeFilter,
165
+ contentTree,
166
+ topicCount,
167
+ moduleCount,
168
+ });
169
+ }
170
+ catch (error) {
171
+ return sanitizeError(error);
172
+ }
173
+ });
174
+ }
175
+ //# sourceMappingURL=get-course-content.js.map
@@ -0,0 +1,192 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetDiscussionsSchema } from "./schemas.js";
3
+ import { toolResponse, sanitizeError, errorResponse } from "./tool-helpers.js";
4
+ import { convertHtmlToMarkdown } from "../utils/html-converter.js";
5
+ import { log } from "../utils/logger.js";
6
+ export function registerGetDiscussions(server, apiClient) {
7
+ server.registerTool("get_discussions", {
8
+ title: "Get Discussions",
9
+ description: "Fetch discussion board content for a course including forums, topics, and posts. Use this when the user asks about discussion boards, forum posts, class discussions, or wants to see what's been posted. Provide just courseId to list all forums and their topics. Add forumId to get topics and posts for a specific forum. Add both forumId and topicId to get all posts in a specific discussion topic.",
10
+ inputSchema: GetDiscussionsSchema,
11
+ }, async (args) => {
12
+ try {
13
+ log("DEBUG", "get_discussions tool called", { args });
14
+ const { courseId, forumId, topicId } = GetDiscussionsSchema.parse(args);
15
+ // topicId requires forumId
16
+ if (topicId !== undefined && forumId === undefined) {
17
+ return errorResponse("topicId requires forumId. Provide both forumId and topicId to get posts for a specific topic.");
18
+ }
19
+ // Specific topic: get posts
20
+ if (forumId !== undefined && topicId !== undefined) {
21
+ return await getTopicPosts(apiClient, courseId, forumId, topicId);
22
+ }
23
+ // Specific forum: get its topics + posts
24
+ if (forumId !== undefined) {
25
+ return await getForumDetail(apiClient, courseId, forumId);
26
+ }
27
+ // All forums overview
28
+ return await getForumsOverview(apiClient, courseId);
29
+ }
30
+ catch (error) {
31
+ return sanitizeError(error);
32
+ }
33
+ });
34
+ }
35
+ async function getForumsOverview(apiClient, courseId) {
36
+ const forumsPath = apiClient.le(courseId, "/discussions/forums/");
37
+ const forums = await apiClient.get(forumsPath, {
38
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
39
+ });
40
+ const result = [];
41
+ for (const forum of forums) {
42
+ // Fetch topics for each forum
43
+ let topics = [];
44
+ try {
45
+ const topicsPath = apiClient.le(courseId, `/discussions/forums/${forum.ForumId}/topics/`);
46
+ topics = await apiClient.get(topicsPath, {
47
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
48
+ });
49
+ }
50
+ catch (error) {
51
+ if (error?.status === 403) {
52
+ log("DEBUG", `No access to topics for forum ${forum.ForumId}, skipping`);
53
+ }
54
+ else {
55
+ log("DEBUG", `Failed to fetch topics for forum ${forum.ForumId}`, error);
56
+ }
57
+ }
58
+ result.push({
59
+ forumId: forum.ForumId,
60
+ name: forum.Name,
61
+ description: forum.Description?.Text ?? null,
62
+ isLocked: forum.IsLocked,
63
+ isHidden: forum.IsHidden,
64
+ topics: topics.map((t) => ({
65
+ topicId: t.TopicId,
66
+ forumId: t.ForumId,
67
+ name: t.Name,
68
+ description: t.Description?.Text ?? null,
69
+ dueDate: t.DueDate,
70
+ isLocked: t.IsLocked,
71
+ isHidden: t.IsHidden,
72
+ mustPostToParticipate: t.MustPostToParticipate,
73
+ scoreOutOf: t.ScoreOutOf,
74
+ })),
75
+ });
76
+ }
77
+ log("INFO", `get_discussions: Retrieved ${forums.length} forums for course ${courseId}`);
78
+ return toolResponse({
79
+ courseId,
80
+ forumCount: result.length,
81
+ forums: result,
82
+ });
83
+ }
84
+ async function getForumDetail(apiClient, courseId, forumId) {
85
+ // Fetch forum info
86
+ const forumPath = apiClient.le(courseId, `/discussions/forums/${forumId}`);
87
+ const forum = await apiClient.get(forumPath, {
88
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
89
+ });
90
+ // Fetch topics
91
+ const topicsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/`);
92
+ const topics = await apiClient.get(topicsPath, {
93
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
94
+ });
95
+ // Fetch posts for each topic
96
+ const topicsWithPosts = [];
97
+ for (const topic of topics) {
98
+ let posts = [];
99
+ try {
100
+ const postsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topic.TopicId}/posts/`);
101
+ posts = await apiClient.get(postsPath, {
102
+ ttl: DEFAULT_CACHE_TTLS.announcements,
103
+ });
104
+ }
105
+ catch (error) {
106
+ if (error?.status === 403) {
107
+ log("DEBUG", `No access to posts for topic ${topic.TopicId}, skipping`);
108
+ }
109
+ else {
110
+ log("DEBUG", `Failed to fetch posts for topic ${topic.TopicId}`, error);
111
+ }
112
+ }
113
+ topicsWithPosts.push({
114
+ topicId: topic.TopicId,
115
+ name: topic.Name,
116
+ description: topic.Description?.Html
117
+ ? convertHtmlToMarkdown(topic.Description.Html).markdown
118
+ : topic.Description?.Text ?? null,
119
+ dueDate: topic.DueDate,
120
+ isLocked: topic.IsLocked,
121
+ mustPostToParticipate: topic.MustPostToParticipate,
122
+ scoreOutOf: topic.ScoreOutOf,
123
+ postCount: posts.length,
124
+ posts: formatPosts(posts),
125
+ });
126
+ }
127
+ log("INFO", `get_discussions: Retrieved forum ${forumId} with ${topics.length} topics for course ${courseId}`);
128
+ return toolResponse({
129
+ courseId,
130
+ forum: {
131
+ forumId: forum.ForumId,
132
+ name: forum.Name,
133
+ description: forum.Description?.Text ?? null,
134
+ isLocked: forum.IsLocked,
135
+ isHidden: forum.IsHidden,
136
+ },
137
+ topicCount: topicsWithPosts.length,
138
+ topics: topicsWithPosts,
139
+ });
140
+ }
141
+ async function getTopicPosts(apiClient, courseId, forumId, topicId) {
142
+ // Fetch topic info
143
+ const topicPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topicId}`);
144
+ const topic = await apiClient.get(topicPath, {
145
+ ttl: DEFAULT_CACHE_TTLS.courseContent,
146
+ });
147
+ // Fetch posts
148
+ const postsPath = apiClient.le(courseId, `/discussions/forums/${forumId}/topics/${topicId}/posts/`);
149
+ const posts = await apiClient.get(postsPath, {
150
+ ttl: DEFAULT_CACHE_TTLS.announcements,
151
+ });
152
+ log("INFO", `get_discussions: Retrieved ${posts.length} posts for topic ${topicId} in forum ${forumId}`);
153
+ return toolResponse({
154
+ courseId,
155
+ forumId,
156
+ topic: {
157
+ topicId: topic.TopicId,
158
+ name: topic.Name,
159
+ description: topic.Description?.Html
160
+ ? convertHtmlToMarkdown(topic.Description.Html).markdown
161
+ : topic.Description?.Text ?? null,
162
+ dueDate: topic.DueDate,
163
+ isLocked: topic.IsLocked,
164
+ mustPostToParticipate: topic.MustPostToParticipate,
165
+ scoreOutOf: topic.ScoreOutOf,
166
+ },
167
+ postCount: posts.length,
168
+ posts: formatPosts(posts),
169
+ });
170
+ }
171
+ function formatPosts(posts) {
172
+ return posts
173
+ .filter((p) => !p.IsDeleted)
174
+ .map((p) => ({
175
+ postId: p.PostId,
176
+ threadId: p.ThreadId,
177
+ parentPostId: p.ParentPostId,
178
+ subject: p.Subject,
179
+ message: p.Message?.Html
180
+ ? convertHtmlToMarkdown(p.Message.Html).markdown
181
+ : p.Message?.Text ?? "",
182
+ author: p.IsAnonymous ? "Anonymous" : p.PostingUserDisplayName,
183
+ datePosted: p.DatePosted,
184
+ lastEditedDate: p.LastEditedDate,
185
+ replyCount: p.ReplyPostIds?.length ?? 0,
186
+ wordCount: p.WordCount,
187
+ attachmentCount: p.AttachmentCount,
188
+ isRead: p.IsRead,
189
+ }))
190
+ .sort((a, b) => new Date(a.datePosted).getTime() - new Date(b.datePosted).getTime());
191
+ }
192
+ //# sourceMappingURL=get-discussions.js.map
@@ -0,0 +1,42 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { fetchAllItems } from "../api/paginate.js";
3
+ import { GetMyCoursesSchema, } from "./schemas.js";
4
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
5
+ import { log } from "../utils/logger.js";
6
+ import { applyCourseFilter } from "../utils/course-filter.js";
7
+ export function registerGetMyCourses(server, apiClient, config) {
8
+ server.registerTool("get_my_courses", {
9
+ title: "Get My Courses",
10
+ description: "Fetch your enrolled Brightspace courses with names, codes, and IDs. Use this when the user asks about their courses, enrolled classes, what they're taking this semester, or needs a course ID for other queries.",
11
+ inputSchema: GetMyCoursesSchema,
12
+ }, async (args) => {
13
+ try {
14
+ log("DEBUG", "get_my_courses tool called", { args });
15
+ // Parse and validate input
16
+ const { activeOnly: activeOnlyArg } = GetMyCoursesSchema.parse(args);
17
+ const activeOnly = activeOnlyArg ?? config.courseFilter.activeOnly;
18
+ // Build path - orgUnitTypeId=3 means "Course Offering" type
19
+ const path = apiClient.lp(`/enrollments/myenrollments/?orgUnitTypeId=3${activeOnly ? "&isActive=true" : ""}`);
20
+ // Fetch enrollments, following the bookmark chain to the last page
21
+ const items = await fetchAllItems(apiClient, path, {
22
+ ttl: DEFAULT_CACHE_TTLS.enrollments,
23
+ });
24
+ // Map to clean objects and apply course filter
25
+ const courses = applyCourseFilter(items.map((item) => ({
26
+ id: item.OrgUnit.Id,
27
+ name: item.OrgUnit.Name,
28
+ code: item.OrgUnit.Code,
29
+ role: item.Access.ClasslistRoleName,
30
+ isActive: item.Access.IsActive,
31
+ canAccess: item.Access.CanAccess,
32
+ lastAccessed: item.Access.LastAccessed,
33
+ })), { ...config.courseFilter, activeOnly });
34
+ log("INFO", `get_my_courses: Retrieved ${courses.length} courses`);
35
+ return toolResponse(courses);
36
+ }
37
+ catch (error) {
38
+ return sanitizeError(error);
39
+ }
40
+ });
41
+ }
42
+ //# sourceMappingURL=get-my-courses.js.map
@@ -0,0 +1,93 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { GetMyGradesSchema, } 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
+ export function registerGetMyGrades(server, apiClient, config) {
7
+ server.registerTool("get_my_grades", {
8
+ title: "Get My Grades",
9
+ description: "Fetch your grade breakdown for a specific course or all enrolled courses. Shows grade items with points, percentages, and comments. Use this when the user asks about grades, scores, marks, GPA, academic performance, or how they're doing in a class.",
10
+ inputSchema: GetMyGradesSchema,
11
+ }, async (args) => {
12
+ try {
13
+ log("DEBUG", "get_my_grades tool called", { args });
14
+ // Parse and validate input
15
+ const { courseId } = GetMyGradesSchema.parse(args);
16
+ // Single course case
17
+ if (courseId) {
18
+ const path = apiClient.le(courseId, "/grades/values/myGradeValues/");
19
+ const gradeValues = await apiClient.get(path, {
20
+ ttl: DEFAULT_CACHE_TTLS.grades,
21
+ });
22
+ // Map to clean objects
23
+ const grades = gradeValues.map((gv) => ({
24
+ name: gv.GradeObjectName,
25
+ displayGrade: gv.DisplayedGrade,
26
+ pointsNumerator: gv.PointsNumerator,
27
+ pointsDenominator: gv.PointsDenominator,
28
+ weightedNumerator: gv.WeightedNumerator,
29
+ weightedDenominator: gv.WeightedDenominator,
30
+ comments: gv.Comments?.Text || null,
31
+ lastModified: gv.LastModified,
32
+ }));
33
+ log("INFO", `get_my_grades: Retrieved ${grades.length} grade items for course ${courseId}`);
34
+ return toolResponse({ courseId, grades });
35
+ }
36
+ // All courses case
37
+ // First, fetch enrolled courses
38
+ const enrollmentPath = apiClient.lp("/enrollments/myenrollments/?orgUnitTypeId=3&isActive=true");
39
+ const enrollmentResponse = await apiClient.get(enrollmentPath, { ttl: DEFAULT_CACHE_TTLS.enrollments });
40
+ // Apply course filter
41
+ const filteredEnrollments = applyCourseFilter(enrollmentResponse.Items.map(item => ({
42
+ id: item.OrgUnit.Id,
43
+ name: item.OrgUnit.Name,
44
+ code: item.OrgUnit.Code,
45
+ isActive: item.Access.IsActive,
46
+ canAccess: item.Access.CanAccess,
47
+ ...item,
48
+ })), config.courseFilter);
49
+ // Fetch grades for each course (handle 403s gracefully)
50
+ const gradePromises = filteredEnrollments.map(async (item) => {
51
+ try {
52
+ const path = apiClient.le(item.OrgUnit.Id, "/grades/values/myGradeValues/");
53
+ const gradeValues = await apiClient.get(path, {
54
+ ttl: DEFAULT_CACHE_TTLS.grades,
55
+ });
56
+ const grades = gradeValues.map((gv) => ({
57
+ name: gv.GradeObjectName,
58
+ displayGrade: gv.DisplayedGrade,
59
+ pointsNumerator: gv.PointsNumerator,
60
+ pointsDenominator: gv.PointsDenominator,
61
+ weightedNumerator: gv.WeightedNumerator,
62
+ weightedDenominator: gv.WeightedDenominator,
63
+ comments: gv.Comments?.Text || null,
64
+ lastModified: gv.LastModified,
65
+ }));
66
+ return {
67
+ courseId: item.OrgUnit.Id,
68
+ courseName: item.OrgUnit.Name,
69
+ grades,
70
+ };
71
+ }
72
+ catch (error) {
73
+ // 403 means no access (past course, etc) - log and skip
74
+ if (error?.status === 403) {
75
+ log("DEBUG", `get_my_grades: 403 Forbidden for course ${item.OrgUnit.Id} (${item.OrgUnit.Name}) - skipping`);
76
+ return null;
77
+ }
78
+ throw error; // Re-throw other errors
79
+ }
80
+ });
81
+ const results = await Promise.allSettled(gradePromises);
82
+ const courses = results
83
+ .filter((r) => r.status === "fulfilled" && r.value !== null)
84
+ .map((r) => r.value);
85
+ log("INFO", `get_my_grades: Retrieved grades for ${courses.length} courses (out of ${enrollmentResponse.Items.length} enrolled)`);
86
+ return toolResponse({ courses });
87
+ }
88
+ catch (error) {
89
+ return sanitizeError(error);
90
+ }
91
+ });
92
+ }
93
+ //# sourceMappingURL=get-my-grades.js.map
@@ -0,0 +1,99 @@
1
+ import { DEFAULT_CACHE_TTLS } from "../api/index.js";
2
+ import { fetchAllObjects } from "../api/paginate.js";
3
+ import { GetRosterSchema, } from "./schemas.js";
4
+ import { toolResponse, sanitizeError } from "./tool-helpers.js";
5
+ import { log } from "../utils/logger.js";
6
+ const INSTRUCTOR_ROLE_ID = 109;
7
+ const TA_ROLE_ID = 135;
8
+ async function fetchClasslistUsers(apiClient, courseId, options) {
9
+ const params = new URLSearchParams();
10
+ if (options?.roleId !== undefined) {
11
+ params.append("roleId", options.roleId.toString());
12
+ }
13
+ if (options?.searchTerm) {
14
+ params.append("searchTerm", options.searchTerm);
15
+ }
16
+ const queryString = params.toString();
17
+ const path = apiClient.le(courseId, `/classlist/paged/${queryString ? "?" + queryString : ""}`);
18
+ return fetchAllObjects(apiClient, path, {
19
+ ttl: DEFAULT_CACHE_TTLS.roster,
20
+ });
21
+ }
22
+ export function registerGetRoster(server, apiClient) {
23
+ server.registerTool("get_roster", {
24
+ title: "Get Course Roster",
25
+ description: "Fetch the roster for a course including instructors, TAs, and optionally students with their names, emails, and roles. Use this when the user asks about classmates, instructor contact info, TA emails, professor names, or who's in a class. By default returns only instructors and TAs for privacy. Use includeStudents to get full class list.",
26
+ inputSchema: GetRosterSchema,
27
+ }, async (args) => {
28
+ try {
29
+ log("DEBUG", "get_roster tool called", { args });
30
+ // Parse and validate input
31
+ const { courseId, includeStudents, searchTerm, limit } = GetRosterSchema.parse(args);
32
+ const allUsers = [];
33
+ if (!includeStudents) {
34
+ // Fetch instructors and TAs in parallel
35
+ const [instructorResult, taResult] = await Promise.allSettled([
36
+ fetchClasslistUsers(apiClient, courseId, {
37
+ roleId: INSTRUCTOR_ROLE_ID,
38
+ searchTerm,
39
+ }),
40
+ fetchClasslistUsers(apiClient, courseId, {
41
+ roleId: TA_ROLE_ID,
42
+ searchTerm,
43
+ }),
44
+ ]);
45
+ // Merge results
46
+ if (instructorResult.status === "fulfilled") {
47
+ allUsers.push(...instructorResult.value);
48
+ }
49
+ else {
50
+ log("WARN", "get_roster: Failed to fetch instructors", {
51
+ error: instructorResult.reason,
52
+ });
53
+ }
54
+ if (taResult.status === "fulfilled") {
55
+ allUsers.push(...taResult.value);
56
+ }
57
+ else {
58
+ log("WARN", "get_roster: Failed to fetch TAs", {
59
+ error: taResult.reason,
60
+ });
61
+ }
62
+ }
63
+ else {
64
+ // Fetch all users
65
+ allUsers.push(...(await fetchClasslistUsers(apiClient, courseId, { searchTerm })));
66
+ }
67
+ const total = allUsers.length;
68
+ const truncated = total > limit;
69
+ const kept = truncated ? allUsers.slice(0, limit) : allUsers;
70
+ if (truncated) {
71
+ log("WARN", "get_roster: Result set exceeds the limit, truncating", {
72
+ total,
73
+ returned: kept.length,
74
+ });
75
+ }
76
+ // Map to clean output
77
+ const users = kept.map((user) => ({
78
+ name: user.DisplayName,
79
+ email: user.Email || null,
80
+ role: user.ClasslistRoleDisplayName,
81
+ }));
82
+ log("INFO", `get_roster: Retrieved ${users.length} users for course ${courseId}`);
83
+ return toolResponse({
84
+ courseId,
85
+ total,
86
+ returned: users.length,
87
+ truncated,
88
+ ...(truncated
89
+ ? { note: `Showing ${users.length} of ${total}. Raise the limit argument to see more.` }
90
+ : {}),
91
+ users,
92
+ });
93
+ }
94
+ catch (error) {
95
+ return sanitizeError(error);
96
+ }
97
+ });
98
+ }
99
+ //# sourceMappingURL=get-roster.js.map