Canvas MCP Server

by vishalsachdev
Verified
#!/usr/bin/env python3 from typing import Any, Dict, List, Optional, Union, TypedDict import os import sys import httpx import json from mcp.server.fastmcp import FastMCP # Initialize FastMCP server mcp = FastMCP("canvas-api") # Constants API_BASE_URL = os.environ.get("CANVAS_API_URL", "https://canvas.illinois.edu/api/v1") API_TOKEN = os.environ.get("CANVAS_API_TOKEN", "") # Global cache for course codes to IDs course_code_to_id_cache = {} id_to_course_code_cache = {} # Custom type definitions class CourseInfo(TypedDict, total=False): id: Union[int, str] name: str course_code: str start_at: str end_at: str time_zone: str default_view: str is_public: bool blueprint: bool class AssignmentInfo(TypedDict, total=False): id: Union[int, str] name: str due_at: Optional[str] points_possible: float submission_types: List[str] published: bool locked_for_user: bool # Initialize HTTP client with auth http_client = httpx.AsyncClient( headers={ 'Authorization': f'Bearer {API_TOKEN}' }, timeout=30.0 ) # Helper functions def format_date(date_str: Optional[str]) -> str: """Format a date string for display or return 'N/A' if None.""" if not date_str: return "N/A" try: # You could add more formatting here if needed return date_str except: return date_str def truncate_text(text: str, max_length: int = 100) -> str: """Truncate text to a maximum length and add ellipsis if needed.""" if not text or len(text) <= max_length: return text return text[:max_length - 3] + "..." # Helper function for API requests async def make_canvas_request( method: str, endpoint: str, params: Optional[Dict[str, Any]] = None, data: Optional[Dict[str, Any]] = None ) -> Dict[str, Any]: """Make a request to the Canvas API with proper error handling.""" try: # Ensure the endpoint starts with a slash if not endpoint.startswith('/'): endpoint = f"/{endpoint}" # Construct the full URL url = f"{API_BASE_URL.rstrip('/')}{endpoint}" # Log the request for debugging print(f"Making {method.upper()} request to {url}", file=sys.stderr) if method.lower() == "get": response = await http_client.get(url, params=params) elif method.lower() == "post": response = await http_client.post(url, json=data) elif method.lower() == "put": response = await http_client.put(url, json=data) elif method.lower() == "delete": response = await http_client.delete(url, params=params) else: return {"error": f"Unsupported method: {method}"} response.raise_for_status() return response.json() except httpx.HTTPStatusError as e: error_message = f"HTTP error: {e.response.status_code}" try: error_details = e.response.json() error_message += f", Details: {error_details}" except: error_details = e.response.text error_message += f", Text: {error_details}" print(f"API error: {error_message}", file=sys.stderr) return {"error": error_message} except Exception as e: print(f"Request failed: {str(e)}", file=sys.stderr) return {"error": f"Request failed: {str(e)}"} async def fetch_all_paginated_results(endpoint: str, params: Optional[Dict[str, Any]] = None) -> List[Dict[str, Any]]: """Fetch all results from a paginated Canvas API endpoint.""" if params is None: params = {} # Ensure we get a reasonable number per page if "per_page" not in params: params["per_page"] = 100 all_results = [] page = 1 while True: current_params = {**params, "page": page} response = await make_canvas_request("get", endpoint, params=current_params) if isinstance(response, dict) and "error" in response: print(f"Error fetching page {page}: {response['error']}", file=sys.stderr) return response if not response or not isinstance(response, list) or len(response) == 0: break all_results.extend(response) # If we got fewer results than requested per page, we're done if len(response) < params.get("per_page", 100): break page += 1 return all_results async def refresh_course_cache() -> bool: """Refresh the global course cache.""" global course_code_to_id_cache, id_to_course_code_cache print("Refreshing course cache...", file=sys.stderr) courses = await fetch_all_paginated_results("/courses", {"per_page": 100}) if isinstance(courses, dict) and "error" in courses: print(f"Error building course cache: {courses.get('error')}", file=sys.stderr) return False # Build caches for bidirectional lookups course_code_to_id_cache = {} id_to_course_code_cache = {} for course in courses: course_id = str(course.get("id")) course_code = course.get("course_code") if course_code and course_id: course_code_to_id_cache[course_code] = course_id id_to_course_code_cache[course_id] = course_code print(f"Cached {len(course_code_to_id_cache)} course codes", file=sys.stderr) return True async def get_course_id(course_identifier: str) -> Optional[str]: """Get course ID from either course code or ID, with caching.""" global course_code_to_id_cache, id_to_course_code_cache # If it looks like a numeric ID if str(course_identifier).isdigit(): return str(course_identifier) # If it's a SIS ID format if course_identifier.startswith("sis_course_id:"): return course_identifier # If it's in our cache, return the ID if course_identifier in course_code_to_id_cache: return course_code_to_id_cache[course_identifier] # If it looks like a course code (contains underscores) if "_" in course_identifier: # Try to refresh cache if it's not there if not course_code_to_id_cache: await refresh_course_cache() if course_identifier in course_code_to_id_cache: return course_code_to_id_cache[course_identifier] # Return SIS format as a fallback return f"sis_course_id:{course_identifier}" # Last resort, return as is return course_identifier async def get_course_code(course_id: str) -> Optional[str]: """Get course code from ID, with caching.""" global id_to_course_code_cache # If it's already a code-like string with underscores if "_" in course_id: return course_id # If it's in our cache, return the code if course_id in id_to_course_code_cache: return id_to_course_code_cache[course_id] # Try to refresh cache if it's not there if not id_to_course_code_cache: await refresh_course_cache() if course_id in id_to_course_code_cache: return id_to_course_code_cache[course_id] # If we can't find a code, try to fetch the course directly response = await make_canvas_request("get", f"/courses/{course_id}") if "error" not in response and "course_code" in response: code = response.get("course_code") # Update our cache id_to_course_code_cache[course_id] = code course_code_to_id_cache[code] = course_id return code # Last resort, return the ID return course_id # ===== COURSES TOOLS ===== @mcp.tool() async def list_courses(include_concluded: bool = False, include_all: bool = False) -> str: """List courses for the authenticated user.""" params = { "include[]": ["term", "teachers", "total_students"], "per_page": 100 } if not include_all: params["enrollment_type"] = "teacher" if include_concluded: params["state[]"] = ["available", "completed"] else: params["state[]"] = ["available"] courses = await fetch_all_paginated_results("/courses", params) if isinstance(courses, dict) and "error" in courses: return f"Error fetching courses: {courses['error']}" if not courses: return "No courses found." # Refresh our caches with the course data for course in courses: course_id = str(course.get("id")) course_code = course.get("course_code") if course_code and course_id: course_code_to_id_cache[course_code] = course_id id_to_course_code_cache[course_id] = course_code courses_info = [] for course in courses: course_id = course.get("id") name = course.get("name", "Unnamed course") code = course.get("course_code", "No code") # Emphasize code in the output courses_info.append(f"Code: {code}\nName: {name}\nID: {course_id}\n") return "Courses:\n\n" + "\n".join(courses_info) @mcp.tool() async def get_course_details(course_identifier: str) -> str: """Get detailed information about a specific course. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID """ course_id = await get_course_id(course_identifier) response = await make_canvas_request("get", f"/courses/{course_id}") if "error" in response: return f"Error fetching course details: {response['error']}" # Update our caches with the course data if "id" in response and "course_code" in response: course_code_to_id_cache[response["course_code"]] = str(response["id"]) id_to_course_code_cache[str(response["id"])] = response["course_code"] details = [ f"Code: {response.get('course_code', 'N/A')}", f"Name: {response.get('name', 'N/A')}", f"Start Date: {format_date(response.get('start_at'))}", f"End Date: {format_date(response.get('end_at'))}", f"Time Zone: {response.get('time_zone', 'N/A')}", f"Default View: {response.get('default_view', 'N/A')}", f"Public: {response.get('is_public', False)}", f"Blueprint: {response.get('blueprint', False)}" ] # Prefer to show course code in the output course_display = response.get("course_code", course_identifier) return f"Course Details for {course_display}:\n\n" + "\n".join(details) # ===== ASSIGNMENTS TOOLS ===== @mcp.tool() async def list_assignments(course_identifier: str) -> str: """List assignments for a specific course. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID """ course_id = await get_course_id(course_identifier) params = { "per_page": 100, "include[]": ["all_dates", "submission"] } all_assignments = await fetch_all_paginated_results(f"/courses/{course_id}/assignments", params) if isinstance(all_assignments, dict) and "error" in all_assignments: return f"Error fetching assignments: {all_assignments['error']}" if not all_assignments: return f"No assignments found for course {course_identifier}." assignments_info = [] for assignment in all_assignments: assignment_id = assignment.get("id") name = assignment.get("name", "Unnamed assignment") due_at = assignment.get("due_at", "No due date") points = assignment.get("points_possible", 0) assignments_info.append( f"ID: {assignment_id}\nName: {name}\nDue: {due_at}\nPoints: {points}\n" ) # Try to get the course code for display course_display = await get_course_code(course_id) or course_identifier return f"Assignments for Course {course_display}:\n\n" + "\n".join(assignments_info) @mcp.tool() async def get_assignment_details(course_identifier: str, assignment_id: str) -> str: """Get detailed information about a specific assignment. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID assignment_id: The Canvas assignment ID """ course_id = await get_course_id(course_identifier) response = await make_canvas_request( "get", f"/courses/{course_id}/assignments/{assignment_id}" ) if "error" in response: return f"Error fetching assignment details: {response['error']}" details = [ f"Name: {response.get('name', 'N/A')}", f"Description: {truncate_text(response.get('description', 'N/A'), 300)}", f"Due Date: {format_date(response.get('due_at'))}", f"Points Possible: {response.get('points_possible', 'N/A')}", f"Submission Types: {', '.join(response.get('submission_types', ['N/A']))}", f"Published: {response.get('published', False)}", f"Locked: {response.get('locked_for_user', False)}" ] # Try to get the course code for display course_display = await get_course_code(course_id) or course_identifier return f"Assignment Details for ID {assignment_id} in course {course_display}:\n\n" + "\n".join(details) # ===== SUBMISSIONS TOOLS ===== @mcp.tool() async def list_submissions(course_identifier: str, assignment_id: str) -> str: """List submissions for a specific assignment. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID assignment_id: The Canvas assignment ID """ course_id = await get_course_id(course_identifier) params = { "per_page": 100 } submissions = await fetch_all_paginated_results( f"/courses/{course_id}/assignments/{assignment_id}/submissions", params ) if isinstance(submissions, dict) and "error" in submissions: return f"Error fetching submissions: {submissions['error']}" if not submissions: return f"No submissions found for assignment {assignment_id}." submissions_info = [] for submission in submissions: user_id = submission.get("user_id") submitted_at = submission.get("submitted_at", "Not submitted") score = submission.get("score", "Not graded") grade = submission.get("grade", "Not graded") submissions_info.append( f"User ID: {user_id}\nSubmitted: {submitted_at}\nScore: {score}\nGrade: {grade}\n" ) # Try to get the course code for display course_display = await get_course_code(course_id) or course_identifier return f"Submissions for Assignment {assignment_id} in course {course_display}:\n\n" + "\n".join(submissions_info) # ===== USERS TOOLS ===== @mcp.tool() async def list_users(course_identifier: str) -> str: """List users enrolled in a specific course. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID """ course_id = await get_course_id(course_identifier) params = { "per_page": 100 } users = await fetch_all_paginated_results(f"/courses/{course_id}/users", params) if isinstance(users, dict) and "error" in users: return f"Error fetching users: {users['error']}" if not users: return f"No users found for course {course_identifier}." users_info = [] for user in users: user_id = user.get("id") name = user.get("name", "Unnamed user") email = user.get("email", "No email") users_info.append(f"ID: {user_id}\nName: {name}\nEmail: {email}\n") # Try to get the course code for display course_display = await get_course_code(course_id) or course_identifier return f"Users for Course {course_display}:\n\n" + "\n".join(users_info) # ===== ANNOUNCEMENTS TOOLS ===== @mcp.tool() async def list_announcements(course_identifier: str) -> str: """List announcements for a specific course. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID """ course_id = await get_course_id(course_identifier) params = { "only_announcements": "true", "per_page": 100 } announcements = await fetch_all_paginated_results( f"/courses/{course_id}/discussion_topics", params ) if isinstance(announcements, dict) and "error" in announcements: return f"Error fetching announcements: {announcements['error']}" if not announcements: return f"No announcements found for course {course_identifier}." announcements_info = [] for announcement in announcements: title = announcement.get("title", "Untitled") posted_at = announcement.get("posted_at", "Unknown date") author = announcement.get("author", {}).get("display_name", "Unknown") announcements_info.append( f"Title: {title}\nPosted: {posted_at}\nAuthor: {author}\n" ) # Try to get the course code for display course_display = await get_course_code(course_id) or course_identifier return f"Announcements for Course {course_display}:\n\n" + "\n".join(announcements_info) # ===== ANALYTICS TOOLS ===== @mcp.tool() async def get_student_analytics(course_identifier: str, current_only: bool = True, include_participation: bool = True, include_assignment_stats: bool = True, include_access_stats: bool = True) -> str: """Get detailed analytics about student activity, participation, and progress in a course. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID current_only: Whether to include only assignments due on or before today include_participation: Whether to include participation data (discussions, submissions) include_assignment_stats: Whether to include assignment completion statistics include_access_stats: Whether to include course access statistics """ import datetime course_id = await get_course_id(course_identifier) # Get current date for filtering assignments today = datetime.datetime.now().isoformat() # Get all students in the course params = { "enrollment_type[]": "student", "per_page": 100 } students = await fetch_all_paginated_results( f"/courses/{course_id}/users", params ) if isinstance(students, dict) and "error" in students: return f"Error fetching students: {students['error']}" if not students: return f"No students found for course {course_identifier}." # Get all assignments in the course assignments = await fetch_all_paginated_results( f"/courses/{course_id}/assignments", {"per_page": 100} ) if isinstance(assignments, dict) and "error" in assignments: return f"Error fetching assignments: {assignments['error']}" # Filter assignments to only include those due on or before today if current_only is True current_assignments = [] future_assignments = [] for assignment in assignments: due_at = assignment.get("due_at") # Skip assignments with no due date if filtering by current if current_only and not due_at: continue if due_at and due_at <= today: current_assignments.append(assignment) else: future_assignments.append(assignment) if current_only: filtered_assignments = current_assignments else: filtered_assignments = assignments # For each student, gather analytics student_analytics = [] for student in students: student_id = student.get("id") student_name = student.get("name", "Unknown") analytics = { "id": student_id, "name": student_name, "participation": {}, "assignments": {}, "access": {} } # Get student analytics data if include_participation or include_access_stats: participation_data = await make_canvas_request( "get", f"/courses/{course_id}/analytics/users/{student_id}/activity" ) if not isinstance(participation_data, dict) or "error" not in participation_data: if include_participation: analytics["participation"] = { "page_views": participation_data.get("page_views", 0), "participations": participation_data.get("participations", 0), "total_activity_time": participation_data.get("total_activity_time", 0) } if include_access_stats: # Extract view data by date if available view_data = participation_data.get("page_views_by_day", {}) analytics["access"] = { "last_access": "Unknown", "access_count": sum(view_data.values()) if view_data else 0, "access_pattern": "Regular" if len(view_data) > 5 else "Irregular" } # Determine last access date if view_data: dates = sorted(view_data.keys(), reverse=True) if dates: analytics["access"]["last_access"] = dates[0] # Get assignment statistics if include_assignment_stats: assignment_data = await make_canvas_request( "get", f"/courses/{course_id}/analytics/users/{student_id}/assignments" ) if not isinstance(assignment_data, dict) or "error" not in assignment_data: # Filter assignment data to match our filtered assignments if current_only: filtered_assignment_data = [] for assign in assignment_data: assignment_id = assign.get("assignment_id") # Check if this assignment is in our filtered list if any(a.get("id") == assignment_id for a in current_assignments): filtered_assignment_data.append(assign) else: filtered_assignment_data = assignment_data # Process assignment data submitted_count = 0 late_count = 0 missing_count = 0 graded_count = 0 score_sum = 0 recent_missing = 0 # Missing in last 7 days # Track the status of each assignment assignment_status = [] for assign in filtered_assignment_data: assign_id = assign.get("assignment_id") assign_name = "Unknown" due_date = None points_possible = 0 # Find the assignment details for a in filtered_assignments: if a.get("id") == assign_id: assign_name = a.get("name", "Unknown") due_date = a.get("due_at") points_possible = a.get("points_possible", 0) break submission = assign.get("submission", {}) # IMPORTANT: In Canvas, an assignment can be graded without being "submitted" # We'll consider an assignment as effectively submitted if it has a score has_score = submission.get("score") is not None is_submitted = submission.get("submitted", False) or has_score status = { "id": assign_id, "name": assign_name, "due_date": due_date, "points_possible": points_possible, "submitted": is_submitted, "score": submission.get("score"), "late": submission.get("late", False), "missing": submission.get("missing", False) and not has_score, "workflow_state": submission.get("workflow_state", "unsubmitted"), "graded": has_score } assignment_status.append(status) if is_submitted: submitted_count += 1 if submission.get("late"): late_count += 1 # Only count as missing if it doesn't have a score if submission.get("missing") and not has_score: missing_count += 1 # Check if the assignment was due in the last 7 days if due_date: due_date_obj = datetime.datetime.fromisoformat(due_date.replace('Z', '+00:00')) now = datetime.datetime.now(datetime.timezone.utc) if (now - due_date_obj).days <= 7: recent_missing += 1 if has_score: graded_count += 1 score_sum += submission.get("score", 0) # Calculate statistics total_assignments = len(filtered_assignment_data) if filtered_assignment_data else 0 # Use graded count for completion if it's higher than submitted count # This handles the case where assignments are graded but not marked as "submitted" effective_submitted = max(submitted_count, graded_count) completion_rate = (effective_submitted / total_assignments * 100) if total_assignments > 0 else 0 average_score = (score_sum / graded_count) if graded_count > 0 else 0 analytics["assignments"] = { "total": total_assignments, "submitted": submitted_count, "graded": graded_count, "effective_submitted": effective_submitted, "completion_rate": round(completion_rate, 1), "late": late_count, "missing": missing_count, "recent_missing": recent_missing, "average_score": round(average_score, 1), "assignment_status": assignment_status } student_analytics.append(analytics) # Format the response course_display = await get_course_code(course_id) or course_identifier if current_only: output = f"Current Assignment Analytics for Course {course_display} (Due on or before today):\n\n" else: output = f"Student Analytics for Course {course_display} (All assignments):\n\n" # Add assignment statistics if current_only and filtered_assignments: output += f"Current Assignments: {len(filtered_assignments)}\n" output += f"Future Assignments: {len(future_assignments)}\n\n" # Per-student analytics for student in student_analytics: output += f"Student: {student['name']}\n" if include_participation and student.get("participation"): p = student["participation"] output += f"Participation: {p.get('participations', 0)} activities, {p.get('page_views', 0)} page views\n" if include_assignment_stats and student.get("assignments"): a = student["assignments"] if current_only: output += f"Current Assignments: {a.get('effective_submitted', 0)}/{a.get('total', 0)} completed ({a.get('completion_rate', 0)}%)\n" output += f" Formally Submitted: {a.get('submitted', 0)}, Graded: {a.get('graded', 0)}\n" output += f" Late: {a.get('late', 0)}, Missing: {a.get('missing', 0)} (Recent: {a.get('recent_missing', 0)})\n" output += f" Avg Score: {a.get('average_score', 0)}\n" # Add missing assignments detail if there are any if a.get("missing", 0) > 0 and a.get("assignment_status"): missing_assignments = [ass for ass in a.get("assignment_status", []) if ass.get("missing")] if missing_assignments: output += " Missing Assignments:\n" for ma in missing_assignments[:3]: # Limit to first 3 to keep output reasonable due_date = ma.get("due_date", "Unknown") if due_date and due_date != "Unknown": due_date = due_date.split('T')[0] # Just show the date part output += f" - {ma.get('name')} (Due: {due_date})\n" if len(missing_assignments) > 3: output += f" - ... and {len(missing_assignments) - 3} more\n" else: output += f"Assignments: {a.get('submitted', 0)}/{a.get('total', 0)} submitted ({a.get('completion_rate', 0)}%)\n" output += f" Late: {a.get('late', 0)}, Missing: {a.get('missing', 0)}, Avg Score: {a.get('average_score', 0)}\n" if include_access_stats and student.get("access"): a = student["access"] output += f"Access: Last seen {a.get('last_access', 'Unknown')}, Pattern: {a.get('access_pattern', 'Unknown')}\n" output += "\n" # Add summary statistics if include_assignment_stats and student_analytics: avg_completion = sum(s["assignments"].get("completion_rate", 0) for s in student_analytics) / len(student_analytics) if student_analytics else 0 avg_graded = sum(s["assignments"].get("graded", 0) for s in student_analytics) / len(student_analytics) if student_analytics else 0 avg_score = sum(s["assignments"].get("average_score", 0) for s in student_analytics) / len(student_analytics) if student_analytics else 0 output += f"\nClass Summary:\n" if current_only: total_students = len(student_analytics) total_current_assignments = len(filtered_assignments) if filtered_assignments else 0 # Calculate global stats graded_count = sum(s["assignments"].get("graded", 0) for s in student_analytics) submitted_count = sum(s["assignments"].get("submitted", 0) for s in student_analytics) effective_count = sum(s["assignments"].get("effective_submitted", 0) for s in student_analytics) missing_count = sum(s["assignments"].get("missing", 0) for s in student_analytics) # Calculate maximum possible number of all student/assignment combinations total_possible = total_students * total_current_assignments if total_current_assignments > 0 else 0 output += f"Average Current Assignment Completion Rate: {round(avg_completion, 1)}%\n" output += f"Average Graded Assignments Per Student: {round(avg_graded, 1)}\n" output += f"Class Average Score: {round(avg_score, 1)}\n" if total_possible > 0: output += f"\nGlobal Stats:\n" output += f" Total Students: {total_students}\n" output += f" Current Assignments: {total_current_assignments}\n" output += f" Formally Submitted: {submitted_count}/{total_possible} ({round(submitted_count/total_possible*100, 1)}%)\n" output += f" Graded: {graded_count}/{total_possible} ({round(graded_count/total_possible*100, 1)}%)\n" output += f" Effective Completion: {effective_count}/{total_possible} ({round(effective_count/total_possible*100, 1)}%)\n" output += f" Missing: {missing_count}/{total_possible} ({round(missing_count/total_possible*100, 1)}%)\n" else: output += f"Average Assignment Completion Rate: {round(avg_completion, 1)}%\n" output += f"Average Graded Assignments Per Student: {round(avg_graded, 1)}\n" output += f"Class Average Score: {round(avg_score, 1)}\n" # Find assignments with lowest completion rates if filtered_assignments: assignment_completion = {} assignment_due_dates = {} for assignment in filtered_assignments: assignment_id = assignment.get("id") assignment_name = assignment.get("name", "Unknown") assignment_due_date = assignment.get("due_at", "Unknown") if assignment_due_date and assignment_due_date != "Unknown": assignment_due_date = assignment_due_date.split('T')[0] # Just show the date part # Count submissions for this assignment submission_count = 0 total_students = len(student_analytics) for student in student_analytics: # Look for this specific assignment in the student's assignment status if "assignments" in student and "assignment_status" in student["assignments"]: for status in student["assignments"]["assignment_status"]: if status.get("id") == assignment_id and status.get("submitted"): submission_count += 1 break completion_rate = (submission_count / total_students * 100) if total_students > 0 else 0 assignment_completion[assignment_name] = completion_rate assignment_due_dates[assignment_name] = assignment_due_date # Get assignments with lowest completion rates lowest_completion = sorted(assignment_completion.items(), key=lambda x: x[1])[:3] if lowest_completion: output += "\nAssignments with Lowest Completion Rates:\n" for name, rate in lowest_completion: due_date = assignment_due_dates.get(name, "Unknown") output += f" {name} (Due: {due_date}): {round(rate, 1)}% completion\n" return output @mcp.tool() async def get_assignment_analytics(course_identifier: str, assignment_id: str) -> str: """Get detailed analytics about student performance on a specific assignment. Args: course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID assignment_id: The Canvas assignment ID """ import datetime from statistics import mean, median, stdev course_id = await get_course_id(course_identifier) # Get assignment details assignment = await make_canvas_request( "get", f"/courses/{course_id}/assignments/{assignment_id}" ) if isinstance(assignment, dict) and "error" in assignment: return f"Error fetching assignment: {assignment['error']}" # Get all students in the course params = { "enrollment_type[]": "student", "per_page": 100 } students = await fetch_all_paginated_results( f"/courses/{course_id}/users", params ) if isinstance(students, dict) and "error" in students: return f"Error fetching students: {students['error']}" if not students: return f"No students found for course {course_identifier}." # Get submissions for this assignment submissions = await fetch_all_paginated_results( f"/courses/{course_id}/assignments/{assignment_id}/submissions", {"per_page": 100, "include[]": ["user"]} ) if isinstance(submissions, dict) and "error" in submissions: return f"Error fetching submissions: {submissions['error']}" # Extract assignment details assignment_name = assignment.get("name", "Unknown Assignment") assignment_description = assignment.get("description", "No description") due_date = assignment.get("due_at") points_possible = assignment.get("points_possible", 0) is_published = assignment.get("published", False) # Format the due date due_date_str = "No due date" if due_date: try: due_date_obj = datetime.datetime.fromisoformat(due_date.replace('Z', '+00:00')) due_date_str = due_date_obj.strftime("%Y-%m-%d %H:%M") now = datetime.datetime.now(datetime.timezone.utc) is_past_due = due_date_obj < now except (ValueError, AttributeError): due_date_str = due_date is_past_due = False else: is_past_due = False # Process submissions submission_stats = { "total_students": len(students), "submitted_count": 0, "missing_count": 0, "late_count": 0, "graded_count": 0, "excused_count": 0, "scores": [], "status_counts": { "submitted": 0, "unsubmitted": 0, "graded": 0, "pending_review": 0 } } # Student status tracking student_status = [] missing_students = [] low_scoring_students = [] high_scoring_students = [] # Track which students have submissions student_ids_with_submissions = set() for submission in submissions: student_id = submission.get("user_id") student_ids_with_submissions.add(student_id) # Find student name student_name = "Unknown" for student in students: if student.get("id") == student_id: student_name = student.get("name", "Unknown") break # Process submission data score = submission.get("score") is_submitted = submission.get("submitted_at") is not None is_late = submission.get("late", False) is_missing = submission.get("missing", False) is_excused = submission.get("excused", False) is_graded = score is not None status = submission.get("workflow_state", "unsubmitted") submitted_at = submission.get("submitted_at") if submitted_at: try: submitted_at = datetime.datetime.fromisoformat( submitted_at.replace('Z', '+00:00') ).strftime("%Y-%m-%d %H:%M") except (ValueError, AttributeError): pass # Update statistics if is_submitted: submission_stats["submitted_count"] += 1 if is_late: submission_stats["late_count"] += 1 if is_missing: submission_stats["missing_count"] += 1 missing_students.append(student_name) if is_excused: submission_stats["excused_count"] += 1 if is_graded: submission_stats["graded_count"] += 1 submission_stats["scores"].append(score) # Track high/low scoring students if points_possible > 0: percentage = (score / points_possible) * 100 if percentage < 70: low_scoring_students.append((student_name, score, percentage)) if percentage > 90: high_scoring_students.append((student_name, score, percentage)) # Update status counts if status in submission_stats["status_counts"]: submission_stats["status_counts"][status] += 1 # Add to student status student_status.append({ "name": student_name, "submitted": is_submitted, "submitted_at": submitted_at, "late": is_late, "missing": is_missing, "excused": is_excused, "score": score, "status": status }) # Find students with no submissions for student in students: if student.get("id") not in student_ids_with_submissions: student_name = student.get("name", "Unknown") missing_students.append(student_name) # Add to student status student_status.append({ "name": student_name, "submitted": False, "submitted_at": None, "late": False, "missing": True, "excused": False, "score": None, "status": "unsubmitted" }) # Compute grade statistics scores = submission_stats["scores"] avg_score = mean(scores) if scores else 0 median_score = median(scores) if scores else 0 try: std_dev = stdev(scores) if len(scores) > 1 else 0 except: std_dev = 0 if points_possible > 0: avg_percentage = (avg_score / points_possible) * 100 else: avg_percentage = 0 # Format the output course_display = await get_course_code(course_id) or course_identifier output = f"Assignment Analytics for '{assignment_name}' in Course {course_display}\n\n" # Assignment details output += "Assignment Details:\n" output += f" Due: {due_date_str}" if is_past_due: output += " (Past Due)" output += "\n" output += f" Points Possible: {points_possible}\n" output += f" Published: {'Yes' if is_published else 'No'}\n\n" # Submission statistics output += "Submission Statistics:\n" total_students = submission_stats["total_students"] submitted = submission_stats["submitted_count"] graded = submission_stats["graded_count"] missing = submission_stats["missing_count"] + (total_students - len(submissions)) late = submission_stats["late_count"] # Calculate percentages submitted_pct = (submitted / total_students * 100) if total_students > 0 else 0 graded_pct = (graded / total_students * 100) if total_students > 0 else 0 missing_pct = (missing / total_students * 100) if total_students > 0 else 0 late_pct = (late / submitted * 100) if submitted > 0 else 0 output += f" Submitted: {submitted}/{total_students} ({round(submitted_pct, 1)}%)\n" output += f" Graded: {graded}/{total_students} ({round(graded_pct, 1)}%)\n" output += f" Missing: {missing}/{total_students} ({round(missing_pct, 1)}%)\n" if submitted > 0: output += f" Late: {late}/{submitted} ({round(late_pct, 1)}% of submissions)\n" output += f" Excused: {submission_stats['excused_count']}\n\n" # Grade statistics if scores: output += "Grade Statistics:\n" output += f" Average Score: {round(avg_score, 2)}/{points_possible} ({round(avg_percentage, 1)}%)\n" output += f" Median Score: {round(median_score, 2)}/{points_possible} ({round((median_score/points_possible)*100, 1)}%)\n" output += f" Standard Deviation: {round(std_dev, 2)}\n" # High/Low scores if low_scoring_students: output += "\nStudents Scoring Below 70%:\n" for name, score, percentage in sorted(low_scoring_students, key=lambda x: x[2]): output += f" {name}: {round(score, 1)}/{points_possible} ({round(percentage, 1)}%)\n" if high_scoring_students: output += "\nStudents Scoring Above 90%:\n" for name, score, percentage in sorted(high_scoring_students, key=lambda x: x[2], reverse=True): output += f" {name}: {round(score, 1)}/{points_possible} ({round(percentage, 1)}%)\n" # Missing students if missing_students: output += "\nStudents Missing Submission:\n" # Sort alphabetically and show first 10 for name in sorted(missing_students)[:10]: output += f" {name}\n" if len(missing_students) > 10: output += f" ...and {len(missing_students) - 10} more\n" return output # ===== RESOURCES ===== @mcp.resource( name="course-syllabus", description="Get the syllabus for a specific course", uri="canvas://course/{course_identifier}/syllabus" ) async def get_course_syllabus(course_identifier: str) -> str: """Get the syllabus for a specific course.""" course_id = await get_course_id(course_identifier) response = await make_canvas_request("get", f"/courses/{course_id}") if "error" in response: return f"Error fetching syllabus: {response['error']}" syllabus_body = response.get("syllabus_body", "") if not syllabus_body: return "No syllabus available for this course." return syllabus_body @mcp.resource( name="assignment-description", description="Get the description for a specific assignment", uri="canvas://course/{course_identifier}/assignment/{assignment_id}/description" ) async def get_assignment_description(course_identifier: str, assignment_id: str) -> str: """Get the description for a specific assignment.""" course_id = await get_course_id(course_identifier) response = await make_canvas_request( "get", f"/courses/{course_id}/assignments/{assignment_id}" ) if "error" in response: return f"Error fetching assignment description: {response['error']}" description = response.get("description", "") if not description: return "No description available for this assignment." return description @mcp.resource( name="course-modules", description="Get the modules for a specific course", uri="canvas://course/{course_identifier}/modules" ) async def get_course_modules(course_identifier: str) -> str: """Get the modules for a specific course.""" course_id = await get_course_id(course_identifier) params = { "per_page": 100 } modules = await fetch_all_paginated_results(f"/courses/{course_id}/modules", params) if isinstance(modules, dict) and "error" in modules: return f"Error fetching modules: {modules['error']}" if not modules: return "No modules available for this course." modules_info = [] for module in modules: module_id = module.get("id") name = module.get("name", "Unnamed module") status = module.get("state", "Unknown") modules_info.append(f"ID: {module_id}\nName: {name}\nStatus: {status}\n") return "Course Modules:\n\n" + "\n".join(modules_info) # ===== PROMPTS ===== @mcp.prompt( name="summarize-course", description="Generate a summary of a Canvas course" ) async def summarize_course(course_identifier: str) -> List[Dict[str, Any]]: """Generate a summary of a Canvas course.""" course_id = await get_course_id(course_identifier) # Get course details course_response = await make_canvas_request("get", f"/courses/{course_id}") if "error" in course_response: return [{"role": "user", "content": f"Error fetching course: {course_response['error']}"}] # Get assignments assignments_response = await fetch_all_paginated_results(f"/courses/{course_id}/assignments") if isinstance(assignments_response, dict) and "error" in assignments_response: assignments_info = "Error fetching assignments" else: assignments_count = len(assignments_response) from datetime import datetime current_date = datetime.now().isoformat() upcoming_assignments = [ a for a in assignments_response if a.get("due_at") and a.get("due_at") > current_date ] upcoming_count = len(upcoming_assignments) assignments_info = f"{assignments_count} total assignments, {upcoming_count} upcoming" # Get modules modules_response = await fetch_all_paginated_results(f"/courses/{course_id}/modules") if isinstance(modules_response, dict) and "error" in modules_response: modules_info = "Error fetching modules" else: modules_count = len(modules_response) modules_info = f"{modules_count} modules" # Create prompt course_name = course_response.get("name", "Unknown course") course_code = course_response.get("course_code", "No code") # Update our caches with the course data if "id" in course_response and "course_code" in course_response: course_code_to_id_cache[course_code] = str(course_response["id"]) id_to_course_code_cache[str(course_response["id"])] = course_code return [ {"role": "system", "content": "You are a helpful assistant that summarizes Canvas course information."}, {"role": "user", "content": f""" Please provide a summary of the Canvas course: Course: {course_name} ({course_code}) Code: {course_code} Assignments: {assignments_info} Modules: {modules_info} Summarize the key information about this course and suggest what the user might want to know about it. """} ] # ===== MAIN EXECUTION ===== if __name__ == "__main__": import sys # Check for API token if not API_TOKEN: print("Error: CANVAS_API_TOKEN environment variable is required", file=sys.stderr) print("Please set it to your Canvas API token", file=sys.stderr) sys.exit(1) print(f"Starting Canvas MCP server with API URL: {API_BASE_URL}", file=sys.stderr) print("Use Ctrl+C to stop the server", file=sys.stderr) try: # Run the server directly mcp.run() except KeyboardInterrupt: print("\nShutting down server...", file=sys.stderr) finally: # We'll rely on Python's cleanup to close the client pass