UNPKG

canvas-mcp-server

Version:

A comprehensive Model Context Protocol (MCP) server for Canvas LMS with full student functionality and account management

637 lines (636 loc) 24.1 kB
// src/client.ts import axios from 'axios'; import { CanvasAPIError } from './types.js'; export class CanvasClient { constructor(token, domain, options) { this.maxRetries = 3; this.retryDelay = 1000; this.baseURL = `https://${domain}/api/v1`; this.maxRetries = options?.maxRetries ?? 3; this.retryDelay = options?.retryDelay ?? 1000; this.client = axios.create({ baseURL: this.baseURL, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 30000 // 30 second timeout }); this.setupInterceptors(); } setupInterceptors() { // Request interceptor for logging this.client.interceptors.request.use((config) => { console.error(`[Canvas API] ${config.method?.toUpperCase()} ${config.url}`); return config; }, (error) => { console.error('[Canvas API] Request error:', error.message || error); return Promise.reject(error); }); // Response interceptor for pagination and retry logic this.client.interceptors.response.use(async (response) => { const { headers, data } = response; const linkHeader = headers.link; const contentType = headers['content-type'] || ''; // Only handle pagination for JSON responses if (Array.isArray(data) && linkHeader && contentType.includes('application/json')) { let allData = [...data]; let nextUrl = this.getNextPageUrl(linkHeader); while (nextUrl) { const nextResponse = await this.client.get(nextUrl); allData = [...allData, ...nextResponse.data]; nextUrl = this.getNextPageUrl(nextResponse.headers.link); } response.data = allData; } return response; }, async (error) => { const config = error.config; // Retry logic for specific errors if (this.shouldRetry(error) && config && config.__retryCount < this.maxRetries) { config.__retryCount = config.__retryCount || 0; config.__retryCount++; const delay = this.retryDelay * Math.pow(2, config.__retryCount - 1); // Exponential backoff console.error(`[Canvas API] Retrying request (${config.__retryCount}/${this.maxRetries}) after ${delay}ms`); await this.sleep(delay); return this.client.request(config); } // Transform error with better handling for non-JSON responses if (error.response) { const { status, data, headers } = error.response; const contentType = headers?.['content-type'] || 'unknown'; console.error(`[Canvas API] Error response: ${status}, Content-Type: ${contentType}, Data type: ${typeof data}`); let errorMessage; try { // Check if data is already a string (HTML error pages, plain text, etc.) if (typeof data === 'string') { errorMessage = data.length > 200 ? data.substring(0, 200) + '...' : data; } else if (data && typeof data === 'object') { // Handle structured Canvas API error responses if (data?.message) { errorMessage = data.message; } else if (data?.errors && Array.isArray(data.errors)) { errorMessage = data.errors.map((err) => err.message || err).join(', '); } else { errorMessage = JSON.stringify(data); } } else { errorMessage = String(data); } } catch (jsonError) { // Fallback if JSON operations fail errorMessage = String(data); } throw new CanvasAPIError(`Canvas API Error (${status}): ${errorMessage}`, status, data); } // Handle network errors or other issues if (error.request) { console.error('[Canvas API] Network error - no response received:', error.message); throw new CanvasAPIError(`Network error: ${error.message}`, 0, null); } console.error('[Canvas API] Unexpected error:', error.message); throw error; }); } shouldRetry(error) { if (!error.response) return true; // Network errors const status = error.response.status; return status === 429 || status >= 500; // Rate limit or server errors } sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } getNextPageUrl(linkHeader) { const links = linkHeader.split(','); const nextLink = links.find(link => link.includes('rel="next"')); if (!nextLink) return null; const match = nextLink.match(/<(.+?)>/); return match ? match[1] : null; } // --------------------- // HEALTH CHECK // --------------------- async healthCheck() { try { const user = await this.getUserProfile(); return { status: 'ok', timestamp: new Date().toISOString(), user: { id: user.id, name: user.name } }; } catch (error) { return { status: 'error', timestamp: new Date().toISOString() }; } } // --------------------- // COURSES (Enhanced) // --------------------- async listCourses(includeEnded = false) { const params = { include: ['total_students', 'teachers', 'term', 'course_progress'] }; if (!includeEnded) { params.state = ['available', 'completed']; } const response = await this.client.get('/courses', { params }); return response.data; } async getCourse(courseId) { const response = await this.client.get(`/courses/${courseId}`, { params: { include: ['total_students', 'teachers', 'term', 'course_progress', 'sections', 'syllabus_body'] } }); return response.data; } async createCourse(args) { const { account_id, ...courseData } = args; const response = await this.client.post(`/accounts/${account_id}/courses`, { course: courseData }); return response.data; } async updateCourse(args) { const { course_id, ...courseData } = args; const response = await this.client.put(`/courses/${course_id}`, { course: courseData }); return response.data; } async deleteCourse(courseId) { await this.client.delete(`/courses/${courseId}`); } // --------------------- // ASSIGNMENTS (Enhanced) // --------------------- async listAssignments(courseId, includeSubmissions = false) { const params = { include: ['assignment_group', 'rubric', 'due_at'] }; if (includeSubmissions) { params.include.push('submission'); } const response = await this.client.get(`/courses/${courseId}/assignments`, { params }); return response.data; } async getAssignment(courseId, assignmentId, includeSubmission = false) { const params = { include: ['assignment_group', 'rubric'] }; if (includeSubmission) { params.include.push('submission'); } const response = await this.client.get(`/courses/${courseId}/assignments/${assignmentId}`, { params }); return response.data; } async createAssignment(args) { const { course_id, ...assignmentData } = args; const response = await this.client.post(`/courses/${course_id}/assignments`, { assignment: assignmentData }); return response.data; } async updateAssignment(args) { const { course_id, assignment_id, ...assignmentData } = args; const response = await this.client.put(`/courses/${course_id}/assignments/${assignment_id}`, { assignment: assignmentData }); return response.data; } async deleteAssignment(courseId, assignmentId) { await this.client.delete(`/courses/${courseId}/assignments/${assignmentId}`); } // --------------------- // ASSIGNMENT GROUPS // --------------------- async listAssignmentGroups(courseId) { const response = await this.client.get(`/courses/${courseId}/assignment_groups`, { params: { include: ['assignments'] } }); return response.data; } async getAssignmentGroup(courseId, groupId) { const response = await this.client.get(`/courses/${courseId}/assignment_groups/${groupId}`, { params: { include: ['assignments'] } }); return response.data; } // --------------------- // SUBMISSIONS (Enhanced for Students) // --------------------- async getSubmissions(courseId, assignmentId) { const response = await this.client.get(`/courses/${courseId}/assignments/${assignmentId}/submissions`, { params: { include: ['submission_comments', 'rubric_assessment', 'assignment'] } }); return response.data; } async getSubmission(courseId, assignmentId, userId = 'self') { const response = await this.client.get(`/courses/${courseId}/assignments/${assignmentId}/submissions/${userId}`, { params: { include: ['submission_comments', 'rubric_assessment', 'assignment'] } }); return response.data; } async submitGrade(args) { const { course_id, assignment_id, user_id, grade, comment } = args; const response = await this.client.put(`/courses/${course_id}/assignments/${assignment_id}/submissions/${user_id}`, { submission: { posted_grade: grade, comment: comment ? { text_comment: comment } : undefined } }); return response.data; } // Student submission with file support async submitAssignment(args) { const { course_id, assignment_id, submission_type, body, url, file_ids } = args; const submissionData = { submission_type }; if (body) submissionData.body = body; if (url) submissionData.url = url; if (file_ids && file_ids.length > 0) submissionData.file_ids = file_ids; const response = await this.client.post(`/courses/${course_id}/assignments/${assignment_id}/submissions`, { submission: submissionData }); return response.data; } // --------------------- // FILES (Enhanced) // --------------------- async listFiles(courseId, folderId) { const endpoint = folderId ? `/folders/${folderId}/files` : `/courses/${courseId}/files`; const response = await this.client.get(endpoint); return response.data; } async getFile(fileId) { const response = await this.client.get(`/files/${fileId}`); return response.data; } async uploadFile(args) { const { course_id, folder_id, name, size } = args; // Step 1: Get upload URL const uploadEndpoint = folder_id ? `/folders/${folder_id}/files` : `/courses/${course_id}/files`; const uploadResponse = await this.client.post(uploadEndpoint, { name, size, content_type: args.content_type || 'application/octet-stream' }); // Note: Actual file upload would require multipart form data handling // This is a simplified version - in practice, you'd need to handle the // two-step upload process Canvas uses return uploadResponse.data; } async listFolders(courseId) { const response = await this.client.get(`/courses/${courseId}/folders`); return response.data; } // --------------------- // PAGES // --------------------- async listPages(courseId) { const response = await this.client.get(`/courses/${courseId}/pages`); return response.data; } async getPage(courseId, pageUrl) { const response = await this.client.get(`/courses/${courseId}/pages/${pageUrl}`); return response.data; } // --------------------- // CALENDAR EVENTS // --------------------- async listCalendarEvents(startDate, endDate) { const params = { type: 'event', all_events: true }; if (startDate) params.start_date = startDate; if (endDate) params.end_date = endDate; const response = await this.client.get('/calendar_events', { params }); return response.data; } async getUpcomingAssignments(limit = 10) { const response = await this.client.get('/users/self/upcoming_events', { params: { limit } }); return response.data.filter((event) => event.assignment); } // --------------------- // RUBRICS // --------------------- async listRubrics(courseId) { const response = await this.client.get(`/courses/${courseId}/rubrics`); return response.data; } async getRubric(courseId, rubricId) { const response = await this.client.get(`/courses/${courseId}/rubrics/${rubricId}`); return response.data; } // --------------------- // DASHBOARD // --------------------- async getDashboard() { const response = await this.client.get('/users/self/dashboard'); return response.data; } async getDashboardCards() { const response = await this.client.get('/dashboard/dashboard_cards'); return response.data; } // --------------------- // SYLLABUS // --------------------- async getSyllabus(courseId) { const response = await this.client.get(`/courses/${courseId}`, { params: { include: ['syllabus_body'] } }); return { course_id: courseId, syllabus_body: response.data.syllabus_body }; } // --------------------- // CONVERSATIONS/MESSAGING // --------------------- async listConversations() { const response = await this.client.get('/conversations'); return response.data; } async getConversation(conversationId) { const response = await this.client.get(`/conversations/${conversationId}`); return response.data; } async createConversation(recipients, body, subject) { const response = await this.client.post('/conversations', { recipients, body, subject }); return response.data; } // --------------------- // NOTIFICATIONS // --------------------- async listNotifications() { const response = await this.client.get('/users/self/activity_stream'); return response.data; } // --------------------- // USERS AND ENROLLMENTS (Enhanced) // --------------------- async listUsers(courseId) { const response = await this.client.get(`/courses/${courseId}/users`, { params: { include: ['email', 'enrollments', 'avatar_url'] } }); return response.data; } async getEnrollments(courseId) { const response = await this.client.get(`/courses/${courseId}/enrollments`); return response.data; } async enrollUser(args) { const { course_id, user_id, role = 'StudentEnrollment', enrollment_state = 'active' } = args; const response = await this.client.post(`/courses/${course_id}/enrollments`, { enrollment: { user_id, type: role, enrollment_state } }); return response.data; } async unenrollUser(courseId, enrollmentId) { await this.client.delete(`/courses/${courseId}/enrollments/${enrollmentId}`); } // --------------------- // GRADES (Enhanced) // --------------------- async getCourseGrades(courseId) { const response = await this.client.get(`/courses/${courseId}/enrollments`, { params: { include: ['grades', 'observed_users'] } }); return response.data; } async getUserGrades() { const response = await this.client.get('/users/self/grades'); return response.data; } // --------------------- // USER PROFILE (Enhanced) // --------------------- async getUserProfile() { const response = await this.client.get('/users/self/profile'); return response.data; } async updateUserProfile(profileData) { const response = await this.client.put('/users/self', { user: profileData }); return response.data; } // --------------------- // STUDENT COURSES (Enhanced) // --------------------- async listStudentCourses() { const response = await this.client.get('/courses', { params: { include: ['enrollments', 'total_students', 'term', 'course_progress'], enrollment_state: 'active' } }); return response.data; } // --------------------- // MODULES (Enhanced) // --------------------- async listModules(courseId) { const response = await this.client.get(`/courses/${courseId}/modules`, { params: { include: ['items'] } }); return response.data; } async getModule(courseId, moduleId) { const response = await this.client.get(`/courses/${courseId}/modules/${moduleId}`, { params: { include: ['items'] } }); return response.data; } async listModuleItems(courseId, moduleId) { const response = await this.client.get(`/courses/${courseId}/modules/${moduleId}/items`, { params: { include: ['content_details'] } }); return response.data; } async getModuleItem(courseId, moduleId, itemId) { const response = await this.client.get(`/courses/${courseId}/modules/${moduleId}/items/${itemId}`, { params: { include: ['content_details'] } }); return response.data; } async markModuleItemComplete(courseId, moduleId, itemId) { await this.client.put(`/courses/${courseId}/modules/${moduleId}/items/${itemId}/done`); } // --------------------- // DISCUSSION TOPICS (Enhanced) // --------------------- async listDiscussionTopics(courseId) { const response = await this.client.get(`/courses/${courseId}/discussion_topics`, { params: { include: ['assignment'] } }); return response.data; } async getDiscussionTopic(courseId, topicId) { const response = await this.client.get(`/courses/${courseId}/discussion_topics/${topicId}`, { params: { include: ['assignment'] } }); return response.data; } async postToDiscussion(courseId, topicId, message) { const response = await this.client.post(`/courses/${courseId}/discussion_topics/${topicId}/entries`, { message }); return response.data; } // --------------------- // ANNOUNCEMENTS (Enhanced) // --------------------- async listAnnouncements(courseId) { const response = await this.client.get(`/courses/${courseId}/discussion_topics`, { params: { type: 'announcement', include: ['assignment'] } }); return response.data; } // --------------------- // QUIZZES (Enhanced) // --------------------- async listQuizzes(courseId) { const response = await this.client.get(`/courses/${courseId}/quizzes`); return response.data; } async getQuiz(courseId, quizId) { const response = await this.client.get(`/courses/${courseId}/quizzes/${quizId}`); return response.data; } async createQuiz(courseId, quizData) { const response = await this.client.post(`/courses/${courseId}/quizzes`, { quiz: quizData }); return response.data; } async updateQuiz(courseId, quizId, quizData) { const response = await this.client.put(`/courses/${courseId}/quizzes/${quizId}`, { quiz: quizData }); return response.data; } async deleteQuiz(courseId, quizId) { await this.client.delete(`/courses/${courseId}/quizzes/${quizId}`); } async startQuizAttempt(courseId, quizId) { const response = await this.client.post(`/courses/${courseId}/quizzes/${quizId}/submissions`); return response.data; } async submitQuizAttempt(courseId, quizId, submissionId, answers) { const response = await this.client.post(`/courses/${courseId}/quizzes/${quizId}/submissions/${submissionId}/complete`, { quiz_submissions: [{ attempt: 1, questions: answers }] }); return response.data; } // --------------------- // SCOPES (Enhanced) // --------------------- async listTokenScopes(accountId, groupBy) { const params = {}; if (groupBy) { params.group_by = groupBy; } const response = await this.client.get(`/accounts/${accountId}/scopes`, { params }); return response.data; } // --------------------- // ACCOUNT MANAGEMENT (New) // --------------------- async getAccount(accountId) { const response = await this.client.get(`/accounts/${accountId}`); return response.data; } async listAccountCourses(args) { const { account_id, ...params } = args; const response = await this.client.get(`/accounts/${account_id}/courses`, { params }); return response.data; } async listAccountUsers(args) { const { account_id, ...params } = args; const response = await this.client.get(`/accounts/${account_id}/users`, { params }); return response.data; } async createUser(args) { const { account_id, ...userData } = args; const response = await this.client.post(`/accounts/${account_id}/users`, userData); return response.data; } async listSubAccounts(accountId) { const response = await this.client.get(`/accounts/${accountId}/sub_accounts`); return response.data; } // --------------------- // ACCOUNT REPORTS (New) // --------------------- async getAccountReports(accountId) { const response = await this.client.get(`/accounts/${accountId}/reports`); return response.data; } async createAccountReport(args) { const { account_id, report, parameters } = args; const response = await this.client.post(`/accounts/${account_id}/reports/${report}`, { parameters: parameters || {} }); return response.data; } async getAccountReport(accountId, reportType, reportId) { const response = await this.client.get(`/accounts/${accountId}/reports/${reportType}/${reportId}`); return response.data; } }