a fancy canvas mcp server!
0
fork

Configure Feed

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

chore: format

+1146 -1088
+331 -304
src/lib/canvas.ts
··· 1 1 // Simple in-memory cache with TTL 2 2 interface CacheEntry { 3 - data: any; 4 - expiresAt: number; 3 + data: any; 4 + expiresAt: number; 5 5 } 6 6 7 7 class SimpleCache { 8 - private cache = new Map<string, CacheEntry>(); 9 - private pendingRequests = new Map<string, Promise<any>>(); 8 + private cache = new Map<string, CacheEntry>(); 9 + private pendingRequests = new Map<string, Promise<any>>(); 10 10 11 - get(key: string): any | null { 12 - const entry = this.cache.get(key); 13 - if (!entry) return null; 11 + get(key: string): any | null { 12 + const entry = this.cache.get(key); 13 + if (!entry) return null; 14 14 15 - if (Date.now() > entry.expiresAt) { 16 - this.cache.delete(key); 17 - return null; 18 - } 15 + if (Date.now() > entry.expiresAt) { 16 + this.cache.delete(key); 17 + return null; 18 + } 19 19 20 - return entry.data; 21 - } 20 + return entry.data; 21 + } 22 22 23 - set(key: string, data: any, ttlMs: number): void { 24 - this.cache.set(key, { 25 - data, 26 - expiresAt: Date.now() + ttlMs, 27 - }); 28 - } 23 + set(key: string, data: any, ttlMs: number): void { 24 + this.cache.set(key, { 25 + data, 26 + expiresAt: Date.now() + ttlMs, 27 + }); 28 + } 29 29 30 - getPendingRequest(key: string): Promise<any> | null { 31 - return this.pendingRequests.get(key) || null; 32 - } 30 + getPendingRequest(key: string): Promise<any> | null { 31 + return this.pendingRequests.get(key) || null; 32 + } 33 33 34 - setPendingRequest(key: string, promise: Promise<any>): void { 35 - this.pendingRequests.set(key, promise); 36 - promise.finally(() => this.pendingRequests.delete(key)); 37 - } 34 + setPendingRequest(key: string, promise: Promise<any>): void { 35 + this.pendingRequests.set(key, promise); 36 + promise.finally(() => this.pendingRequests.delete(key)); 37 + } 38 38 39 - clear(): void { 40 - this.cache.clear(); 41 - this.pendingRequests.clear(); 42 - } 39 + clear(): void { 40 + this.cache.clear(); 41 + this.pendingRequests.clear(); 42 + } 43 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 - } 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 53 } 54 54 55 55 // Global cache instance (shared across all CanvasClient instances) ··· 60 60 61 61 // Canvas API client 62 62 export class CanvasClient { 63 - private cache: SimpleCache; 63 + private cache: SimpleCache; 64 64 65 - constructor( 66 - private domain: string, 67 - private accessToken: string, 68 - private cacheTTL: number = 5 * 60 * 1000 // Default: 5 minutes 69 - ) { 70 - this.cache = globalCache; 71 - } 65 + constructor( 66 + private domain: string, 67 + private accessToken: string, 68 + private cacheTTL: number = 5 * 60 * 1000, // Default: 5 minutes 69 + ) { 70 + this.cache = globalCache; 71 + } 72 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 - } 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 + } 78 78 79 - private async request(path: string, options?: RequestInit): Promise<any> { 80 - const url = `https://${this.domain}/api/v1${path}`; 81 - const cacheKey = this.getCacheKey(path); 79 + private async request(path: string, options?: RequestInit): Promise<any> { 80 + const url = `https://${this.domain}/api/v1${path}`; 81 + const cacheKey = this.getCacheKey(path); 82 82 83 - // Only cache GET requests 84 - const isGetRequest = !options?.method || options.method === 'GET'; 83 + // Only cache GET requests 84 + const isGetRequest = !options?.method || options.method === "GET"; 85 85 86 - if (isGetRequest) { 87 - // Check cache first 88 - const cached = this.cache.get(cacheKey); 89 - if (cached !== null) { 90 - return cached; 91 - } 86 + if (isGetRequest) { 87 + // Check cache first 88 + const cached = this.cache.get(cacheKey); 89 + if (cached !== null) { 90 + return cached; 91 + } 92 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 - } 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 99 100 - // Make the request 101 - const requestPromise = fetch(url, { 102 - ...options, 103 - headers: { 104 - Authorization: `Bearer ${this.accessToken}`, 105 - "Content-Type": "application/json", 106 - ...options?.headers, 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(); 115 - }); 100 + // Make the request 101 + const requestPromise = fetch(url, { 102 + ...options, 103 + headers: { 104 + Authorization: `Bearer ${this.accessToken}`, 105 + "Content-Type": "application/json", 106 + ...options?.headers, 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(); 115 + }); 116 116 117 - // Store pending request for deduplication 118 - if (isGetRequest) { 119 - this.cache.setPendingRequest(cacheKey, requestPromise); 120 - } 117 + // Store pending request for deduplication 118 + if (isGetRequest) { 119 + this.cache.setPendingRequest(cacheKey, requestPromise); 120 + } 121 121 122 - try { 123 - const data = await requestPromise; 122 + try { 123 + const data = await requestPromise; 124 124 125 - // Cache the result 126 - if (isGetRequest) { 127 - this.cache.set(cacheKey, data, this.cacheTTL); 128 - } 125 + // Cache the result 126 + if (isGetRequest) { 127 + this.cache.set(cacheKey, data, this.cacheTTL); 128 + } 129 129 130 - return data; 131 - } catch (error) { 132 - throw error; 133 - } 134 - } 130 + return data; 131 + } catch (error) { 132 + throw error; 133 + } 134 + } 135 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 - } 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 141 142 - // Get cache statistics (for monitoring) 143 - static getCacheStats() { 144 - return { 145 - size: globalCache['cache'].size, 146 - pendingRequests: globalCache['pendingRequests'].size 147 - }; 148 - } 142 + // Get cache statistics (for monitoring) 143 + static getCacheStats() { 144 + return { 145 + size: globalCache["cache"].size, 146 + pendingRequests: globalCache["pendingRequests"].size, 147 + }; 148 + } 149 149 150 - async getCurrentUser() { 151 - return this.request("/users/self"); 152 - } 150 + async getCurrentUser() { 151 + return this.request("/users/self"); 152 + } 153 153 154 - async listCourses(params?: { enrollment_state?: string }) { 155 - const query = new URLSearchParams(params as any).toString(); 156 - const path = `/courses${query ? `?${query}` : ""}`; 157 - return this.request(path); 158 - } 154 + async listCourses(params?: { enrollment_state?: string }) { 155 + const query = new URLSearchParams(params as any).toString(); 156 + const path = `/courses${query ? `?${query}` : ""}`; 157 + return this.request(path); 158 + } 159 159 160 - async searchAssignments(params?: { 161 - search_term?: string; 162 - course_ids?: number[]; 163 - }) { 164 - // Get courses to search 165 - let courses: any[]; 166 - if (params?.course_ids && params.course_ids.length > 0) { 167 - // Use specific course IDs 168 - courses = params.course_ids.map(id => ({ id })); 169 - } else { 170 - // Get all active courses 171 - courses = await this.listCourses({ enrollment_state: "active" }); 172 - } 160 + async searchAssignments(params?: { 161 + search_term?: string; 162 + course_ids?: number[]; 163 + }) { 164 + // Get courses to search 165 + let courses: any[]; 166 + if (params?.course_ids && params.course_ids.length > 0) { 167 + // Use specific course IDs 168 + courses = params.course_ids.map((id) => ({ id })); 169 + } else { 170 + // Get all active courses 171 + courses = await this.listCourses({ enrollment_state: "active" }); 172 + } 173 173 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 - ); 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( 188 + `Failed to fetch assignments for course ${course.id}:`, 189 + error, 190 + ); 191 + return []; 192 + }), 193 + ); 191 194 192 - const assignmentArrays = await Promise.all(assignmentPromises); 193 - const allAssignments = assignmentArrays.flat(); 195 + const assignmentArrays = await Promise.all(assignmentPromises); 196 + const allAssignments = assignmentArrays.flat(); 194 197 195 - // Filter by search term if provided 196 - if (params?.search_term) { 197 - const searchLower = params.search_term.toLowerCase(); 198 - return allAssignments.filter(assignment => 199 - assignment.name?.toLowerCase().includes(searchLower) || 200 - assignment.description?.toLowerCase().includes(searchLower) 201 - ); 202 - } 198 + // Filter by search term if provided 199 + if (params?.search_term) { 200 + const searchLower = params.search_term.toLowerCase(); 201 + return allAssignments.filter( 202 + (assignment) => 203 + assignment.name?.toLowerCase().includes(searchLower) || 204 + assignment.description?.toLowerCase().includes(searchLower), 205 + ); 206 + } 203 207 204 - return allAssignments; 205 - } 208 + return allAssignments; 209 + } 206 210 207 - async getAssignment(courseId: number, assignmentId: number) { 208 - return this.request(`/courses/${courseId}/assignments/${assignmentId}`); 209 - } 211 + async getAssignment(courseId: number, assignmentId: number) { 212 + return this.request(`/courses/${courseId}/assignments/${assignmentId}`); 213 + } 210 214 211 - async getUpcomingAssignments() { 212 - // Get upcoming assignments using the planner API 213 - const startDate = new Date().toISOString(); 214 - const endDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(); // 30 days from now 215 + async getUpcomingAssignments() { 216 + // Get upcoming assignments using the planner API 217 + const startDate = new Date().toISOString(); 218 + const endDate = new Date( 219 + Date.now() + 30 * 24 * 60 * 60 * 1000, 220 + ).toISOString(); // 30 days from now 215 221 216 - // Get all planner items without filter to see what Canvas shows in the planner 217 - return this.request(`/planner/items?start_date=${startDate}&end_date=${endDate}`); 218 - } 222 + // Get all planner items without filter to see what Canvas shows in the planner 223 + return this.request( 224 + `/planner/items?start_date=${startDate}&end_date=${endDate}`, 225 + ); 226 + } 219 227 220 - async getCourseAnnouncements(courseId?: number, limit: number = 10) { 221 - if (courseId) { 222 - // Get announcements for a specific course 223 - return this.request(`/courses/${courseId}/discussion_topics?only_announcements=true&per_page=${limit}`); 224 - } else { 225 - // Get announcements across all courses in parallel 226 - const courses = await this.listCourses({ enrollment_state: "active" }); 228 + async getCourseAnnouncements(courseId?: number, limit: number = 10) { 229 + if (courseId) { 230 + // Get announcements for a specific course 231 + return this.request( 232 + `/courses/${courseId}/discussion_topics?only_announcements=true&per_page=${limit}`, 233 + ); 234 + } else { 235 + // Get announcements across all courses in parallel 236 + const courses = await this.listCourses({ enrollment_state: "active" }); 227 237 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 - ); 238 + const announcementPromises = courses.map((course) => 239 + this.request( 240 + `/courses/${course.id}/discussion_topics?only_announcements=true&per_page=5`, 241 + ) 242 + .then((announcements) => { 243 + announcements.forEach((announcement: any) => { 244 + announcement.course_id = course.id; 245 + announcement.course_name = course.name; 246 + }); 247 + return announcements; 248 + }) 249 + .catch((error) => { 250 + // Skip courses that fail 251 + console.error( 252 + `Failed to fetch announcements for course ${course.id}:`, 253 + error, 254 + ); 255 + return []; 256 + }), 257 + ); 243 258 244 - const announcementArrays = await Promise.all(announcementPromises); 245 - const allAnnouncements = announcementArrays.flat(); 259 + const announcementArrays = await Promise.all(announcementPromises); 260 + const allAnnouncements = announcementArrays.flat(); 246 261 247 - // Sort by posted date (most recent first) 248 - allAnnouncements.sort((a, b) => 249 - new Date(b.posted_at || b.created_at).getTime() - new Date(a.posted_at || a.created_at).getTime() 250 - ); 262 + // Sort by posted date (most recent first) 263 + allAnnouncements.sort( 264 + (a, b) => 265 + new Date(b.posted_at || b.created_at).getTime() - 266 + new Date(a.posted_at || a.created_at).getTime(), 267 + ); 251 268 252 - return allAnnouncements.slice(0, limit); 253 - } 254 - } 269 + return allAnnouncements.slice(0, limit); 270 + } 271 + } 255 272 256 - async getGradesAndSubmissions(courseId?: number) { 257 - if (courseId) { 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 - ]); 273 + async getGradesAndSubmissions(courseId?: number) { 274 + if (courseId) { 275 + // Get submissions for a specific course - parallelize these two requests 276 + const [enrollments, assignments] = await Promise.all([ 277 + this.request( 278 + `/courses/${courseId}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`, 279 + ), 280 + this.request(`/courses/${courseId}/assignments?include[]=submission`), 281 + ]); 263 282 264 - return { 265 - enrollments, 266 - assignments 267 - }; 268 - } else { 269 - // Get grades across all courses in parallel 270 - const courses = await this.listCourses({ enrollment_state: "active" }); 283 + return { 284 + enrollments, 285 + assignments, 286 + }; 287 + } else { 288 + // Get grades across all courses in parallel 289 + const courses = await this.listCourses({ enrollment_state: "active" }); 271 290 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) => ({ 276 - course_id: course.id, 277 - course_name: course.name, 278 - course_code: course.course_code, 279 - current_grade: enrollment.grades?.current_grade, 280 - current_score: enrollment.grades?.current_score, 281 - final_grade: enrollment.grades?.final_grade, 282 - final_score: enrollment.grades?.final_score 283 - })); 284 - }) 285 - .catch(error => { 286 - console.error(`Failed to fetch grades for course ${course.id}:`, error); 287 - return []; 288 - }) 289 - ); 291 + const gradePromises = courses.map((course) => 292 + this.request( 293 + `/courses/${course.id}/enrollments?user_id=self&include[]=current_grading_period_scores&include[]=total_scores`, 294 + ) 295 + .then((enrollments) => { 296 + return enrollments.map((enrollment: any) => ({ 297 + course_id: course.id, 298 + course_name: course.name, 299 + course_code: course.course_code, 300 + current_grade: enrollment.grades?.current_grade, 301 + current_score: enrollment.grades?.current_score, 302 + final_grade: enrollment.grades?.final_grade, 303 + final_score: enrollment.grades?.final_score, 304 + })); 305 + }) 306 + .catch((error) => { 307 + console.error( 308 + `Failed to fetch grades for course ${course.id}:`, 309 + error, 310 + ); 311 + return []; 312 + }), 313 + ); 290 314 291 - const gradeArrays = await Promise.all(gradePromises); 292 - return gradeArrays.flat(); 293 - } 294 - } 315 + const gradeArrays = await Promise.all(gradePromises); 316 + return gradeArrays.flat(); 317 + } 318 + } 295 319 } 296 320 297 321 // OAuth helpers 298 322 export interface CanvasOAuthConfig { 299 - clientId: string; 300 - clientSecret: string; 301 - redirectUri: string; 302 - canvasDomain: string; 323 + clientId: string; 324 + clientSecret: string; 325 + redirectUri: string; 326 + canvasDomain: string; 303 327 } 304 328 305 - export function getAuthorizationUrl(config: CanvasOAuthConfig, state: string): string { 306 - const params = new URLSearchParams({ 307 - client_id: config.clientId, 308 - response_type: "code", 309 - redirect_uri: config.redirectUri, 310 - state, 311 - scope: "url:GET|/api/v1/courses url:GET|/api/v1/assignments", 312 - }); 329 + export function getAuthorizationUrl( 330 + config: CanvasOAuthConfig, 331 + state: string, 332 + ): string { 333 + const params = new URLSearchParams({ 334 + client_id: config.clientId, 335 + response_type: "code", 336 + redirect_uri: config.redirectUri, 337 + state, 338 + scope: "url:GET|/api/v1/courses url:GET|/api/v1/assignments", 339 + }); 313 340 314 - return `https://${config.canvasDomain}/login/oauth2/auth?${params.toString()}`; 341 + return `https://${config.canvasDomain}/login/oauth2/auth?${params.toString()}`; 315 342 } 316 343 317 344 export async function exchangeCodeForToken( 318 - config: CanvasOAuthConfig, 319 - code: string 345 + config: CanvasOAuthConfig, 346 + code: string, 320 347 ): Promise<{ 321 - access_token: string; 322 - refresh_token?: string; 323 - expires_in?: number; 324 - user: any; 348 + access_token: string; 349 + refresh_token?: string; 350 + expires_in?: number; 351 + user: any; 325 352 }> { 326 - const response = await fetch( 327 - `https://${config.canvasDomain}/login/oauth2/token`, 328 - { 329 - method: "POST", 330 - headers: { "Content-Type": "application/json" }, 331 - body: JSON.stringify({ 332 - grant_type: "authorization_code", 333 - client_id: config.clientId, 334 - client_secret: config.clientSecret, 335 - redirect_uri: config.redirectUri, 336 - code, 337 - }), 338 - } 339 - ); 353 + const response = await fetch( 354 + `https://${config.canvasDomain}/login/oauth2/token`, 355 + { 356 + method: "POST", 357 + headers: { "Content-Type": "application/json" }, 358 + body: JSON.stringify({ 359 + grant_type: "authorization_code", 360 + client_id: config.clientId, 361 + client_secret: config.clientSecret, 362 + redirect_uri: config.redirectUri, 363 + code, 364 + }), 365 + }, 366 + ); 340 367 341 - if (!response.ok) { 342 - throw new Error(`OAuth token exchange failed: ${response.statusText}`); 343 - } 368 + if (!response.ok) { 369 + throw new Error(`OAuth token exchange failed: ${response.statusText}`); 370 + } 344 371 345 - const data = await response.json(); 372 + const data = await response.json(); 346 373 347 - // Get user info 348 - const client = new CanvasClient(config.canvasDomain, data.access_token); 349 - const user = await client.getCurrentUser(); 374 + // Get user info 375 + const client = new CanvasClient(config.canvasDomain, data.access_token); 376 + const user = await client.getCurrentUser(); 350 377 351 - return { 352 - access_token: data.access_token, 353 - refresh_token: data.refresh_token, 354 - expires_in: data.expires_in, 355 - user, 356 - }; 378 + return { 379 + access_token: data.access_token, 380 + refresh_token: data.refresh_token, 381 + expires_in: data.expires_in, 382 + user, 383 + }; 357 384 } 358 385 359 386 export async function refreshAccessToken( 360 - config: CanvasOAuthConfig, 361 - refreshToken: string 387 + config: CanvasOAuthConfig, 388 + refreshToken: string, 362 389 ): Promise<{ access_token: string; expires_in?: number }> { 363 - const response = await fetch( 364 - `https://${config.canvasDomain}/login/oauth2/token`, 365 - { 366 - method: "POST", 367 - headers: { "Content-Type": "application/json" }, 368 - body: JSON.stringify({ 369 - grant_type: "refresh_token", 370 - client_id: config.clientId, 371 - client_secret: config.clientSecret, 372 - refresh_token: refreshToken, 373 - }), 374 - } 375 - ); 390 + const response = await fetch( 391 + `https://${config.canvasDomain}/login/oauth2/token`, 392 + { 393 + method: "POST", 394 + headers: { "Content-Type": "application/json" }, 395 + body: JSON.stringify({ 396 + grant_type: "refresh_token", 397 + client_id: config.clientId, 398 + client_secret: config.clientSecret, 399 + refresh_token: refreshToken, 400 + }), 401 + }, 402 + ); 376 403 377 - if (!response.ok) { 378 - throw new Error(`Token refresh failed: ${response.statusText}`); 379 - } 404 + if (!response.ok) { 405 + throw new Error(`Token refresh failed: ${response.statusText}`); 406 + } 380 407 381 - return response.json(); 408 + return response.json(); 382 409 }
+428 -402
src/lib/db.ts
··· 5 5 6 6 // In-memory cache for verified API keys 7 7 interface ApiKeyCacheEntry { 8 - userId: number; 9 - verifiedAt: number; 8 + userId: number; 9 + verifiedAt: number; 10 10 } 11 11 12 12 const apiKeyCache = new Map<string, ApiKeyCacheEntry>(); 13 13 const CACHE_TTL = parseInt(process.env.API_KEY_CACHE_TTL || "900000"); // 15 minutes default 14 14 15 15 // Cache cleanup interval (runs every 5 minutes) 16 - setInterval(() => { 17 - const now = Date.now(); 18 - for (const [key, entry] of apiKeyCache.entries()) { 19 - if (now - entry.verifiedAt > CACHE_TTL) { 20 - apiKeyCache.delete(key); 21 - } 22 - } 23 - }, 5 * 60 * 1000); 16 + setInterval( 17 + () => { 18 + const now = Date.now(); 19 + for (const [key, entry] of apiKeyCache.entries()) { 20 + if (now - entry.verifiedAt > CACHE_TTL) { 21 + apiKeyCache.delete(key); 22 + } 23 + } 24 + }, 25 + 5 * 60 * 1000, 26 + ); 24 27 25 28 // Initialize database schema 26 29 db.exec(` ··· 102 105 const ALGORITHM = "aes-256-gcm"; 103 106 104 107 function encrypt(text: string): string { 105 - const iv = randomBytes(16); 106 - const cipher = createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv); 108 + const iv = randomBytes(16); 109 + const cipher = createCipheriv(ALGORITHM, ENCRYPTION_KEY, iv); 107 110 108 - let encrypted = cipher.update(text, "utf8", "hex"); 109 - encrypted += cipher.final("hex"); 111 + let encrypted = cipher.update(text, "utf8", "hex"); 112 + encrypted += cipher.final("hex"); 110 113 111 - const authTag = cipher.getAuthTag(); 114 + const authTag = cipher.getAuthTag(); 112 115 113 - // Return: iv:authTag:encrypted 114 - return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; 116 + // Return: iv:authTag:encrypted 117 + return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`; 115 118 } 116 119 117 120 function decrypt(encryptedData: string): string { 118 - const [ivHex, authTagHex, encrypted] = encryptedData.split(":"); 121 + const [ivHex, authTagHex, encrypted] = encryptedData.split(":"); 119 122 120 - const iv = Buffer.from(ivHex, "hex"); 121 - const authTag = Buffer.from(authTagHex, "hex"); 122 - const decipher = createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv); 123 + const iv = Buffer.from(ivHex, "hex"); 124 + const authTag = Buffer.from(authTagHex, "hex"); 125 + const decipher = createDecipheriv(ALGORITHM, ENCRYPTION_KEY, iv); 123 126 124 - decipher.setAuthTag(authTag); 127 + decipher.setAuthTag(authTag); 125 128 126 - let decrypted = decipher.update(encrypted, "hex", "utf8"); 127 - decrypted += decipher.final("utf8"); 129 + let decrypted = decipher.update(encrypted, "hex", "utf8"); 130 + decrypted += decipher.final("utf8"); 128 131 129 - return decrypted; 132 + return decrypted; 130 133 } 131 134 132 135 // Generate secure API key 133 136 function generateApiKey(): string { 134 - return `cmcp_${randomBytes(32).toString("base64url")}`; 137 + return `cmcp_${randomBytes(32).toString("base64url")}`; 135 138 } 136 139 137 140 // Hash API key for storage 138 141 async function hashApiKey(apiKey: string): Promise<string> { 139 - return await Bun.password.hash(apiKey, { 140 - algorithm: "argon2id", 141 - memoryCost: 19456, 142 - timeCost: 2, 143 - }); 142 + return await Bun.password.hash(apiKey, { 143 + algorithm: "argon2id", 144 + memoryCost: 19456, 145 + timeCost: 2, 146 + }); 144 147 } 145 148 146 149 // Verify API key 147 150 async function verifyApiKey(apiKey: string, hash: string): Promise<boolean> { 148 - return await Bun.password.verify(apiKey, hash); 151 + return await Bun.password.verify(apiKey, hash); 149 152 } 150 153 151 154 export interface User { 152 - id: number; 153 - canvas_user_id: string; 154 - canvas_domain: string; 155 - email?: string; 156 - canvas_access_token: string; 157 - canvas_refresh_token?: string; 158 - mcp_api_key: string; 159 - created_at: number; 160 - last_used_at?: number; 161 - token_expires_at?: number; 155 + id: number; 156 + canvas_user_id: string; 157 + canvas_domain: string; 158 + email?: string; 159 + canvas_access_token: string; 160 + canvas_refresh_token?: string; 161 + mcp_api_key: string; 162 + created_at: number; 163 + last_used_at?: number; 164 + token_expires_at?: number; 162 165 } 163 166 164 167 export const DB = { 165 - // Raw database access 166 - raw: db, 168 + // Raw database access 169 + raw: db, 167 170 168 - // Create or update user after OAuth 169 - async createOrUpdateUser(data: { 170 - canvas_user_id: string; 171 - canvas_domain: string; 172 - email?: string; 173 - canvas_access_token: string; 174 - canvas_refresh_token?: string; 175 - token_expires_at?: number; 176 - }): Promise<{ user: User; apiKey: string | null; isNewUser: boolean }> { 177 - const encryptedToken = encrypt(data.canvas_access_token); 178 - const encryptedRefreshToken = data.canvas_refresh_token 179 - ? encrypt(data.canvas_refresh_token) 180 - : null; 171 + // Create or update user after OAuth 172 + async createOrUpdateUser(data: { 173 + canvas_user_id: string; 174 + canvas_domain: string; 175 + email?: string; 176 + canvas_access_token: string; 177 + canvas_refresh_token?: string; 178 + token_expires_at?: number; 179 + }): Promise<{ user: User; apiKey: string | null; isNewUser: boolean }> { 180 + const encryptedToken = encrypt(data.canvas_access_token); 181 + const encryptedRefreshToken = data.canvas_refresh_token 182 + ? encrypt(data.canvas_refresh_token) 183 + : null; 181 184 182 - // Check if user exists by canvas_user_id or email 183 - let existing = db 184 - .query("SELECT * FROM users WHERE canvas_user_id = ?") 185 - .get(data.canvas_user_id) as User | null; 185 + // Check if user exists by canvas_user_id or email 186 + let existing = db 187 + .query("SELECT * FROM users WHERE canvas_user_id = ?") 188 + .get(data.canvas_user_id) as User | null; 186 189 187 - // If not found by canvas_user_id, check by email (for magic link users) 188 - if (!existing && data.email) { 189 - existing = db 190 - .query("SELECT * FROM users WHERE email = ? AND canvas_user_id IS NULL") 191 - .get(data.email) as User | null; 192 - } 190 + // If not found by canvas_user_id, check by email (for magic link users) 191 + if (!existing && data.email) { 192 + existing = db 193 + .query("SELECT * FROM users WHERE email = ? AND canvas_user_id IS NULL") 194 + .get(data.email) as User | null; 195 + } 193 196 194 - if (existing) { 195 - // Check if user needs an API key (magic link users) 196 - let apiKey: string | null = null; 197 - let hashedApiKey = existing.mcp_api_key; 197 + if (existing) { 198 + // Check if user needs an API key (magic link users) 199 + let apiKey: string | null = null; 200 + let hashedApiKey = existing.mcp_api_key; 198 201 199 - if (!hashedApiKey) { 200 - // Generate API key for magic link users connecting Canvas for first time 201 - apiKey = generateApiKey(); 202 - hashedApiKey = await hashApiKey(apiKey); 203 - } 202 + if (!hashedApiKey) { 203 + // Generate API key for magic link users connecting Canvas for first time 204 + apiKey = generateApiKey(); 205 + hashedApiKey = await hashApiKey(apiKey); 206 + } 204 207 205 - // Update existing user (might not have canvas_user_id if from magic link) 206 - db.run( 207 - `UPDATE users SET 208 + // Update existing user (might not have canvas_user_id if from magic link) 209 + db.run( 210 + `UPDATE users SET 208 211 canvas_user_id = ?, 209 212 canvas_domain = ?, 210 213 canvas_access_token = ?, ··· 213 216 last_used_at = ?, 214 217 mcp_api_key = ? 215 218 WHERE id = ?`, 216 - [ 217 - data.canvas_user_id, 218 - data.canvas_domain, 219 - encryptedToken, 220 - encryptedRefreshToken, 221 - data.token_expires_at, 222 - Date.now(), 223 - hashedApiKey, 224 - existing.id, 225 - ] 226 - ); 219 + [ 220 + data.canvas_user_id, 221 + data.canvas_domain, 222 + encryptedToken, 223 + encryptedRefreshToken, 224 + data.token_expires_at, 225 + Date.now(), 226 + hashedApiKey, 227 + existing.id, 228 + ], 229 + ); 227 230 228 - const user = db 229 - .query("SELECT * FROM users WHERE id = ?") 230 - .get(existing.id) as User; 231 + const user = db 232 + .query("SELECT * FROM users WHERE id = ?") 233 + .get(existing.id) as User; 231 234 232 - // Return API key only if we just generated it (for magic link users) 233 - const isNewUser = apiKey !== null; 234 - return { user, apiKey, isNewUser }; 235 - } else { 236 - // Create new user with API key 237 - const apiKey = generateApiKey(); 238 - const hashedApiKey = await hashApiKey(apiKey); 235 + // Return API key only if we just generated it (for magic link users) 236 + const isNewUser = apiKey !== null; 237 + return { user, apiKey, isNewUser }; 238 + } else { 239 + // Create new user with API key 240 + const apiKey = generateApiKey(); 241 + const hashedApiKey = await hashApiKey(apiKey); 239 242 240 - const result = db.run( 241 - `INSERT INTO users ( 243 + const result = db.run( 244 + `INSERT INTO users ( 242 245 canvas_user_id, canvas_domain, email, 243 246 canvas_access_token, canvas_refresh_token, 244 247 mcp_api_key, created_at, token_expires_at 245 248 ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, 246 - [ 247 - data.canvas_user_id, 248 - data.canvas_domain, 249 - data.email, 250 - encryptedToken, 251 - encryptedRefreshToken, 252 - hashedApiKey, 253 - Date.now(), 254 - data.token_expires_at, 255 - ] 256 - ); 249 + [ 250 + data.canvas_user_id, 251 + data.canvas_domain, 252 + data.email, 253 + encryptedToken, 254 + encryptedRefreshToken, 255 + hashedApiKey, 256 + Date.now(), 257 + data.token_expires_at, 258 + ], 259 + ); 257 260 258 - const user = db 259 - .query("SELECT * FROM users WHERE id = ?") 260 - .get(result.lastInsertRowid) as User; 261 + const user = db 262 + .query("SELECT * FROM users WHERE id = ?") 263 + .get(result.lastInsertRowid) as User; 261 264 262 - return { user, apiKey, isNewUser: true }; 263 - } 264 - }, 265 + return { user, apiKey, isNewUser: true }; 266 + } 267 + }, 265 268 266 - // Get user by API key (with caching for performance) 267 - async getUserByApiKey(apiKey: string): Promise<User | null> { 268 - // Check cache first for O(1) lookup 269 - const cached = apiKeyCache.get(apiKey); 270 - if (cached && Date.now() - cached.verifiedAt < CACHE_TTL) { 271 - // Cache hit - fast path 272 - const user = db 273 - .query("SELECT * FROM users WHERE id = ?") 274 - .get(cached.userId) as User | null; 269 + // Get user by API key (with caching for performance) 270 + async getUserByApiKey(apiKey: string): Promise<User | null> { 271 + // Check cache first for O(1) lookup 272 + const cached = apiKeyCache.get(apiKey); 273 + if (cached && Date.now() - cached.verifiedAt < CACHE_TTL) { 274 + // Cache hit - fast path 275 + const user = db 276 + .query("SELECT * FROM users WHERE id = ?") 277 + .get(cached.userId) as User | null; 275 278 276 - if (user) { 277 - return user; 278 - } 279 - // User was deleted - invalidate cache entry 280 - apiKeyCache.delete(apiKey); 281 - } 279 + if (user) { 280 + return user; 281 + } 282 + // User was deleted - invalidate cache entry 283 + apiKeyCache.delete(apiKey); 284 + } 282 285 283 - // Cache miss - perform full verification (slow path) 284 - const users = db.query("SELECT * FROM users WHERE mcp_api_key IS NOT NULL").all() as User[]; 286 + // Cache miss - perform full verification (slow path) 287 + const users = db 288 + .query("SELECT * FROM users WHERE mcp_api_key IS NOT NULL") 289 + .all() as User[]; 285 290 286 - for (const user of users) { 287 - if (await verifyApiKey(apiKey, user.mcp_api_key)) { 288 - // Cache the verified key for future requests 289 - apiKeyCache.set(apiKey, { 290 - userId: user.id, 291 - verifiedAt: Date.now(), 292 - }); 293 - return user; 294 - } 295 - } 291 + for (const user of users) { 292 + if (await verifyApiKey(apiKey, user.mcp_api_key)) { 293 + // Cache the verified key for future requests 294 + apiKeyCache.set(apiKey, { 295 + userId: user.id, 296 + verifiedAt: Date.now(), 297 + }); 298 + return user; 299 + } 300 + } 296 301 297 - return null; 298 - }, 302 + return null; 303 + }, 299 304 300 - // Get user by Canvas user ID 301 - getUserByCanvasId(canvas_user_id: string): User | null { 302 - return db 303 - .query("SELECT * FROM users WHERE canvas_user_id = ?") 304 - .get(canvas_user_id) as User | null; 305 - }, 305 + // Get user by Canvas user ID 306 + getUserByCanvasId(canvas_user_id: string): User | null { 307 + return db 308 + .query("SELECT * FROM users WHERE canvas_user_id = ?") 309 + .get(canvas_user_id) as User | null; 310 + }, 306 311 307 - // Get decrypted Canvas token for user 308 - getCanvasToken(user: User): string { 309 - return decrypt(user.canvas_access_token); 310 - }, 312 + // Get decrypted Canvas token for user 313 + getCanvasToken(user: User): string { 314 + return decrypt(user.canvas_access_token); 315 + }, 311 316 312 - // Get decrypted refresh token 313 - getRefreshToken(user: User): string | null { 314 - return user.canvas_refresh_token 315 - ? decrypt(user.canvas_refresh_token) 316 - : null; 317 - }, 317 + // Get decrypted refresh token 318 + getRefreshToken(user: User): string | null { 319 + return user.canvas_refresh_token 320 + ? decrypt(user.canvas_refresh_token) 321 + : null; 322 + }, 318 323 319 - // Log API usage 320 - logUsage(userId: number, endpoint: string) { 321 - db.run( 322 - "INSERT INTO usage_logs (user_id, endpoint, timestamp) VALUES (?, ?, ?)", 323 - [userId, endpoint, Date.now()] 324 - ); 325 - }, 324 + // Log API usage 325 + logUsage(userId: number, endpoint: string) { 326 + db.run( 327 + "INSERT INTO usage_logs (user_id, endpoint, timestamp) VALUES (?, ?, ?)", 328 + [userId, endpoint, Date.now()], 329 + ); 330 + }, 326 331 327 - // Get usage stats for user 328 - getUsageStats(userId: number, since?: number) { 329 - const query = since 330 - ? "SELECT * FROM usage_logs WHERE user_id = ? AND timestamp >= ?" 331 - : "SELECT * FROM usage_logs WHERE user_id = ?"; 332 + // Get usage stats for user 333 + getUsageStats(userId: number, since?: number) { 334 + const query = since 335 + ? "SELECT * FROM usage_logs WHERE user_id = ? AND timestamp >= ?" 336 + : "SELECT * FROM usage_logs WHERE user_id = ?"; 332 337 333 - const params = since ? [userId, since] : [userId]; 334 - return db.query(query).all(...params); 335 - }, 338 + const params = since ? [userId, since] : [userId]; 339 + return db.query(query).all(...params); 340 + }, 336 341 337 - // Update last used timestamp 338 - updateLastUsed(userId: number) { 339 - db.run("UPDATE users SET last_used_at = ? WHERE id = ?", [ 340 - Date.now(), 341 - userId, 342 - ]); 343 - }, 342 + // Update last used timestamp 343 + updateLastUsed(userId: number) { 344 + db.run("UPDATE users SET last_used_at = ? WHERE id = ?", [ 345 + Date.now(), 346 + userId, 347 + ]); 348 + }, 344 349 345 - // Regenerate API key 346 - async regenerateApiKey(userId: number): Promise<string> { 347 - // Invalidate all cached entries for this user 348 - for (const [key, entry] of apiKeyCache.entries()) { 349 - if (entry.userId === userId) { 350 - apiKeyCache.delete(key); 351 - } 352 - } 350 + // Regenerate API key 351 + async regenerateApiKey(userId: number): Promise<string> { 352 + // Invalidate all cached entries for this user 353 + for (const [key, entry] of apiKeyCache.entries()) { 354 + if (entry.userId === userId) { 355 + apiKeyCache.delete(key); 356 + } 357 + } 353 358 354 - const newApiKey = generateApiKey(); 355 - const hashedApiKey = await hashApiKey(newApiKey); 359 + const newApiKey = generateApiKey(); 360 + const hashedApiKey = await hashApiKey(newApiKey); 356 361 357 - db.run("UPDATE users SET mcp_api_key = ? WHERE id = ?", [ 358 - hashedApiKey, 359 - userId, 360 - ]); 362 + db.run("UPDATE users SET mcp_api_key = ? WHERE id = ?", [ 363 + hashedApiKey, 364 + userId, 365 + ]); 361 366 362 - return newApiKey; 363 - }, 367 + return newApiKey; 368 + }, 364 369 365 - // Session management 366 - createSession(sessionId: string, data: { 367 - user_id?: number; 368 - canvas_domain: string; 369 - state: string; 370 - api_key?: string; 371 - maxAge: number; // in seconds 372 - }) { 373 - const now = Date.now(); 374 - db.run( 375 - `INSERT INTO sessions (id, user_id, canvas_domain, state, api_key, created_at, expires_at) 370 + // Session management 371 + createSession( 372 + sessionId: string, 373 + data: { 374 + user_id?: number; 375 + canvas_domain: string; 376 + state: string; 377 + api_key?: string; 378 + maxAge: number; // in seconds 379 + }, 380 + ) { 381 + const now = Date.now(); 382 + db.run( 383 + `INSERT INTO sessions (id, user_id, canvas_domain, state, api_key, created_at, expires_at) 376 384 VALUES (?, ?, ?, ?, ?, ?, ?)`, 377 - [ 378 - sessionId, 379 - data.user_id || null, 380 - data.canvas_domain, 381 - data.state, 382 - data.api_key || null, 383 - now, 384 - now + data.maxAge * 1000, 385 - ] 386 - ); 387 - }, 385 + [ 386 + sessionId, 387 + data.user_id || null, 388 + data.canvas_domain, 389 + data.state, 390 + data.api_key || null, 391 + now, 392 + now + data.maxAge * 1000, 393 + ], 394 + ); 395 + }, 388 396 389 - getSession(sessionId: string) { 390 - return db 391 - .query("SELECT * FROM sessions WHERE id = ? AND expires_at > ?") 392 - .get(sessionId, Date.now()) as any; 393 - }, 397 + getSession(sessionId: string) { 398 + return db 399 + .query("SELECT * FROM sessions WHERE id = ? AND expires_at > ?") 400 + .get(sessionId, Date.now()) as any; 401 + }, 394 402 395 - updateSession(sessionId: string, data: Partial<{ user_id: number; api_key: string }>) { 396 - const updates: string[] = []; 397 - const values: any[] = []; 403 + updateSession( 404 + sessionId: string, 405 + data: Partial<{ user_id: number; api_key: string }>, 406 + ) { 407 + const updates: string[] = []; 408 + const values: any[] = []; 398 409 399 - if (data.user_id !== undefined) { 400 - updates.push("user_id = ?"); 401 - values.push(data.user_id); 402 - } 403 - if (data.api_key !== undefined) { 404 - updates.push("api_key = ?"); 405 - values.push(data.api_key); 406 - } 410 + if (data.user_id !== undefined) { 411 + updates.push("user_id = ?"); 412 + values.push(data.user_id); 413 + } 414 + if (data.api_key !== undefined) { 415 + updates.push("api_key = ?"); 416 + values.push(data.api_key); 417 + } 407 418 408 - if (updates.length > 0) { 409 - values.push(sessionId); 410 - db.run( 411 - `UPDATE sessions SET ${updates.join(", ")} WHERE id = ?`, 412 - values 413 - ); 414 - } 415 - }, 419 + if (updates.length > 0) { 420 + values.push(sessionId); 421 + db.run(`UPDATE sessions SET ${updates.join(", ")} WHERE id = ?`, values); 422 + } 423 + }, 416 424 417 - deleteSession(sessionId: string) { 418 - db.run("DELETE FROM sessions WHERE id = ?", [sessionId]); 419 - }, 425 + deleteSession(sessionId: string) { 426 + db.run("DELETE FROM sessions WHERE id = ?", [sessionId]); 427 + }, 420 428 421 - clearApiKeyFromSession(sessionId: string) { 422 - db.run("UPDATE sessions SET api_key = NULL WHERE id = ?", [sessionId]); 423 - }, 429 + clearApiKeyFromSession(sessionId: string) { 430 + db.run("UPDATE sessions SET api_key = NULL WHERE id = ?", [sessionId]); 431 + }, 424 432 425 - // Get session by API key (for MCP authentication) 426 - getSessionByToken(token: string) { 427 - return db 428 - .query("SELECT * FROM sessions WHERE api_key = ? AND expires_at > ?") 429 - .get(token, Date.now()) as any; 430 - }, 433 + // Get session by API key (for MCP authentication) 434 + getSessionByToken(token: string) { 435 + return db 436 + .query("SELECT * FROM sessions WHERE api_key = ? AND expires_at > ?") 437 + .get(token, Date.now()) as any; 438 + }, 431 439 432 - // Magic link authentication 433 - createMagicLink(email: string, token: string, expiresAt: number) { 434 - return db.run( 435 - "INSERT INTO magic_links (email, token, expires_at) VALUES (?, ?, ?)", 436 - [email, token, expiresAt] 437 - ); 438 - }, 440 + // Magic link authentication 441 + createMagicLink(email: string, token: string, expiresAt: number) { 442 + return db.run( 443 + "INSERT INTO magic_links (email, token, expires_at) VALUES (?, ?, ?)", 444 + [email, token, expiresAt], 445 + ); 446 + }, 439 447 440 - getMagicLink(token: string) { 441 - return db 442 - .query( 443 - "SELECT * FROM magic_links WHERE token = ? AND expires_at > ? AND used = 0" 444 - ) 445 - .get(token, Date.now()) as any; 446 - }, 448 + getMagicLink(token: string) { 449 + return db 450 + .query( 451 + "SELECT * FROM magic_links WHERE token = ? AND expires_at > ? AND used = 0", 452 + ) 453 + .get(token, Date.now()) as any; 454 + }, 447 455 448 - markMagicLinkUsed(token: string) { 449 - return db.run("UPDATE magic_links SET used = 1 WHERE token = ?", [token]); 450 - }, 456 + markMagicLinkUsed(token: string) { 457 + return db.run("UPDATE magic_links SET used = 1 WHERE token = ?", [token]); 458 + }, 451 459 452 - getUserByEmail(email: string) { 453 - return db.query("SELECT * FROM users WHERE email = ?").get(email) as any; 454 - }, 460 + getUserByEmail(email: string) { 461 + return db.query("SELECT * FROM users WHERE email = ?").get(email) as any; 462 + }, 455 463 456 - // Update user with Canvas credentials (for magic link users) 457 - async updateUserCanvas( 458 - userId: number, 459 - canvasUserId: string, 460 - canvasDomain: string, 461 - canvasToken: string 462 - ): Promise<{ apiKey: string | null }> { 463 - const existing = db.query("SELECT * FROM users WHERE id = ?").get(userId) as User | null; 464 + // Update user with Canvas credentials (for magic link users) 465 + async updateUserCanvas( 466 + userId: number, 467 + canvasUserId: string, 468 + canvasDomain: string, 469 + canvasToken: string, 470 + ): Promise<{ apiKey: string | null }> { 471 + const existing = db 472 + .query("SELECT * FROM users WHERE id = ?") 473 + .get(userId) as User | null; 464 474 465 - if (!existing) { 466 - throw new Error("User not found"); 467 - } 475 + if (!existing) { 476 + throw new Error("User not found"); 477 + } 468 478 469 - const encryptedToken = encrypt(canvasToken); 479 + const encryptedToken = encrypt(canvasToken); 470 480 471 - // Generate API key if user doesn't have one 472 - let apiKey: string | null = null; 473 - let hashedApiKey = existing.mcp_api_key; 481 + // Generate API key if user doesn't have one 482 + let apiKey: string | null = null; 483 + let hashedApiKey = existing.mcp_api_key; 474 484 475 - if (!hashedApiKey) { 476 - apiKey = generateApiKey(); 477 - hashedApiKey = await hashApiKey(apiKey); 478 - } 485 + if (!hashedApiKey) { 486 + apiKey = generateApiKey(); 487 + hashedApiKey = await hashApiKey(apiKey); 488 + } 479 489 480 - // Update user with Canvas credentials 481 - db.run( 482 - `UPDATE users SET 490 + // Update user with Canvas credentials 491 + db.run( 492 + `UPDATE users SET 483 493 canvas_user_id = ?, 484 494 canvas_domain = ?, 485 495 canvas_access_token = ?, 486 496 mcp_api_key = ?, 487 497 last_used_at = ? 488 498 WHERE id = ?`, 489 - [ 490 - canvasUserId, 491 - canvasDomain, 492 - encryptedToken, 493 - hashedApiKey, 494 - Date.now(), 495 - userId, 496 - ] 497 - ); 499 + [ 500 + canvasUserId, 501 + canvasDomain, 502 + encryptedToken, 503 + hashedApiKey, 504 + Date.now(), 505 + userId, 506 + ], 507 + ); 498 508 499 - return { apiKey }; 500 - }, 509 + return { apiKey }; 510 + }, 501 511 502 - // Rate limiting for magic links 503 - canSendMagicLink(email: string, cooldownMs: number = 60000): boolean { 504 - // Check if a magic link was sent recently (within cooldown period) 505 - const recent = db 506 - .query( 507 - "SELECT * FROM magic_links WHERE email = ? AND created_at > ? ORDER BY created_at DESC LIMIT 1" 508 - ) 509 - .get(email, Date.now() - cooldownMs) as any; 512 + // Rate limiting for magic links 513 + canSendMagicLink(email: string, cooldownMs: number = 60000): boolean { 514 + // Check if a magic link was sent recently (within cooldown period) 515 + const recent = db 516 + .query( 517 + "SELECT * FROM magic_links WHERE email = ? AND created_at > ? ORDER BY created_at DESC LIMIT 1", 518 + ) 519 + .get(email, Date.now() - cooldownMs) as any; 510 520 511 - return !recent; 512 - }, 521 + return !recent; 522 + }, 513 523 514 - getLastMagicLinkTime(email: string): number | null { 515 - const recent = db 516 - .query( 517 - "SELECT created_at FROM magic_links WHERE email = ? ORDER BY created_at DESC LIMIT 1" 518 - ) 519 - .get(email) as any; 524 + getLastMagicLinkTime(email: string): number | null { 525 + const recent = db 526 + .query( 527 + "SELECT created_at FROM magic_links WHERE email = ? ORDER BY created_at DESC LIMIT 1", 528 + ) 529 + .get(email) as any; 520 530 521 - return recent ? recent.created_at : null; 522 - }, 531 + return recent ? recent.created_at : null; 532 + }, 523 533 524 - // OAuth tokens 525 - createOAuthToken(userId: number, scope: string, expiresIn: number = 86400000): string { 526 - const token = generateApiKey(); // Reuse the API key generator 527 - const expiresAt = Date.now() + expiresIn; 534 + // OAuth tokens 535 + createOAuthToken( 536 + userId: number, 537 + scope: string, 538 + expiresIn: number = 86400000, 539 + ): string { 540 + const token = generateApiKey(); // Reuse the API key generator 541 + const expiresAt = Date.now() + expiresIn; 528 542 529 - db.run( 530 - "INSERT INTO oauth_tokens (token, user_id, scope, expires_at) VALUES (?, ?, ?, ?)", 531 - [token, userId, scope, expiresAt] 532 - ); 543 + db.run( 544 + "INSERT INTO oauth_tokens (token, user_id, scope, expires_at) VALUES (?, ?, ?, ?)", 545 + [token, userId, scope, expiresAt], 546 + ); 533 547 534 - return token; 535 - }, 548 + return token; 549 + }, 536 550 537 - getUserByOAuthToken(token: string): User | null { 538 - const tokenData = db 539 - .query("SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > ?") 540 - .get(token, Date.now()) as any; 551 + getUserByOAuthToken(token: string): User | null { 552 + const tokenData = db 553 + .query("SELECT * FROM oauth_tokens WHERE token = ? AND expires_at > ?") 554 + .get(token, Date.now()) as any; 541 555 542 - if (!tokenData) { 543 - return null; 544 - } 556 + if (!tokenData) { 557 + return null; 558 + } 545 559 546 - return db.query("SELECT * FROM users WHERE id = ?").get(tokenData.user_id) as User | null; 547 - }, 560 + return db 561 + .query("SELECT * FROM users WHERE id = ?") 562 + .get(tokenData.user_id) as User | null; 563 + }, 548 564 549 - // Cache management utilities 550 - clearApiKeyCache() { 551 - apiKeyCache.clear(); 552 - }, 565 + // Cache management utilities 566 + clearApiKeyCache() { 567 + apiKeyCache.clear(); 568 + }, 553 569 554 - invalidateUserCache(userId: number) { 555 - for (const [key, entry] of apiKeyCache.entries()) { 556 - if (entry.userId === userId) { 557 - apiKeyCache.delete(key); 558 - } 559 - } 560 - }, 570 + invalidateUserCache(userId: number) { 571 + for (const [key, entry] of apiKeyCache.entries()) { 572 + if (entry.userId === userId) { 573 + apiKeyCache.delete(key); 574 + } 575 + } 576 + }, 561 577 562 - getApiKeyCacheStats() { 563 - return { 564 - size: apiKeyCache.size, 565 - ttl: CACHE_TTL, 566 - }; 567 - }, 578 + getApiKeyCacheStats() { 579 + return { 580 + size: apiKeyCache.size, 581 + ttl: CACHE_TTL, 582 + }; 583 + }, 568 584 569 - // Background cleanup operations (run these periodically, not on request path) 570 - cleanupExpiredSessions(): number { 571 - const result = db.run("DELETE FROM sessions WHERE expires_at < ?", [Date.now()]); 572 - return result.changes; 573 - }, 585 + // Background cleanup operations (run these periodically, not on request path) 586 + cleanupExpiredSessions(): number { 587 + const result = db.run("DELETE FROM sessions WHERE expires_at < ?", [ 588 + Date.now(), 589 + ]); 590 + return result.changes; 591 + }, 574 592 575 - cleanupExpiredMagicLinks(): number { 576 - const result = db.run("DELETE FROM magic_links WHERE expires_at < ?", [Date.now()]); 577 - return result.changes; 578 - }, 593 + cleanupExpiredMagicLinks(): number { 594 + const result = db.run("DELETE FROM magic_links WHERE expires_at < ?", [ 595 + Date.now(), 596 + ]); 597 + return result.changes; 598 + }, 579 599 580 - cleanupExpiredAuthCodes(): number { 581 - const result = db.run("DELETE FROM auth_codes WHERE expires_at < ?", [Date.now()]); 582 - return result.changes; 583 - }, 600 + cleanupExpiredAuthCodes(): number { 601 + const result = db.run("DELETE FROM auth_codes WHERE expires_at < ?", [ 602 + Date.now(), 603 + ]); 604 + return result.changes; 605 + }, 584 606 585 - cleanupExpiredOAuthTokens(): number { 586 - const result = db.run("DELETE FROM oauth_tokens WHERE expires_at < ?", [Date.now()]); 587 - return result.changes; 588 - }, 607 + cleanupExpiredOAuthTokens(): number { 608 + const result = db.run("DELETE FROM oauth_tokens WHERE expires_at < ?", [ 609 + Date.now(), 610 + ]); 611 + return result.changes; 612 + }, 589 613 590 - // Clean up old usage logs (keep last 90 days) 591 - cleanupOldUsageLogs(retentionDays: number = 90): number { 592 - const cutoffTime = Date.now() - (retentionDays * 24 * 60 * 60 * 1000); 593 - const result = db.run("DELETE FROM usage_logs WHERE timestamp < ?", [cutoffTime]); 594 - return result.changes; 595 - }, 614 + // Clean up old usage logs (keep last 90 days) 615 + cleanupOldUsageLogs(retentionDays: number = 90): number { 616 + const cutoffTime = Date.now() - retentionDays * 24 * 60 * 60 * 1000; 617 + const result = db.run("DELETE FROM usage_logs WHERE timestamp < ?", [ 618 + cutoffTime, 619 + ]); 620 + return result.changes; 621 + }, 596 622 597 - // Run all cleanup operations 598 - runAllCleanups(): { [key: string]: number } { 599 - const results = { 600 - sessions: this.cleanupExpiredSessions(), 601 - magicLinks: this.cleanupExpiredMagicLinks(), 602 - authCodes: this.cleanupExpiredAuthCodes(), 603 - oauthTokens: this.cleanupExpiredOAuthTokens(), 604 - usageLogs: this.cleanupOldUsageLogs(), 605 - }; 623 + // Run all cleanup operations 624 + runAllCleanups(): { [key: string]: number } { 625 + const results = { 626 + sessions: this.cleanupExpiredSessions(), 627 + magicLinks: this.cleanupExpiredMagicLinks(), 628 + authCodes: this.cleanupExpiredAuthCodes(), 629 + oauthTokens: this.cleanupExpiredOAuthTokens(), 630 + usageLogs: this.cleanupOldUsageLogs(), 631 + }; 606 632 607 - console.log('[Cleanup] Removed expired records:', results); 608 - return results; 609 - }, 633 + console.log("[Cleanup] Removed expired records:", results); 634 + return results; 635 + }, 610 636 }; 611 637 612 638 export default DB;
+81 -79
src/lib/email.ts
··· 3 3 import dkim from "nodemailer-dkim"; 4 4 5 5 const SMTP_HOST = process.env.SMTP_HOST; 6 - const SMTP_PORT = process.env.SMTP_PORT ? parseInt(process.env.SMTP_PORT) : undefined; 6 + const SMTP_PORT = process.env.SMTP_PORT 7 + ? parseInt(process.env.SMTP_PORT) 8 + : undefined; 7 9 const SMTP_USER = process.env.SMTP_USER; 8 10 const SMTP_PASS = process.env.SMTP_PASS; 9 11 const SMTP_FROM = process.env.SMTP_FROM; ··· 15 17 const DKIM_PRIVATE_KEY_FILE = process.env.DKIM_PRIVATE_KEY_FILE; 16 18 17 19 class Mailer { 18 - private transporter: any; 19 - private enabled: boolean; 20 + private transporter: any; 21 + private enabled: boolean; 20 22 21 - constructor() { 22 - // Check if SMTP is configured 23 - if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) { 24 - console.warn("SMTP not configured - email functionality disabled"); 25 - this.enabled = false; 26 - return; 27 - } 23 + constructor() { 24 + // Check if SMTP is configured 25 + if (!SMTP_HOST || !SMTP_PORT || !SMTP_USER || !SMTP_PASS || !SMTP_FROM) { 26 + console.warn("SMTP not configured - email functionality disabled"); 27 + this.enabled = false; 28 + return; 29 + } 28 30 29 - this.enabled = true; 31 + this.enabled = true; 30 32 31 - // Create SMTP transporter 32 - this.transporter = nodemailer.createTransport({ 33 - host: SMTP_HOST, 34 - port: SMTP_PORT, 35 - secure: false, // Use STARTTLS 36 - auth: { 37 - user: SMTP_USER, 38 - pass: SMTP_PASS, 39 - }, 40 - }); 33 + // Create SMTP transporter 34 + this.transporter = nodemailer.createTransport({ 35 + host: SMTP_HOST, 36 + port: SMTP_PORT, 37 + secure: false, // Use STARTTLS 38 + auth: { 39 + user: SMTP_USER, 40 + pass: SMTP_PASS, 41 + }, 42 + }); 41 43 42 - // Add DKIM signing if configured 43 - if (DKIM_SELECTOR && DKIM_DOMAIN && DKIM_PRIVATE_KEY_FILE) { 44 - try { 45 - const dkimPrivateKey = readFileSync(DKIM_PRIVATE_KEY_FILE, "utf-8"); 46 - this.transporter.use( 47 - "stream", 48 - dkim.signer({ 49 - domainName: DKIM_DOMAIN, 50 - keySelector: DKIM_SELECTOR, 51 - privateKey: dkimPrivateKey, 52 - headerFieldNames: "from:to:subject:date:message-id", 53 - }) 54 - ); 55 - console.log("DKIM signing enabled"); 56 - } catch (error) { 57 - console.warn("DKIM private key not found, emails will not be signed"); 58 - } 59 - } 60 - } 44 + // Add DKIM signing if configured 45 + if (DKIM_SELECTOR && DKIM_DOMAIN && DKIM_PRIVATE_KEY_FILE) { 46 + try { 47 + const dkimPrivateKey = readFileSync(DKIM_PRIVATE_KEY_FILE, "utf-8"); 48 + this.transporter.use( 49 + "stream", 50 + dkim.signer({ 51 + domainName: DKIM_DOMAIN, 52 + keySelector: DKIM_SELECTOR, 53 + privateKey: dkimPrivateKey, 54 + headerFieldNames: "from:to:subject:date:message-id", 55 + }), 56 + ); 57 + console.log("DKIM signing enabled"); 58 + } catch (error) { 59 + console.warn("DKIM private key not found, emails will not be signed"); 60 + } 61 + } 62 + } 61 63 62 - private async sendMail( 63 - to: string, 64 - subject: string, 65 - html: string, 66 - text: string 67 - ): Promise<void> { 68 - if (!this.enabled) { 69 - throw new Error("Email is not configured"); 70 - } 64 + private async sendMail( 65 + to: string, 66 + subject: string, 67 + html: string, 68 + text: string, 69 + ): Promise<void> { 70 + if (!this.enabled) { 71 + throw new Error("Email is not configured"); 72 + } 71 73 72 - await this.transporter.sendMail({ 73 - from: SMTP_FROM, 74 - to, 75 - subject, 76 - text, 77 - html, 78 - headers: { 79 - "X-Mailer": "Canvas MCP", 80 - }, 81 - }); 82 - } 74 + await this.transporter.sendMail({ 75 + from: SMTP_FROM, 76 + to, 77 + subject, 78 + text, 79 + html, 80 + headers: { 81 + "X-Mailer": "Canvas MCP", 82 + }, 83 + }); 84 + } 83 85 84 - async sendMagicLink(email: string, token: string): Promise<void> { 85 - const magicLink = `${BASE_URL}/auth/verify?token=${token}`; 86 + async sendMagicLink(email: string, token: string): Promise<void> { 87 + const magicLink = `${BASE_URL}/auth/verify?token=${token}`; 86 88 87 - const html = `<!DOCTYPE html> 89 + const html = `<!DOCTYPE html> 88 90 <html> 89 91 <head> 90 92 <meta charset="utf-8"> ··· 104 106 </body> 105 107 </html>`; 106 108 107 - const text = `Sign in to Canvas MCP 109 + const text = `Sign in to Canvas MCP 108 110 109 111 Click this link to sign in: 110 112 ${magicLink} ··· 112 114 This link expires in 15 minutes. 113 115 If you didn't request this, you can safely ignore it.`; 114 116 115 - await this.sendMail(email, "Sign in to Canvas MCP", html, text); 116 - } 117 + await this.sendMail(email, "Sign in to Canvas MCP", html, text); 118 + } 117 119 118 - async sendOAuthConfirmation( 119 - email: string, 120 - canvasDomain: string 121 - ): Promise<void> { 122 - const html = `<!DOCTYPE html> 120 + async sendOAuthConfirmation( 121 + email: string, 122 + canvasDomain: string, 123 + ): Promise<void> { 124 + const html = `<!DOCTYPE html> 123 125 <html> 124 126 <head> 125 127 <meta charset="utf-8"> ··· 147 149 </body> 148 150 </html>`; 149 151 150 - const text = `Canvas Account Connected! 152 + const text = `Canvas Account Connected! 151 153 152 154 Your Canvas account (${canvasDomain}) has been successfully connected. 153 155 ··· 158 160 2. Authorize Claude to access your Canvas data 159 161 3. Start asking questions about your courses!`; 160 162 161 - await this.sendMail( 162 - email, 163 - "Canvas Account Connected - Canvas MCP", 164 - html, 165 - text 166 - ); 167 - } 163 + await this.sendMail( 164 + email, 165 + "Canvas Account Connected - Canvas MCP", 166 + html, 167 + text, 168 + ); 169 + } 168 170 } 169 171 170 172 export default new Mailer();
+228 -225
src/lib/mcp-server.ts
··· 1 1 import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 2 import { 3 - CallToolRequestSchema, 4 - ListToolsRequestSchema, 5 - Tool, 3 + CallToolRequestSchema, 4 + ListToolsRequestSchema, 5 + Tool, 6 6 } from "@modelcontextprotocol/sdk/types.js"; 7 7 import { z } from "zod"; 8 8 import { CanvasClient } from "./canvas.js"; ··· 10 10 11 11 // Create MCP Server instance with user context 12 12 export function createMcpServer(userId: number): Server { 13 - const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; 13 + const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; 14 14 15 - const server = new Server( 16 - { 17 - name: "canvas-mcp", 18 - version: "1.0.0", 19 - title: "Canvas LMS", 20 - description: "Access your Canvas courses, assignments, grades, and announcements", 21 - websiteUrl: BASE_URL, 22 - icons: [ 23 - { 24 - src: `${BASE_URL}/favicon.ico`, 25 - mimeType: "image/x-icon", 26 - sizes: ["32x32"], 27 - }, 28 - ], 29 - }, 30 - { 31 - capabilities: { 32 - tools: {}, 33 - }, 34 - } 35 - ); 15 + const server = new Server( 16 + { 17 + name: "canvas-mcp", 18 + version: "1.0.0", 19 + title: "Canvas LMS", 20 + description: 21 + "Access your Canvas courses, assignments, grades, and announcements", 22 + websiteUrl: BASE_URL, 23 + icons: [ 24 + { 25 + src: `${BASE_URL}/favicon.ico`, 26 + mimeType: "image/x-icon", 27 + sizes: ["32x32"], 28 + }, 29 + ], 30 + }, 31 + { 32 + capabilities: { 33 + tools: {}, 34 + }, 35 + }, 36 + ); 36 37 37 - // Register handlers with user context 38 - registerHandlers(server, userId); 38 + // Register handlers with user context 39 + registerHandlers(server, userId); 39 40 40 - return server; 41 + return server; 41 42 } 42 43 43 44 function registerHandlers(mcpServer: Server, userId: number) { 45 + // Define tool schemas 46 + const listCoursesSchema = z.object({ 47 + enrollment_state: z 48 + .enum(["active", "completed", "invited", "rejected"]) 49 + .optional(), 50 + }); 44 51 45 - // Define tool schemas 46 - const listCoursesSchema = z.object({ 47 - enrollment_state: z 48 - .enum(["active", "completed", "invited", "rejected"]) 49 - .optional(), 50 - }); 52 + const getAssignmentSchema = z.object({ 53 + course_id: z.number(), 54 + assignment_id: z.number(), 55 + }); 51 56 52 - const getAssignmentSchema = z.object({ 53 - course_id: z.number(), 54 - assignment_id: z.number(), 55 - }); 57 + const getAnnouncementsSchema = z.object({ 58 + course_id: z.number().optional(), 59 + limit: z.number().min(1).max(50).optional(), 60 + }); 56 61 57 - const getAnnouncementsSchema = z.object({ 58 - course_id: z.number().optional(), 59 - limit: z.number().min(1).max(50).optional(), 60 - }); 62 + const getGradesSchema = z.object({ 63 + course_id: z.number().optional(), 64 + }); 61 65 62 - const getGradesSchema = z.object({ 63 - course_id: z.number().optional(), 64 - }); 66 + // Tool definitions 67 + const tools: Tool[] = [ 68 + { 69 + name: "list_courses", 70 + description: 71 + "List Canvas courses for the authenticated user. Can filter by enrollment state (active, completed, invited, rejected).", 72 + inputSchema: { 73 + type: "object", 74 + properties: { 75 + enrollment_state: { 76 + type: "string", 77 + enum: ["active", "completed", "invited", "rejected"], 78 + description: "Filter courses by enrollment state", 79 + }, 80 + }, 81 + }, 82 + }, 83 + { 84 + name: "get_assignment", 85 + description: 86 + "Get detailed information about a specific assignment including description, due date, points, and submission details.", 87 + inputSchema: { 88 + type: "object", 89 + properties: { 90 + course_id: { 91 + type: "number", 92 + description: "The Canvas course ID", 93 + }, 94 + assignment_id: { 95 + type: "number", 96 + description: "The Canvas assignment ID", 97 + }, 98 + }, 99 + required: ["course_id", "assignment_id"], 100 + }, 101 + }, 102 + { 103 + name: "get_upcoming_assignments", 104 + description: 105 + "Get upcoming assignments and deadlines for the next 30 days across all courses. Returns assignments with due dates, to-do items, and calendar events.", 106 + inputSchema: { 107 + type: "object", 108 + properties: {}, 109 + }, 110 + }, 111 + { 112 + name: "get_announcements", 113 + description: 114 + "Get course announcements. Can retrieve announcements from a specific course or across all courses, sorted by most recent first.", 115 + inputSchema: { 116 + type: "object", 117 + properties: { 118 + course_id: { 119 + type: "number", 120 + description: 121 + "Optional course ID to get announcements from a specific course. If not provided, returns announcements from all courses.", 122 + }, 123 + limit: { 124 + type: "number", 125 + description: 126 + "Maximum number of announcements to return (1-50). Default is 10.", 127 + }, 128 + }, 129 + }, 130 + }, 131 + { 132 + name: "get_grades", 133 + description: 134 + "Get grades and submission information. Can retrieve grades for a specific course (including individual assignment submissions) or overall grades across all courses.", 135 + inputSchema: { 136 + type: "object", 137 + properties: { 138 + course_id: { 139 + type: "number", 140 + description: 141 + "Optional course ID to get detailed grades and submissions for a specific course. If not provided, returns summary grades for all courses.", 142 + }, 143 + }, 144 + }, 145 + }, 146 + ]; 65 147 66 - // Tool definitions 67 - const tools: Tool[] = [ 68 - { 69 - name: "list_courses", 70 - description: 71 - "List Canvas courses for the authenticated user. Can filter by enrollment state (active, completed, invited, rejected).", 72 - inputSchema: { 73 - type: "object", 74 - properties: { 75 - enrollment_state: { 76 - type: "string", 77 - enum: ["active", "completed", "invited", "rejected"], 78 - description: "Filter courses by enrollment state", 79 - }, 80 - }, 81 - }, 82 - }, 83 - { 84 - name: "get_assignment", 85 - description: 86 - "Get detailed information about a specific assignment including description, due date, points, and submission details.", 87 - inputSchema: { 88 - type: "object", 89 - properties: { 90 - course_id: { 91 - type: "number", 92 - description: "The Canvas course ID", 93 - }, 94 - assignment_id: { 95 - type: "number", 96 - description: "The Canvas assignment ID", 97 - }, 98 - }, 99 - required: ["course_id", "assignment_id"], 100 - }, 101 - }, 102 - { 103 - name: "get_upcoming_assignments", 104 - description: 105 - "Get upcoming assignments and deadlines for the next 30 days across all courses. Returns assignments with due dates, to-do items, and calendar events.", 106 - inputSchema: { 107 - type: "object", 108 - properties: {}, 109 - }, 110 - }, 111 - { 112 - name: "get_announcements", 113 - description: 114 - "Get course announcements. Can retrieve announcements from a specific course or across all courses, sorted by most recent first.", 115 - inputSchema: { 116 - type: "object", 117 - properties: { 118 - course_id: { 119 - type: "number", 120 - description: "Optional course ID to get announcements from a specific course. If not provided, returns announcements from all courses.", 121 - }, 122 - limit: { 123 - type: "number", 124 - description: "Maximum number of announcements to return (1-50). Default is 10.", 125 - }, 126 - }, 127 - }, 128 - }, 129 - { 130 - name: "get_grades", 131 - description: 132 - "Get grades and submission information. Can retrieve grades for a specific course (including individual assignment submissions) or overall grades across all courses.", 133 - inputSchema: { 134 - type: "object", 135 - properties: { 136 - course_id: { 137 - type: "number", 138 - description: "Optional course ID to get detailed grades and submissions for a specific course. If not provided, returns summary grades for all courses.", 139 - }, 140 - }, 141 - }, 142 - }, 143 - ]; 148 + // Register list_tools handler 149 + mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { 150 + return { tools }; 151 + }); 144 152 145 - // Register list_tools handler 146 - mcpServer.setRequestHandler(ListToolsRequestSchema, async () => { 147 - return { tools }; 148 - }); 153 + // Register call_tool handler 154 + mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { 155 + const { name, arguments: args } = request.params; 149 156 150 - // Register call_tool handler 151 - mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => { 152 - const { name, arguments: args } = request.params; 157 + // Get user and Canvas token from database 158 + const userData = DB.raw 159 + .query("SELECT * FROM users WHERE id = ?") 160 + .get(userId) as any; 153 161 154 - // Get user and Canvas token from database 155 - const userData = DB.raw 156 - .query("SELECT * FROM users WHERE id = ?") 157 - .get(userId) as any; 162 + if (!userData) { 163 + throw new Error("User not found"); 164 + } 158 165 159 - if (!userData) { 160 - throw new Error("User not found"); 161 - } 166 + const canvasToken = DB.getCanvasToken(userData); 167 + const client = new CanvasClient(userData.canvas_domain, canvasToken); 162 168 163 - const canvasToken = DB.getCanvasToken(userData); 164 - const client = new CanvasClient(userData.canvas_domain, canvasToken); 169 + // Log usage 170 + DB.logUsage(userId, name); 171 + DB.updateLastUsed(userId); 165 172 166 - // Log usage 167 - DB.logUsage(userId, name); 168 - DB.updateLastUsed(userId); 169 - 170 - try { 171 - switch (name) { 172 - case "list_courses": { 173 - const params = listCoursesSchema.parse(args); 174 - const courses = await client.listCourses(params); 175 - return { 176 - content: [ 177 - { 178 - type: "text", 179 - text: JSON.stringify(courses, null, 2), 180 - }, 181 - ], 182 - }; 183 - } 173 + try { 174 + switch (name) { 175 + case "list_courses": { 176 + const params = listCoursesSchema.parse(args); 177 + const courses = await client.listCourses(params); 178 + return { 179 + content: [ 180 + { 181 + type: "text", 182 + text: JSON.stringify(courses, null, 2), 183 + }, 184 + ], 185 + }; 186 + } 184 187 185 - case "get_assignment": { 186 - const params = getAssignmentSchema.parse(args); 187 - const assignment = await client.getAssignment( 188 - params.course_id, 189 - params.assignment_id 190 - ); 191 - return { 192 - content: [ 193 - { 194 - type: "text", 195 - text: JSON.stringify(assignment, null, 2), 196 - }, 197 - ], 198 - }; 199 - } 188 + case "get_assignment": { 189 + const params = getAssignmentSchema.parse(args); 190 + const assignment = await client.getAssignment( 191 + params.course_id, 192 + params.assignment_id, 193 + ); 194 + return { 195 + content: [ 196 + { 197 + type: "text", 198 + text: JSON.stringify(assignment, null, 2), 199 + }, 200 + ], 201 + }; 202 + } 200 203 201 - case "get_upcoming_assignments": { 202 - const upcoming = await client.getUpcomingAssignments(); 203 - return { 204 - content: [ 205 - { 206 - type: "text", 207 - text: JSON.stringify(upcoming, null, 2), 208 - }, 209 - ], 210 - }; 211 - } 204 + case "get_upcoming_assignments": { 205 + const upcoming = await client.getUpcomingAssignments(); 206 + return { 207 + content: [ 208 + { 209 + type: "text", 210 + text: JSON.stringify(upcoming, null, 2), 211 + }, 212 + ], 213 + }; 214 + } 212 215 213 - case "get_announcements": { 214 - const params = getAnnouncementsSchema.parse(args); 215 - const announcements = await client.getCourseAnnouncements( 216 - params.course_id, 217 - params.limit 218 - ); 219 - return { 220 - content: [ 221 - { 222 - type: "text", 223 - text: JSON.stringify(announcements, null, 2), 224 - }, 225 - ], 226 - }; 227 - } 216 + case "get_announcements": { 217 + const params = getAnnouncementsSchema.parse(args); 218 + const announcements = await client.getCourseAnnouncements( 219 + params.course_id, 220 + params.limit, 221 + ); 222 + return { 223 + content: [ 224 + { 225 + type: "text", 226 + text: JSON.stringify(announcements, null, 2), 227 + }, 228 + ], 229 + }; 230 + } 228 231 229 - case "get_grades": { 230 - const params = getGradesSchema.parse(args); 231 - const grades = await client.getGradesAndSubmissions(params.course_id); 232 - return { 233 - content: [ 234 - { 235 - type: "text", 236 - text: JSON.stringify(grades, null, 2), 237 - }, 238 - ], 239 - }; 240 - } 232 + case "get_grades": { 233 + const params = getGradesSchema.parse(args); 234 + const grades = await client.getGradesAndSubmissions(params.course_id); 235 + return { 236 + content: [ 237 + { 238 + type: "text", 239 + text: JSON.stringify(grades, null, 2), 240 + }, 241 + ], 242 + }; 243 + } 241 244 242 - default: 243 - throw new Error(`Unknown tool: ${name}`); 244 - } 245 - } catch (error: any) { 246 - return { 247 - content: [ 248 - { 249 - type: "text", 250 - text: `Error: ${error.message}`, 251 - }, 252 - ], 253 - isError: true, 254 - }; 255 - } 256 - }); 245 + default: 246 + throw new Error(`Unknown tool: ${name}`); 247 + } 248 + } catch (error: any) { 249 + return { 250 + content: [ 251 + { 252 + type: "text", 253 + text: `Error: ${error.message}`, 254 + }, 255 + ], 256 + isError: true, 257 + }; 258 + } 259 + }); 257 260 }
+78 -78
src/lib/mcp-transport.ts
··· 5 5 6 6 // Handle MCP Streamable HTTP requests 7 7 export async function handleMcpRequest( 8 - req: Request, 9 - apiToken?: string 8 + req: Request, 9 + apiToken?: string, 10 10 ): Promise<Response> { 11 - // Validate API token 12 - if (!apiToken) { 13 - const baseUrl = process.env.BASE_URL || "http://localhost:3000"; 14 - return new Response( 15 - JSON.stringify({ 16 - jsonrpc: "2.0", 17 - error: { 18 - code: -32001, 19 - message: "Missing API token. Please authenticate first.", 20 - }, 21 - id: null, 22 - }), 23 - { 24 - status: 401, 25 - headers: { 26 - "Content-Type": "application/json", 27 - "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="canvas:read canvas:courses:read"`, 28 - }, 29 - } 30 - ); 31 - } 11 + // Validate API token 12 + if (!apiToken) { 13 + const baseUrl = process.env.BASE_URL || "http://localhost:3000"; 14 + return new Response( 15 + JSON.stringify({ 16 + jsonrpc: "2.0", 17 + error: { 18 + code: -32001, 19 + message: "Missing API token. Please authenticate first.", 20 + }, 21 + id: null, 22 + }), 23 + { 24 + status: 401, 25 + headers: { 26 + "Content-Type": "application/json", 27 + "WWW-Authenticate": `Bearer resource_metadata="${baseUrl}/.well-known/oauth-protected-resource", scope="canvas:read canvas:courses:read"`, 28 + }, 29 + }, 30 + ); 31 + } 32 32 33 - // Look up user by API key or OAuth token 34 - let user = await DB.getUserByApiKey(apiToken); 33 + // Look up user by API key or OAuth token 34 + let user = await DB.getUserByApiKey(apiToken); 35 35 36 - // If not found as API key, try as OAuth token 37 - if (!user) { 38 - user = DB.getUserByOAuthToken(apiToken); 39 - } 36 + // If not found as API key, try as OAuth token 37 + if (!user) { 38 + user = DB.getUserByOAuthToken(apiToken); 39 + } 40 40 41 - if (!user) { 42 - return new Response( 43 - JSON.stringify({ 44 - jsonrpc: "2.0", 45 - error: { 46 - code: -32001, 47 - message: "Invalid or expired token", 48 - }, 49 - id: null, 50 - }), 51 - { 52 - status: 401, 53 - headers: { "Content-Type": "application/json" }, 54 - } 55 - ); 56 - } 41 + if (!user) { 42 + return new Response( 43 + JSON.stringify({ 44 + jsonrpc: "2.0", 45 + error: { 46 + code: -32001, 47 + message: "Invalid or expired token", 48 + }, 49 + id: null, 50 + }), 51 + { 52 + status: 401, 53 + headers: { "Content-Type": "application/json" }, 54 + }, 55 + ); 56 + } 57 57 58 - try { 59 - // Create stateless transport (new transport per request) 60 - const transport = new WebStandardStreamableHTTPServerTransport({ 61 - sessionIdGenerator: undefined, // Stateless mode 62 - }); 58 + try { 59 + // Create stateless transport (new transport per request) 60 + const transport = new WebStandardStreamableHTTPServerTransport({ 61 + sessionIdGenerator: undefined, // Stateless mode 62 + }); 63 63 64 - // Create MCP server instance with user context 65 - const server = createMcpServer(user.id); 64 + // Create MCP server instance with user context 65 + const server = createMcpServer(user.id); 66 66 67 - // Connect server to transport 68 - await server.connect(transport); 67 + // Connect server to transport 68 + await server.connect(transport); 69 69 70 - // Handle the request through transport 71 - return await transport.handleRequest(req); 72 - } catch (error: any) { 73 - return new Response( 74 - JSON.stringify({ 75 - jsonrpc: "2.0", 76 - error: { 77 - code: -32603, 78 - message: error.message || "Internal server error", 79 - }, 80 - id: null, 81 - }), 82 - { 83 - status: 500, 84 - headers: { "Content-Type": "application/json" }, 85 - } 86 - ); 87 - } 70 + // Handle the request through transport 71 + return await transport.handleRequest(req); 72 + } catch (error: any) { 73 + return new Response( 74 + JSON.stringify({ 75 + jsonrpc: "2.0", 76 + error: { 77 + code: -32603, 78 + message: error.message || "Internal server error", 79 + }, 80 + id: null, 81 + }), 82 + { 83 + status: 500, 84 + headers: { "Content-Type": "application/json" }, 85 + }, 86 + ); 87 + } 88 88 } 89 89 90 90 // Protected Resource Metadata for OAuth discovery 91 91 export function getProtectedResourceMetadata(baseUrl: string) { 92 - return { 93 - resource: baseUrl, 94 - authorization_servers: [`${baseUrl}/auth`], 95 - bearer_methods_supported: ["header"], 96 - scopes_supported: ["canvas:read"], 97 - }; 92 + return { 93 + resource: baseUrl, 94 + authorization_servers: [`${baseUrl}/auth`], 95 + bearer_methods_supported: ["header"], 96 + scopes_supported: ["canvas:read"], 97 + }; 98 98 }