"""Module-related MCP tools for Canvas API.
Provides tools for creating, updating, and managing Canvas course modules
and module items. Modules are the primary content organization system in Canvas.
"""
from typing import Optional, Union
from mcp.server.fastmcp import FastMCP
from ..core.cache import get_course_code, get_course_id
from ..core.client import fetch_all_paginated_results, make_canvas_request
from ..core.dates import format_date, parse_date
from ..core.validation import validate_params
def register_module_tools(mcp: FastMCP):
"""Register all module-related MCP tools."""
@mcp.tool()
@validate_params
async def list_modules(
course_identifier: Union[str, int],
include_items: bool = False,
search_term: Optional[str] = None
) -> str:
"""List all modules in a course.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
include_items: Whether to include a summary of items in each module
search_term: Optional search term to filter modules by name
"""
course_id = await get_course_id(course_identifier)
params = {"per_page": 100}
if include_items:
params["include[]"] = ["items"]
if search_term:
params["search_term"] = search_term
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 f"No modules found in course."
course_display = await get_course_code(course_id) or course_identifier
result = f"Modules in {course_display}:\n\n"
for module in modules:
module_id = module.get("id")
name = module.get("name", "Unnamed")
position = module.get("position", 0)
state = module.get("state", "unknown")
published = module.get("published", False)
items_count = module.get("items_count", 0)
unlock_at = module.get("unlock_at")
require_sequential = module.get("require_sequential_progress", False)
prerequisite_ids = module.get("prerequisite_module_ids", [])
result += f"**{name}**\n"
result += f" ID: {module_id}\n"
result += f" Position: {position}\n"
result += f" Status: {state} | Published: {'Yes' if published else 'No'}\n"
result += f" Items: {items_count}\n"
if unlock_at:
result += f" Unlocks: {format_date(unlock_at)}\n"
if require_sequential:
result += f" Sequential Progress: Required\n"
if prerequisite_ids:
result += f" Prerequisites: {prerequisite_ids}\n"
# Include item summary if requested
if include_items and "items" in module:
items = module.get("items", [])
if items:
result += " Items:\n"
for item in items[:5]: # Show first 5 items
item_title = item.get("title", "Untitled")
item_type = item.get("type", "Unknown")
result += f" - {item_title} ({item_type})\n"
if len(items) > 5:
result += f" ... and {len(items) - 5} more items\n"
result += "\n"
return result
@mcp.tool()
@validate_params
async def create_module(
course_identifier: Union[str, int],
name: str,
position: Optional[int] = None,
unlock_at: Optional[str] = None,
require_sequential_progress: bool = False,
prerequisite_module_ids: Optional[str] = None,
published: bool = True
) -> str:
"""Create a new module in a course.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
name: The name of the module (required)
position: Position in the module list (1-indexed, lower = earlier)
unlock_at: Date/time when the module unlocks (ISO 8601 format)
require_sequential_progress: If true, students must complete items in order
prerequisite_module_ids: Comma-separated list of module IDs that must be completed first
published: Whether the module is published (default: True)
"""
course_id = await get_course_id(course_identifier)
# Build module parameters
module_params = {
"module[name]": name,
"module[published]": str(published).lower()
}
if position is not None:
module_params["module[position]"] = position
if unlock_at:
parsed_date = parse_date(unlock_at)
if parsed_date:
module_params["module[unlock_at]"] = parsed_date.isoformat()
if require_sequential_progress:
module_params["module[require_sequential_progress]"] = "true"
# Handle prerequisite module IDs - need list of tuples for httpx form data
prereq_tuples = []
if prerequisite_module_ids:
# Parse comma-separated IDs
prereq_ids = [id.strip() for id in prerequisite_module_ids.split(",")]
prereq_tuples = [("module[prerequisite_module_ids][]", prereq_id) for prereq_id in prereq_ids]
# Convert module_params dict to list of tuples and append prereq tuples
form_data = list(module_params.items()) + prereq_tuples
response = await make_canvas_request(
"post",
f"/courses/{course_id}/modules",
data=form_data,
use_form_data=True
)
if "error" in response:
return f"Error creating module: {response['error']}"
# Format success response
module_id = response.get("id")
module_name = response.get("name")
module_position = response.get("position")
module_published = response.get("published", False)
course_display = await get_course_code(course_id) or course_identifier
result = f"✅ Module created successfully!\n\n"
result += f"**{module_name}**\n"
result += f" Course: {course_display}\n"
result += f" Module ID: {module_id}\n"
result += f" Position: {module_position}\n"
result += f" Published: {'Yes' if module_published else 'No'}\n"
if unlock_at:
result += f" Unlocks: {format_date(response.get('unlock_at'))}\n"
if require_sequential_progress:
result += f" Sequential Progress: Required\n"
return result
@mcp.tool()
@validate_params
async def update_module(
course_identifier: Union[str, int],
module_id: Union[str, int],
name: Optional[str] = None,
position: Optional[int] = None,
unlock_at: Optional[str] = None,
require_sequential_progress: Optional[bool] = None,
prerequisite_module_ids: Optional[str] = None,
published: Optional[bool] = None
) -> str:
"""Update an existing module's settings.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
module_id: The ID of the module to update
name: New name for the module
position: New position in the module list
unlock_at: New unlock date/time (ISO 8601 format), or empty string to remove
require_sequential_progress: Whether students must complete items in order
prerequisite_module_ids: Comma-separated list of prerequisite module IDs, or empty to clear
published: Whether the module is published
"""
course_id = await get_course_id(course_identifier)
# Build update parameters (only include changed fields)
module_params = {}
if name is not None:
module_params["module[name]"] = name
if position is not None:
module_params["module[position]"] = position
if unlock_at is not None:
if unlock_at == "":
module_params["module[unlock_at]"] = ""
else:
parsed_date = parse_date(unlock_at)
if parsed_date:
module_params["module[unlock_at]"] = parsed_date.isoformat()
if require_sequential_progress is not None:
module_params["module[require_sequential_progress]"] = str(require_sequential_progress).lower()
# Handle prerequisite module IDs - need list of tuples for httpx form data
prereq_tuples = []
if prerequisite_module_ids is not None:
if prerequisite_module_ids == "":
module_params["module[prerequisite_module_ids][]"] = ""
else:
prereq_ids = [id.strip() for id in prerequisite_module_ids.split(",")]
prereq_tuples = [("module[prerequisite_module_ids][]", prereq_id) for prereq_id in prereq_ids]
if published is not None:
module_params["module[published]"] = str(published).lower()
if not module_params and not prereq_tuples:
return "No changes specified. Please provide at least one field to update."
# Convert module_params dict to list of tuples and append prereq tuples
form_data = list(module_params.items()) + prereq_tuples
response = await make_canvas_request(
"put",
f"/courses/{course_id}/modules/{module_id}",
data=form_data,
use_form_data=True
)
if "error" in response:
return f"Error updating module: {response['error']}"
# Format success response
module_name = response.get("name")
module_position = response.get("position")
module_published = response.get("published", False)
course_display = await get_course_code(course_id) or course_identifier
result = f"✅ Module updated successfully!\n\n"
result += f"**{module_name}**\n"
result += f" Course: {course_display}\n"
result += f" Module ID: {module_id}\n"
result += f" Position: {module_position}\n"
result += f" Published: {'Yes' if module_published else 'No'}\n"
if response.get("unlock_at"):
result += f" Unlocks: {format_date(response.get('unlock_at'))}\n"
if response.get("require_sequential_progress"):
result += f" Sequential Progress: Required\n"
return result
@mcp.tool()
@validate_params
async def delete_module(
course_identifier: Union[str, int],
module_id: Union[str, int]
) -> str:
"""Delete a module from a course.
Warning: This permanently removes the module and all its item associations.
The actual content (pages, assignments, etc.) is NOT deleted, only the
module organization.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
module_id: The ID of the module to delete
"""
course_id = await get_course_id(course_identifier)
# First get module info for confirmation
module_response = await make_canvas_request(
"get",
f"/courses/{course_id}/modules/{module_id}"
)
module_name = "Unknown"
items_count = 0
if "error" not in module_response:
module_name = module_response.get("name", "Unknown")
items_count = module_response.get("items_count", 0)
# Delete the module
response = await make_canvas_request(
"delete",
f"/courses/{course_id}/modules/{module_id}"
)
if isinstance(response, dict) and "error" in response:
return f"Error deleting module: {response['error']}"
course_display = await get_course_code(course_id) or course_identifier
result = f"✅ Module deleted successfully!\n\n"
result += f" Deleted: **{module_name}**\n"
result += f" Course: {course_display}\n"
result += f" Module ID: {module_id}\n"
result += f" Items affected: {items_count} (items unlinked, content preserved)\n"
return result
@mcp.tool()
@validate_params
async def add_module_item(
course_identifier: Union[str, int],
module_id: Union[str, int],
item_type: str,
content_id: Optional[Union[str, int]] = None,
title: Optional[str] = None,
position: Optional[int] = None,
indent: Optional[int] = None,
page_url: Optional[str] = None,
external_url: Optional[str] = None,
new_tab: bool = False,
completion_requirement_type: Optional[str] = None,
completion_requirement_min_score: Optional[int] = None
) -> str:
"""Add an item to a module.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
module_id: The ID of the module to add the item to
item_type: Type of item - one of: File, Page, Discussion, Assignment, Quiz,
SubHeader, ExternalUrl, ExternalTool
content_id: The Canvas ID of the content (required for File, Discussion,
Assignment, Quiz, ExternalTool). Not needed for Page, SubHeader, ExternalUrl.
title: Title for the item (required for SubHeader, ExternalUrl; optional for others)
position: Position within the module (1-indexed)
indent: Indentation level (0-4, for visual hierarchy)
page_url: The URL slug of the page (required for Page type, e.g., "my-page-title")
external_url: The URL for ExternalUrl type items
new_tab: Whether external links open in a new tab (default: False)
completion_requirement_type: One of: must_view, must_submit, must_contribute,
min_score, must_mark_done
completion_requirement_min_score: Minimum score required (only for min_score type)
"""
course_id = await get_course_id(course_identifier)
# Validate item type
valid_types = ["File", "Page", "Discussion", "Assignment", "Quiz",
"SubHeader", "ExternalUrl", "ExternalTool"]
if item_type not in valid_types:
return f"Invalid item_type '{item_type}'. Must be one of: {', '.join(valid_types)}"
# Build item parameters
item_params = {
"module_item[type]": item_type
}
# Handle content_id requirement
types_requiring_content_id = ["File", "Discussion", "Assignment", "Quiz", "ExternalTool"]
if item_type in types_requiring_content_id:
if content_id is None:
return f"content_id is required for {item_type} items"
item_params["module_item[content_id]"] = content_id
# Handle Page type
if item_type == "Page":
if page_url is None:
return "page_url is required for Page items (e.g., 'my-page-title')"
item_params["module_item[page_url]"] = page_url
# Handle ExternalUrl type
if item_type == "ExternalUrl":
if external_url is None:
return "external_url is required for ExternalUrl items"
if title is None:
return "title is required for ExternalUrl items"
item_params["module_item[external_url]"] = external_url
# Handle SubHeader type
if item_type == "SubHeader":
if title is None:
return "title is required for SubHeader items"
# Optional parameters
if title is not None:
item_params["module_item[title]"] = title
if position is not None:
item_params["module_item[position]"] = position
if indent is not None:
if indent < 0 or indent > 4:
return "indent must be between 0 and 4"
item_params["module_item[indent]"] = indent
if new_tab:
item_params["module_item[new_tab]"] = "true"
# Completion requirements
if completion_requirement_type:
valid_completion_types = ["must_view", "must_submit", "must_contribute",
"min_score", "must_mark_done"]
if completion_requirement_type not in valid_completion_types:
return f"Invalid completion_requirement_type. Must be one of: {', '.join(valid_completion_types)}"
item_params["module_item[completion_requirement][type]"] = completion_requirement_type
if completion_requirement_type == "min_score":
if completion_requirement_min_score is None:
return "completion_requirement_min_score is required when type is 'min_score'"
item_params["module_item[completion_requirement][min_score]"] = completion_requirement_min_score
response = await make_canvas_request(
"post",
f"/courses/{course_id}/modules/{module_id}/items",
data=item_params,
use_form_data=True
)
if "error" in response:
return f"Error adding module item: {response['error']}"
# Format success response
item_id = response.get("id")
item_title = response.get("title", title or "Untitled")
item_position = response.get("position")
item_indent = response.get("indent", 0)
course_display = await get_course_code(course_id) or course_identifier
result = f"✅ Module item added successfully!\n\n"
result += f"**{item_title}**\n"
result += f" Course: {course_display}\n"
result += f" Module ID: {module_id}\n"
result += f" Item ID: {item_id}\n"
result += f" Type: {item_type}\n"
result += f" Position: {item_position}\n"
if item_indent > 0:
result += f" Indent: {item_indent}\n"
if content_id:
result += f" Content ID: {content_id}\n"
if external_url:
result += f" URL: {external_url}\n"
result += f" Opens in new tab: {'Yes' if new_tab else 'No'}\n"
if completion_requirement_type:
result += f" Completion: {completion_requirement_type}"
if completion_requirement_min_score:
result += f" (min score: {completion_requirement_min_score})"
result += "\n"
return result
@mcp.tool()
@validate_params
async def update_module_item(
course_identifier: Union[str, int],
module_id: Union[str, int],
item_id: Union[str, int],
title: Optional[str] = None,
position: Optional[int] = None,
indent: Optional[int] = None,
external_url: Optional[str] = None,
new_tab: Optional[bool] = None,
completion_requirement_type: Optional[str] = None,
completion_requirement_min_score: Optional[int] = None,
published: Optional[bool] = None,
move_to_module_id: Optional[Union[str, int]] = None
) -> str:
"""Update an existing module item.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
module_id: The ID of the module containing the item
item_id: The ID of the item to update
title: New title for the item
position: New position within the module
indent: New indentation level (0-4)
external_url: New URL (for ExternalUrl items only)
new_tab: Whether external links open in a new tab
completion_requirement_type: New completion requirement type, or empty string to remove
completion_requirement_min_score: New minimum score (for min_score type)
published: Whether the item is published
move_to_module_id: Move this item to a different module
"""
course_id = await get_course_id(course_identifier)
# Build update parameters
item_params = {}
if title is not None:
item_params["module_item[title]"] = title
if position is not None:
item_params["module_item[position]"] = position
if indent is not None:
if indent < 0 or indent > 4:
return "indent must be between 0 and 4"
item_params["module_item[indent]"] = indent
if external_url is not None:
item_params["module_item[external_url]"] = external_url
if new_tab is not None:
item_params["module_item[new_tab]"] = str(new_tab).lower()
if published is not None:
item_params["module_item[published]"] = str(published).lower()
if move_to_module_id is not None:
item_params["module_item[module_id]"] = move_to_module_id
# Handle completion requirements
if completion_requirement_type is not None:
if completion_requirement_type == "":
# Remove completion requirement
item_params["module_item[completion_requirement][type]"] = ""
else:
valid_completion_types = ["must_view", "must_submit", "must_contribute",
"min_score", "must_mark_done"]
if completion_requirement_type not in valid_completion_types:
return f"Invalid completion_requirement_type. Must be one of: {', '.join(valid_completion_types)}"
item_params["module_item[completion_requirement][type]"] = completion_requirement_type
if completion_requirement_type == "min_score":
if completion_requirement_min_score is None:
return "completion_requirement_min_score is required when type is 'min_score'"
item_params["module_item[completion_requirement][min_score]"] = completion_requirement_min_score
if not item_params:
return "No changes specified. Please provide at least one field to update."
response = await make_canvas_request(
"put",
f"/courses/{course_id}/modules/{module_id}/items/{item_id}",
data=item_params,
use_form_data=True
)
if "error" in response:
return f"Error updating module item: {response['error']}"
# Format success response
item_title = response.get("title", "Untitled")
item_type = response.get("type", "Unknown")
item_position = response.get("position")
item_published = response.get("published", False)
course_display = await get_course_code(course_id) or course_identifier
result = f"✅ Module item updated successfully!\n\n"
result += f"**{item_title}**\n"
result += f" Course: {course_display}\n"
result += f" Module ID: {response.get('module_id', module_id)}\n"
result += f" Item ID: {item_id}\n"
result += f" Type: {item_type}\n"
result += f" Position: {item_position}\n"
result += f" Published: {'Yes' if item_published else 'No'}\n"
if move_to_module_id and str(response.get("module_id")) == str(move_to_module_id):
result += f" ✓ Moved to module {move_to_module_id}\n"
return result
@mcp.tool()
@validate_params
async def delete_module_item(
course_identifier: Union[str, int],
module_id: Union[str, int],
item_id: Union[str, int]
) -> str:
"""Remove an item from a module.
Note: This only removes the item from the module. The actual content
(page, assignment, etc.) is NOT deleted.
Args:
course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
module_id: The ID of the module containing the item
item_id: The ID of the item to remove
"""
course_id = await get_course_id(course_identifier)
# First get item info for confirmation
item_response = await make_canvas_request(
"get",
f"/courses/{course_id}/modules/{module_id}/items/{item_id}"
)
item_title = "Unknown"
item_type = "Unknown"
if "error" not in item_response:
item_title = item_response.get("title", "Unknown")
item_type = item_response.get("type", "Unknown")
# Delete the item
response = await make_canvas_request(
"delete",
f"/courses/{course_id}/modules/{module_id}/items/{item_id}"
)
if isinstance(response, dict) and "error" in response:
return f"Error deleting module item: {response['error']}"
course_display = await get_course_code(course_id) or course_identifier
result = f"✅ Module item removed successfully!\n\n"
result += f" Removed: **{item_title}** ({item_type})\n"
result += f" Course: {course_display}\n"
result += f" Module ID: {module_id}\n"
result += f" Item ID: {item_id}\n"
result += f"\n Note: The underlying content was NOT deleted, only unlinked from this module.\n"
return result