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.
- package/LICENSE +24 -0
- package/README.md +269 -0
- package/build/api/cache.js +49 -0
- package/build/api/client.js +345 -0
- package/build/api/errors.js +48 -0
- package/build/api/index.js +12 -0
- package/build/api/paginate.js +70 -0
- package/build/api/rate-limiter.js +52 -0
- package/build/api/retry.js +39 -0
- package/build/api/types.js +11 -0
- package/build/api/version-discovery.js +43 -0
- package/build/auth/authenticator.js +146 -0
- package/build/auth/browser.js +82 -0
- package/build/auth/index.js +6 -0
- package/build/auth/login.js +227 -0
- package/build/auth/session-store.js +46 -0
- package/build/auth/token-manager.js +150 -0
- package/build/auth/token-mint.js +75 -0
- package/build/cli/clients.js +344 -0
- package/build/cli/main.js +162 -0
- package/build/cli/school.js +74 -0
- package/build/cli/setup.js +189 -0
- package/build/cli/ui.js +39 -0
- package/build/index.js +13 -0
- package/build/server.js +175 -0
- package/build/tools/download-file.js +167 -0
- package/build/tools/get-announcements.js +116 -0
- package/build/tools/get-assignment-files.js +159 -0
- package/build/tools/get-assignments.js +343 -0
- package/build/tools/get-classlist-emails.js +38 -0
- package/build/tools/get-course-content.js +175 -0
- package/build/tools/get-discussions.js +192 -0
- package/build/tools/get-my-courses.js +42 -0
- package/build/tools/get-my-grades.js +93 -0
- package/build/tools/get-roster.js +99 -0
- package/build/tools/get-syllabus.js +154 -0
- package/build/tools/get-todo.js +61 -0
- package/build/tools/get-upcoming-due-dates.js +138 -0
- package/build/tools/index.js +18 -0
- package/build/tools/schemas.js +91 -0
- package/build/tools/submission-status.js +71 -0
- package/build/tools/tool-helpers.js +62 -0
- package/build/types/index.js +2 -0
- package/build/utils/atomic-write.js +66 -0
- package/build/utils/config.js +60 -0
- package/build/utils/course-filter.js +24 -0
- package/build/utils/deep-links.js +13 -0
- package/build/utils/download-errors.js +14 -0
- package/build/utils/download-helpers.js +73 -0
- package/build/utils/errors.js +31 -0
- package/build/utils/file-validator.js +121 -0
- package/build/utils/html-converter.js +20 -0
- package/build/utils/logger.js +69 -0
- package/build/utils/paths.js +15 -0
- package/build/utils/pdf-extractor.js +18 -0
- package/build/utils/version.js +18 -0
- package/build/utils/zip-extract.js +139 -0
- 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
|