Canvas MCP Server
#!/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)
# ===== 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