a fancy canvas mcp server!
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: cache canvas

+191 -58
+4 -1
src/index.ts
··· 63 63 timestamp: new Date().toISOString(), 64 64 version: "1.0.0", 65 65 uptime: process.uptime(), 66 - cache: DB.getApiKeyCacheStats(), 66 + cache: { 67 + apiKeys: DB.getApiKeyCacheStats(), 68 + canvas: CanvasClient.getCacheStats(), 69 + }, 67 70 }); 68 71 } 69 72
+187 -57
src/lib/canvas.ts
··· 1 + // Simple in-memory cache with TTL 2 + interface CacheEntry { 3 + data: any; 4 + expiresAt: number; 5 + } 6 + 7 + class SimpleCache { 8 + private cache = new Map<string, CacheEntry>(); 9 + private pendingRequests = new Map<string, Promise<any>>(); 10 + 11 + get(key: string): any | null { 12 + const entry = this.cache.get(key); 13 + if (!entry) return null; 14 + 15 + if (Date.now() > entry.expiresAt) { 16 + this.cache.delete(key); 17 + return null; 18 + } 19 + 20 + return entry.data; 21 + } 22 + 23 + set(key: string, data: any, ttlMs: number): void { 24 + this.cache.set(key, { 25 + data, 26 + expiresAt: Date.now() + ttlMs, 27 + }); 28 + } 29 + 30 + getPendingRequest(key: string): Promise<any> | null { 31 + return this.pendingRequests.get(key) || null; 32 + } 33 + 34 + setPendingRequest(key: string, promise: Promise<any>): void { 35 + this.pendingRequests.set(key, promise); 36 + promise.finally(() => this.pendingRequests.delete(key)); 37 + } 38 + 39 + clear(): void { 40 + this.cache.clear(); 41 + this.pendingRequests.clear(); 42 + } 43 + 44 + // Periodic cleanup of expired entries 45 + cleanup(): void { 46 + const now = Date.now(); 47 + for (const [key, entry] of this.cache.entries()) { 48 + if (now > entry.expiresAt) { 49 + this.cache.delete(key); 50 + } 51 + } 52 + } 53 + } 54 + 55 + // Global cache instance (shared across all CanvasClient instances) 56 + const globalCache = new SimpleCache(); 57 + 58 + // Cleanup expired cache entries every 5 minutes 59 + setInterval(() => globalCache.cleanup(), 5 * 60 * 1000); 60 + 1 61 // Canvas API client 2 62 export class CanvasClient { 63 + private cache: SimpleCache; 64 + 3 65 constructor( 4 66 private domain: string, 5 - private accessToken: string 6 - ) {} 67 + private accessToken: string, 68 + private cacheTTL: number = 5 * 60 * 1000 // Default: 5 minutes 69 + ) { 70 + this.cache = globalCache; 71 + } 72 + 73 + private getCacheKey(path: string): string { 74 + // Include domain and user token hash in cache key for isolation 75 + const tokenHash = this.accessToken.slice(-8); 76 + return `${this.domain}:${tokenHash}:${path}`; 77 + } 7 78 8 79 private async request(path: string, options?: RequestInit): Promise<any> { 9 80 const url = `https://${this.domain}/api/v1${path}`; 81 + const cacheKey = this.getCacheKey(path); 10 82 11 - const response = await fetch(url, { 83 + // Only cache GET requests 84 + const isGetRequest = !options?.method || options.method === 'GET'; 85 + 86 + if (isGetRequest) { 87 + // Check cache first 88 + const cached = this.cache.get(cacheKey); 89 + if (cached !== null) { 90 + return cached; 91 + } 92 + 93 + // Check if there's already a pending request for this path (request deduplication) 94 + const pending = this.cache.getPendingRequest(cacheKey); 95 + if (pending) { 96 + return pending; 97 + } 98 + } 99 + 100 + // Make the request 101 + const requestPromise = fetch(url, { 12 102 ...options, 13 103 headers: { 14 104 Authorization: `Bearer ${this.accessToken}`, 15 105 "Content-Type": "application/json", 16 106 ...options?.headers, 17 107 }, 108 + }).then(async (response) => { 109 + if (!response.ok) { 110 + throw new Error( 111 + `Canvas API error: ${response.status} ${response.statusText}` 112 + ); 113 + } 114 + return response.json(); 18 115 }); 19 116 20 - if (!response.ok) { 21 - throw new Error( 22 - `Canvas API error: ${response.status} ${response.statusText}` 23 - ); 117 + // Store pending request for deduplication 118 + if (isGetRequest) { 119 + this.cache.setPendingRequest(cacheKey, requestPromise); 24 120 } 25 121 26 - return response.json(); 122 + try { 123 + const data = await requestPromise; 124 + 125 + // Cache the result 126 + if (isGetRequest) { 127 + this.cache.set(cacheKey, data, this.cacheTTL); 128 + } 129 + 130 + return data; 131 + } catch (error) { 132 + throw error; 133 + } 134 + } 135 + 136 + // Clear cache for this client (useful for testing or forcing refresh) 137 + clearCache(): void { 138 + // This clears the entire global cache - could be refined to only clear entries for this user 139 + this.cache.clear(); 140 + } 141 + 142 + // Get cache statistics (for monitoring) 143 + static getCacheStats() { 144 + return { 145 + size: globalCache['cache'].size, 146 + pendingRequests: globalCache['pendingRequests'].size 147 + }; 27 148 } 28 149 29 150 async getCurrentUser() { ··· 50 171 courses = await this.listCourses({ enrollment_state: "active" }); 51 172 } 52 173 53 - // Fetch assignments from each course 54 - const allAssignments: any[] = []; 55 - for (const course of courses) { 56 - try { 57 - const assignments = await this.request(`/courses/${course.id}/assignments`); 58 - // Add course info to each assignment 59 - assignments.forEach((assignment: any) => { 60 - assignment.course_id = course.id; 61 - assignment.course_name = course.name; 62 - }); 63 - allAssignments.push(...assignments); 64 - } catch (error) { 65 - // Skip courses that fail (e.g., no permission) 66 - console.error(`Failed to fetch assignments for course ${course.id}:`, error); 67 - } 68 - } 174 + // Fetch assignments from all courses in parallel 175 + const assignmentPromises = courses.map(course => 176 + this.request(`/courses/${course.id}/assignments`) 177 + .then(assignments => { 178 + // Add course info to each assignment 179 + assignments.forEach((assignment: any) => { 180 + assignment.course_id = course.id; 181 + assignment.course_name = course.name; 182 + }); 183 + return assignments; 184 + }) 185 + .catch(error => { 186 + // Skip courses that fail (e.g., no permission) 187 + console.error(`Failed to fetch assignments for course ${course.id}:`, error); 188 + return []; 189 + }) 190 + ); 191 + 192 + const assignmentArrays = await Promise.all(assignmentPromises); 193 + const allAssignments = assignmentArrays.flat(); 69 194 70 195 // Filter by search term if provided 71 196 if (params?.search_term) { ··· 97 222 // Get announcements for a specific course 98 223 return this.request(`/courses/${courseId}/discussion_topics?only_announcements=true&per_page=${limit}`); 99 224 } else { 100 - // Get announcements across all courses 225 + // Get announcements across all courses in parallel 101 226 const courses = await this.listCourses({ enrollment_state: "active" }); 102 - const allAnnouncements: any[] = []; 103 227 104 - for (const course of courses) { 105 - try { 106 - const announcements = await this.request(`/courses/${course.id}/discussion_topics?only_announcements=true&per_page=5`); 107 - announcements.forEach((announcement: any) => { 108 - announcement.course_id = course.id; 109 - announcement.course_name = course.name; 110 - }); 111 - allAnnouncements.push(...announcements); 112 - } catch (error) { 113 - // Skip courses that fail 114 - console.error(`Failed to fetch announcements for course ${course.id}:`, error); 115 - } 116 - } 228 + const announcementPromises = courses.map(course => 229 + this.request(`/courses/${course.id}/discussion_topics?only_announcements=true&per_page=5`) 230 + .then(announcements => { 231 + announcements.forEach((announcement: any) => { 232 + announcement.course_id = course.id; 233 + announcement.course_name = course.name; 234 + }); 235 + return announcements; 236 + }) 237 + .catch(error => { 238 + // Skip courses that fail 239 + console.error(`Failed to fetch announcements for course ${course.id}:`, error); 240 + return []; 241 + }) 242 + ); 243 + 244 + const announcementArrays = await Promise.all(announcementPromises); 245 + const allAnnouncements = announcementArrays.flat(); 117 246 118 247 // Sort by posted date (most recent first) 119 248 allAnnouncements.sort((a, b) => ··· 126 255 127 256 async getGradesAndSubmissions(courseId?: number) { 128 257 if (courseId) { 129 - // Get submissions for a specific course 130 - const enrollments = await this.request(`/courses/${courseId}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`); 131 - const assignments = await this.request(`/courses/${courseId}/assignments?include[]=submission`); 258 + // Get submissions for a specific course - parallelize these two requests 259 + const [enrollments, assignments] = await Promise.all([ 260 + this.request(`/courses/${courseId}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`), 261 + this.request(`/courses/${courseId}/assignments?include[]=submission`) 262 + ]); 132 263 133 264 return { 134 265 enrollments, 135 266 assignments 136 267 }; 137 268 } else { 138 - // Get grades across all courses 269 + // Get grades across all courses in parallel 139 270 const courses = await this.listCourses({ enrollment_state: "active" }); 140 - const allGrades: any[] = []; 141 271 142 - for (const course of courses) { 143 - try { 144 - const enrollments = await this.request(`/courses/${course.id}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`); 145 - 146 - enrollments.forEach((enrollment: any) => { 147 - allGrades.push({ 272 + const gradePromises = courses.map(course => 273 + this.request(`/courses/${course.id}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`) 274 + .then(enrollments => { 275 + return enrollments.map((enrollment: any) => ({ 148 276 course_id: course.id, 149 277 course_name: course.name, 150 278 course_code: course.course_code, ··· 152 280 current_score: enrollment.grades?.current_score, 153 281 final_grade: enrollment.grades?.final_grade, 154 282 final_score: enrollment.grades?.final_score 155 - }); 156 - }); 157 - } catch (error) { 158 - console.error(`Failed to fetch grades for course ${course.id}:`, error); 159 - } 160 - } 283 + })); 284 + }) 285 + .catch(error => { 286 + console.error(`Failed to fetch grades for course ${course.id}:`, error); 287 + return []; 288 + }) 289 + ); 161 290 162 - return allGrades; 291 + const gradeArrays = await Promise.all(gradePromises); 292 + return gradeArrays.flat(); 163 293 } 164 294 } 165 295 }