Skip to main content
Glama

create_announcement

Create and schedule course announcements in Canvas LMS by specifying title, message, and optional posting times.

Instructions

Create a new announcement for a course with optional scheduling.

    Args:
        course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
        title: The title/subject of the announcement
        message: The content/body of the announcement
        delayed_post_at: Optional ISO 8601 datetime to schedule posting (e.g., "2024-01-15T12:00:00Z")
        lock_at: Optional ISO 8601 datetime to automatically lock the announcement
    

Input Schema

TableJSON Schema
NameRequiredDescriptionDefault
course_identifierYes
titleYes
messageYes
delayed_post_atNo
lock_atNo

Implementation Reference

  • The core handler function for the 'create_announcement' tool. Creates a new announcement by posting a discussion topic to the Canvas API with 'is_announcement': True. Includes parameters for title, message, scheduling (delayed_post_at, lock_at), and uses helpers like get_course_id and make_canvas_request.
    @validate_params
    async def create_announcement(course_identifier: str | int,
                                title: str,
                                message: str,
                                delayed_post_at: str | None = None,
                                lock_at: str | None = None) -> str:
        """Create a new announcement for a course with optional scheduling.
    
        Args:
            course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
            title: The title/subject of the announcement
            message: The content/body of the announcement
            delayed_post_at: Optional ISO 8601 datetime to schedule posting (e.g., "2024-01-15T12:00:00Z")
            lock_at: Optional ISO 8601 datetime to automatically lock the announcement
        """
        course_id = await get_course_id(course_identifier)
    
        data = {
            "title": title,
            "message": message,
            "is_announcement": True,
            "published": True
        }
    
        if delayed_post_at:
            data["delayed_post_at"] = delayed_post_at
    
        if lock_at:
            data["lock_at"] = lock_at
    
        response = await make_canvas_request(
            "post", f"/courses/{course_id}/discussion_topics", data=data
        )
    
        if "error" in response:
            return f"Error creating announcement: {response['error']}"
    
        announcement_id = response.get("id")
        announcement_title = response.get("title", title)
        created_at = format_date(response.get("created_at"))
    
        course_display = await get_course_code(course_id) or course_identifier
        return f"Announcement created successfully in course {course_display}:\n\n" + \
               f"ID: {announcement_id}\n" + \
               f"Title: {announcement_title}\n" + \
               f"Created: {created_at}"
  • The register_all_tools function which calls register_discussion_tools(mcp) at line 49, thereby registering the create_announcement tool along with others.
    def register_all_tools(mcp: FastMCP) -> None:
        """Register all MCP tools, resources, and prompts."""
        log_info("Registering Canvas MCP tools...")
    
        # Register tools by category
        register_course_tools(mcp)
        register_assignment_tools(mcp)
        register_discussion_tools(mcp)
        register_other_tools(mcp)
        register_rubric_tools(mcp)
        register_peer_review_tools(mcp)
        register_peer_review_comment_tools(mcp)
        register_messaging_tools(mcp)
        register_student_tools(mcp)
        register_accessibility_tools(mcp)
        register_discovery_tools(mcp)
        register_code_execution_tools(mcp)
    
        # Register resources and prompts
        register_resources_and_prompts(mcp)
    
        log_info("All Canvas MCP tools registered successfully!")
  • Defines AnnouncementInfo TypedDict type, describing the structure of announcement data with fields like id, title, message, posted_at, etc.
    class AnnouncementInfo(TypedDict, total=False):
        id: int | str
        title: str
        message: str
        posted_at: str | None
        delayed_post_at: str | None
        lock_at: str | None
        published: bool
        is_announcement: bool
  • The register_discussion_tools function that defines and registers the create_announcement tool via @mcp.tool() decorator.
    def register_discussion_tools(mcp: FastMCP):
        """Register all discussion and announcement MCP tools."""
    
        # ===== DISCUSSION TOOLS =====
    
        @mcp.tool()
        @validate_params
        async def list_discussion_topics(course_identifier: str | int,
                                       include_announcements: bool = False) -> str:
            """List discussion topics for a specific course.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                include_announcements: Whether to include announcements in the list (default: False)
            """
            course_id = await get_course_id(course_identifier)
    
            params = {"per_page": 100}
    
            if include_announcements:
                params["include[]"] = ["announcement"]
    
            topics = await fetch_all_paginated_results(f"/courses/{course_id}/discussion_topics", params)
    
            if isinstance(topics, dict) and "error" in topics:
                return f"Error fetching discussion topics: {topics['error']}"
    
            if not topics:
                return f"No discussion topics found for course {course_identifier}."
    
            topics_info = []
            for topic in topics:
                topic_id = topic.get("id")
                title = topic.get("title", "Untitled topic")
                is_announcement = topic.get("is_announcement", False)
                published = topic.get("published", False)
                posted_at = format_date(topic.get("posted_at"))
    
                topic_type = "Announcement" if is_announcement else "Discussion"
                status = "Published" if published else "Unpublished"
    
                topics_info.append(
                    f"ID: {topic_id}\nType: {topic_type}\nTitle: {title}\nStatus: {status}\nPosted: {posted_at}\n"
                )
    
            course_display = await get_course_code(course_id) or course_identifier
            return f"Discussion Topics for Course {course_display}:\n\n" + "\n".join(topics_info)
    
        @mcp.tool()
        @validate_params
        async def get_discussion_topic_details(course_identifier: str | int,
                                             topic_id: str | int) -> str:
            """Get detailed information about a specific discussion topic.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                topic_id: The Canvas discussion topic ID
            """
            course_id = await get_course_id(course_identifier)
    
            response = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{topic_id}"
            )
    
            if "error" in response:
                return f"Error fetching discussion topic details: {response['error']}"
    
            # Extract topic details
            title = response.get("title", "Untitled")
            message = response.get("message", "")
            is_announcement = response.get("is_announcement", False)
            author = response.get("author", {})
            author_name = author.get("display_name", "Unknown author")
            author_id = author.get("id", "Unknown")
    
            created_at = format_date(response.get("created_at"))
            posted_at = format_date(response.get("posted_at"))
    
            # Discussion statistics
            discussion_entries_count = response.get("discussion_entries_count", 0)
            unread_count = response.get("unread_count", 0)
            read_state = response.get("read_state", "unknown")
    
            # Topic settings
            locked = response.get("locked", False)
            pinned = response.get("pinned", False)
            require_initial_post = response.get("require_initial_post", False)
    
            # Format the output
            course_display = await get_course_code(course_id) or course_identifier
            topic_type = "Announcement" if is_announcement else "Discussion"
    
            result = f"{topic_type} Details for Course {course_display}:\n\n"
            result += f"Title: {title}\n"
            result += f"ID: {topic_id}\n"
            result += f"Type: {topic_type}\n"
            result += f"Author: {author_name} (ID: {author_id})\n"
            result += f"Created: {created_at}\n"
            result += f"Posted: {posted_at}\n"
    
            if locked:
                result += "Status: Locked\n"
            if pinned:
                result += "Pinned: Yes\n"
            if require_initial_post:
                result += "Requires Initial Post: Yes\n"
    
            result += f"Total Entries: {discussion_entries_count}\n"
            if unread_count > 0:
                result += f"Unread Entries: {unread_count}\n"
            result += f"Read State: {read_state.title()}\n"
    
            if message:
                result += f"\nContent:\n{message}"
    
            return result
    
        @mcp.tool()
        @validate_params
        async def list_discussion_entries(course_identifier: str | int,
                                        topic_id: str | int,
                                        include_full_content: bool = False,
                                        include_replies: bool = False) -> str:
            """List discussion entries (posts) for a specific discussion topic with optional full content and replies.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                topic_id: The Canvas discussion topic ID
                include_full_content: Whether to fetch full content for each entry (default: False)
                include_replies: Whether to fetch replies for each entry (default: False)
            """
            course_id = await get_course_id(course_identifier)
    
            # Get basic entries first
            entries = await fetch_all_paginated_results(
                f"/courses/{course_id}/discussion_topics/{topic_id}/entries",
                {"per_page": 100}
            )
    
            if isinstance(entries, dict) and "error" in entries:
                return f"Error fetching discussion entries: {entries['error']}"
    
            if not entries:
                return f"No discussion entries found for topic {topic_id}."
    
            # Anonymize entries to protect student privacy
            try:
                anonymized_entries = anonymize_response_data(entries, data_type="discussions")
                # Basic validation: check that anonymization occurred
                if anonymized_entries and isinstance(anonymized_entries, list) and len(anonymized_entries) > 0:
                    # Verify first entry was anonymized (has anonymous user_name)
                    first_entry = anonymized_entries[0]
                    if first_entry.get("user_name", "").startswith("Student_"):
                        entries = anonymized_entries  # Use anonymized data
                    else:
                        log_warning(
                            "Anonymization may not have been applied properly",
                            course_id=course_id,
                            topic_id=topic_id
                        )
                else:
                    entries = anonymized_entries  # Use result even if validation unclear
            except Exception as e:
                # Log error but continue with original data rather than failing completely
                log_error(
                    "Failed to anonymize discussion entries",
                    exc=e,
                    course_id=course_id,
                    topic_id=topic_id
                )
                # Continue with original data - this maintains functionality while logging the issue
    
            # Enhanced content fetching using multiple methods
            if include_full_content or include_replies:
                # Method 1: Try to get everything from discussion view (most efficient)
                full_entries_map = {}
                try:
                    view_response = await make_canvas_request(
                        "get", f"/courses/{course_id}/discussion_topics/{topic_id}/view"
                    )
    
                    if "error" not in view_response and "view" in view_response:
                        for view_entry in view_response.get("view", []):
                            full_entries_map[str(view_entry.get("id"))] = view_entry
                except Exception as e:
                    log_warning(
                        "Failed to fetch discussion view, falling back to individual calls",
                        exc=e,
                        course_id=course_id,
                        topic_id=topic_id
                    )
    
                # Method 2: For entries not found in view, try entry_list endpoint
                missing_entry_ids = []
                for entry in entries:
                    entry_id = str(entry.get("id"))
                    if entry_id not in full_entries_map:
                        missing_entry_ids.append(entry_id)
    
                if missing_entry_ids:
                    try:
                        entry_list_response = await make_canvas_request(
                            "get", f"/courses/{course_id}/discussion_topics/{topic_id}/entry_list",
                            params={"ids[]": missing_entry_ids}
                        )
    
                        if "error" not in entry_list_response and isinstance(entry_list_response, list):
                            for full_entry in entry_list_response:
                                full_entries_map[str(full_entry.get("id"))] = full_entry
                    except Exception as e:
                        log_warning(
                            "Failed to fetch entry list",
                            exc=e,
                            course_id=course_id,
                            topic_id=topic_id,
                            missing_count=len(missing_entry_ids)
                        )
    
            # Get topic details for context
            topic_response = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{topic_id}"
            )
    
            topic_title = "Unknown Topic"
            if "error" not in topic_response:
                topic_title = topic_response.get("title", "Unknown Topic")
    
            # Format the output
            course_display = await get_course_code(course_id) or course_identifier
            entries_info = []
    
            for entry in entries:
                entry_id = entry.get("id")
                entry_id_str = str(entry_id)
                user_id = entry.get("user_id")
                user_name = entry.get("user_name", "Unknown user")
                created_at = format_date(entry.get("created_at"))
    
                # Get message content
                if include_full_content and entry_id_str in full_entries_map:
                    # Use full content from enhanced fetch
                    full_entry = full_entries_map[entry_id_str]
                    message = full_entry.get("message", entry.get("message", ""))
                else:
                    # Use basic content from original entry
                    message = entry.get("message", "")
    
                # Process message content
                import re
                if message:
                    if include_full_content:
                        # For full content, clean HTML but keep the full text
                        message_display = re.sub(r'<[^>]+>', '', message)
                        message_display = message_display.strip()
                        if not message_display:
                            message_display = "[Content contains only HTML/formatting]"
                    else:
                        # For preview, truncate as before
                        message_preview = re.sub(r'<[^>]+>', '', message)
                        if len(message_preview) > 300:
                            message_preview = message_preview[:300] + "..."
                        message_display = message_preview.replace("\n", " ").strip()
                else:
                    message_display = "[No content]"
    
                # Handle replies
                replies_info = ""
                if include_replies:
                    replies = []
    
                    # Try to get replies from enhanced fetch first
                    if entry_id_str in full_entries_map:
                        replies = full_entries_map[entry_id_str].get("replies", [])
    
                    # If no replies from enhanced fetch, try basic recent_replies
                    if not replies:
                        replies = entry.get("recent_replies", [])
    
                    # If still no replies or need more, try direct API call
                    has_more_replies = entry.get("has_more_replies", False)
                    if not replies or has_more_replies:
                        try:
                            replies_response = await fetch_all_paginated_results(
                                f"/courses/{course_id}/discussion_topics/{topic_id}/entries/{entry_id}/replies",
                                {"per_page": 100}
                            )
    
                            if not isinstance(replies_response, dict) or "error" not in replies_response:
                                replies = replies_response
                        except Exception as e:
                            log_warning(
                                "Failed to fetch entry replies",
                                exc=e,
                                course_id=course_id,
                                topic_id=topic_id,
                                entry_id=entry_id
                            )
    
                    if replies:
                        replies_info = f"\n  Replies ({len(replies)}):\n"
                        for i, reply in enumerate(replies, 1):
                            reply_user = reply.get("user_name", "Unknown")
                            reply_created = format_date(reply.get("created_at"))
                            reply_msg = reply.get("message", "")
    
                            # Clean reply message
                            if reply_msg:
                                reply_clean = re.sub(r'<[^>]+>', '', reply_msg)
                                if len(reply_clean) > 200:
                                    reply_clean = reply_clean[:200] + "..."
                                reply_clean = reply_clean.replace("\n", " ").strip()
                            else:
                                reply_clean = "[No content]"
    
                            replies_info += f"    {i}. {reply_user} ({reply_created}): {reply_clean}\n"
                    else:
                        replies_info = "\n  No replies found.\n"
                else:
                    # Just show reply count without fetching
                    recent_replies = entry.get("recent_replies", [])
                    has_more_replies = entry.get("has_more_replies", False)
                    total_replies = len(recent_replies)
                    if has_more_replies:
                        total_replies_text = f"{total_replies}+ replies"
                    elif total_replies > 0:
                        total_replies_text = f"{total_replies} replies"
                    else:
                        total_replies_text = "No replies"
    
                    replies_info = f"\n  Replies: {total_replies_text}"
    
                # Build entry info
                entry_info = f"Entry ID: {entry_id}\n"
                entry_info += f"Author: {user_name} (ID: {user_id})\n"
                entry_info += f"Posted: {created_at}{replies_info}\n"
    
                if include_full_content:
                    entry_info += f"Full Content:\n{message_display}\n"
                else:
                    entry_info += f"Content Preview: {message_display}\n"
    
                entries_info.append(entry_info)
    
            # Add helpful footer information
            footer = ""
            if not include_full_content:
                footer += "\nšŸ’” Tip: Use include_full_content=True to get complete post content in one call"
            if not include_replies:
                footer += "\nšŸ’” Tip: Use include_replies=True to fetch all replies"
    
            return f"Discussion Entries for '{topic_title}' in Course {course_display}:\n\n" + "\n".join(entries_info) + footer
    
        @mcp.tool()
        @validate_params
        async def get_discussion_entry_details(course_identifier: str | int,
                                             topic_id: str | int,
                                             entry_id: str | int,
                                             include_replies: bool = True) -> str:
            """Get detailed information about a specific discussion entry including all its replies.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                topic_id: The Canvas discussion topic ID
                entry_id: The Canvas discussion entry ID
                include_replies: Whether to fetch and include replies (default: True)
            """
            course_id = await get_course_id(course_identifier)
    
            # Method 1: Try to get entry details from the discussion view endpoint
            entry_response = None
            replies = []
    
            try:
                # First try the discussion view endpoint which includes all entries
                view_response = await make_canvas_request(
                    "get", f"/courses/{course_id}/discussion_topics/{topic_id}/view"
                )
    
                if "error" not in view_response and "view" in view_response:
                    # Find our specific entry in the view
                    for entry in view_response.get("view", []):
                        if str(entry.get("id")) == str(entry_id):
                            entry_response = entry
                            if include_replies:
                                replies = entry.get("replies", [])
                            break
            except Exception as e:
                log_warning(
                    "Failed to fetch discussion view for entry details",
                    exc=e,
                    course_id=course_id,
                    topic_id=topic_id,
                    entry_id=entry_id
                )
    
            # Method 2: If view method failed, try the entry_list endpoint
            if not entry_response:
                try:
                    entry_list_response = await make_canvas_request(
                        "get", f"/courses/{course_id}/discussion_topics/{topic_id}/entry_list",
                        params={"ids[]": entry_id}
                    )
    
                    if "error" not in entry_list_response and isinstance(entry_list_response, list):
                        if entry_list_response:
                            entry_response = entry_list_response[0]
                except Exception as e:
                    log_warning(
                        "Failed to fetch entry from entry_list",
                        exc=e,
                        course_id=course_id,
                        topic_id=topic_id,
                        entry_id=entry_id
                    )
    
            # Method 3: Fallback to getting all entries and finding our target
            if not entry_response:
                try:
                    all_entries = await fetch_all_paginated_results(
                        f"/courses/{course_id}/discussion_topics/{topic_id}/entries",
                        {"per_page": 100}
                    )
    
                    if not isinstance(all_entries, dict) or "error" not in all_entries:
                        for entry in all_entries:
                            if str(entry.get("id")) == str(entry_id):
                                entry_response = entry
                                # Get recent_replies from this method
                                if include_replies:
                                    replies = entry.get("recent_replies", [])
                                break
                except Exception as e:
                    log_warning(
                        "Failed to fetch all entries as fallback",
                        exc=e,
                        course_id=course_id,
                        topic_id=topic_id,
                        entry_id=entry_id
                    )
    
            # If we still don't have the entry, return error
            if not entry_response:
                return f"Error: Could not find discussion entry {entry_id} in topic {topic_id}. The entry may not exist or you may not have permission to view it."
    
            # Method 4: If we have the entry but no replies yet, try the replies endpoint
            if include_replies and not replies:
                try:
                    replies_response = await fetch_all_paginated_results(
                        f"/courses/{course_id}/discussion_topics/{topic_id}/entries/{entry_id}/replies",
                        {"per_page": 100}
                    )
    
                    if not isinstance(replies_response, dict) or "error" not in replies_response:
                        replies = replies_response
                except Exception as e:
                    log_warning(
                        "Failed to fetch entry replies from replies endpoint",
                        exc=e,
                        course_id=course_id,
                        topic_id=topic_id,
                        entry_id=entry_id
                    )
    
            # Get topic details for context
            topic_response = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{topic_id}"
            )
    
            topic_title = "Unknown Topic"
            if "error" not in topic_response:
                topic_title = topic_response.get("title", "Unknown Topic")
    
            # Format the entry details
            course_display = await get_course_code(course_id) or course_identifier
    
            user_id = entry_response.get("user_id")
            user_name = entry_response.get("user_name", "Unknown user")
            message = entry_response.get("message", "")
            created_at = format_date(entry_response.get("created_at"))
            updated_at = format_date(entry_response.get("updated_at"))
            read_state = entry_response.get("read_state", "unknown")
    
            result = f"Discussion Entry Details for '{topic_title}' in Course {course_display}:\n\n"
            result += f"Topic ID: {topic_id}\n"
            result += f"Entry ID: {entry_id}\n"
            result += f"Author: {user_name} (ID: {user_id})\n"
            result += f"Posted: {created_at}\n"
    
            if updated_at != "N/A" and updated_at != created_at:
                result += f"Updated: {updated_at}\n"
    
            result += f"Read State: {read_state.title()}\n"
            result += f"\nContent:\n{message}\n"
    
            # Format replies
            if include_replies:
                if replies:
                    result += f"\nReplies ({len(replies)}):\n"
                    result += "=" * 50 + "\n"
    
                    for i, reply in enumerate(replies, 1):
                        reply_id = reply.get("id")
                        reply_user_name = reply.get("user_name", "Unknown user")
                        reply_message = reply.get("message", "")
                        reply_created_at = format_date(reply.get("created_at"))
    
                        result += f"\nReply #{i}:\n"
                        result += f"Reply ID: {reply_id}\n"
                        result += f"Author: {reply_user_name}\n"
                        result += f"Posted: {reply_created_at}\n"
                        result += f"Content:\n{reply_message}\n"
                else:
                    result += "\nNo replies found for this entry."
            else:
                result += "\n(Replies not included - set include_replies=True to fetch them)"
    
            return result
    
        @mcp.tool()
        @validate_params
        async def get_discussion_with_replies(course_identifier: str | int,
                                            topic_id: str | int,
                                            include_replies: bool = False) -> str:
            """Enhanced function to get discussion entries with optional reply fetching.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                topic_id: The Canvas discussion topic ID
                include_replies: Whether to fetch detailed replies for all entries (default: False)
            """
            course_id = await get_course_id(course_identifier)
    
            # Get basic entries first
            entries = await fetch_all_paginated_results(
                f"/courses/{course_id}/discussion_topics/{topic_id}/entries",
                {"per_page": 100}
            )
    
            if isinstance(entries, dict) and "error" in entries:
                return f"Error fetching discussion entries: {entries['error']}"
    
            if not entries:
                return f"No discussion entries found for topic {topic_id}."
    
            # Get topic details for context
            topic_response = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{topic_id}"
            )
    
            topic_title = "Unknown Topic"
            if "error" not in topic_response:
                topic_title = topic_response.get("title", "Unknown Topic")
    
            course_display = await get_course_code(course_id) or course_identifier
            result = f"Discussion '{topic_title}' in Course {course_display}:\n\n"
    
            # Process each entry
            for entry in entries:
                entry_id = entry.get("id")
                user_name = entry.get("user_name", "Unknown user")
                message = entry.get("message", "")
                created_at = format_date(entry.get("created_at"))
    
                # Clean up message for display
                import re
                if message:
                    message_preview = re.sub(r'<[^>]+>', '', message)
                    if len(message_preview) > 200:
                        message_preview = message_preview[:200] + "..."
                    message_preview = message_preview.replace("\n", " ").strip()
                else:
                    message_preview = "[No content]"
    
                result += f"šŸ“ Entry {entry_id} by {user_name}\n"
                result += f"   Posted: {created_at}\n"
                result += f"   Content: {message_preview}\n"
    
                # Handle replies
                if include_replies:
                    replies = []
    
                    # Method 1: Check recent_replies from the entry
                    recent_replies = entry.get("recent_replies", [])
                    if recent_replies:
                        replies = recent_replies
    
                    # Method 2: If no recent_replies or has_more_replies, try direct API call
                    has_more_replies = entry.get("has_more_replies", False)
                    if not replies or has_more_replies:
                        try:
                            replies_response = await fetch_all_paginated_results(
                                f"/courses/{course_id}/discussion_topics/{topic_id}/entries/{entry_id}/replies",
                                {"per_page": 100}
                            )
    
                            if not isinstance(replies_response, dict) or "error" not in replies_response:
                                replies = replies_response
                        except Exception as e:
                            log_warning(
                                "Failed to fetch detailed replies",
                                exc=e,
                                course_id=course_id,
                                topic_id=topic_id,
                                entry_id=entry_id
                            )
    
                    # Display replies
                    if replies:
                        result += f"   šŸ’¬ Replies ({len(replies)}):\n"
                        for i, reply in enumerate(replies, 1):
                            reply_user = reply.get("user_name", "Unknown")
                            reply_created = format_date(reply.get("created_at"))
                            reply_msg = reply.get("message", "")
    
                            # Clean reply message
                            if reply_msg:
                                reply_preview = re.sub(r'<[^>]+>', '', reply_msg)
                                if len(reply_preview) > 150:
                                    reply_preview = reply_preview[:150] + "..."
                                reply_preview = reply_preview.replace("\n", " ").strip()
                            else:
                                reply_preview = "[No content]"
    
                            result += f"      └─ Reply {i} by {reply_user} ({reply_created}): {reply_preview}\n"
                    else:
                        recent_count = len(entry.get("recent_replies", []))
                        has_more = entry.get("has_more_replies", False)
                        if recent_count > 0 or has_more:
                            result += f"   šŸ’¬ Replies: {recent_count}{'+ (has more)' if has_more else ''} (failed to fetch details)\n"
                        else:
                            result += "   šŸ’¬ No replies\n"
                else:
                    # Just show reply count without fetching
                    recent_count = len(entry.get("recent_replies", []))
                    has_more = entry.get("has_more_replies", False)
                    if recent_count > 0 or has_more:
                        result += f"   šŸ’¬ Replies: {recent_count}{'+ (has more)' if has_more else ''}\n"
                    else:
                        result += "   šŸ’¬ No replies\n"
    
                result += "\n"
    
            if not include_replies:
                result += "\nšŸ’” Tip: Use include_replies=True to fetch detailed reply content"
    
            return result
    
        @mcp.tool()
        @validate_params
        async def post_discussion_entry(course_identifier: str | int,
                                      topic_id: str | int,
                                      message: str) -> str:
            """Post a new top-level entry to a discussion topic.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                topic_id: The Canvas discussion topic ID
                message: The entry message content
            """
            course_id = await get_course_id(course_identifier)
    
            # Prepare the entry data
            data = {
                "message": message
            }
    
            # Post the entry
            response = await make_canvas_request(
                "post", f"/courses/{course_id}/discussion_topics/{topic_id}/entries",
                data=data
            )
    
            if "error" in response:
                return f"Error posting discussion entry: {response['error']}"
    
            # Get context information for confirmation
            topic_response = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{topic_id}"
            )
    
            topic_title = "Unknown Topic"
            if "error" not in topic_response:
                topic_title = topic_response.get("title", "Unknown Topic")
    
            # Extract entry details from response
            entry_id = response.get("id")
            entry_created_at = format_date(response.get("created_at"))
            entry_user_name = response.get("user_name", "You")
    
            # Build confirmation message
            course_display = await get_course_code(course_id) or course_identifier
            result = "Discussion entry posted successfully!\n\n"
            result += f"Course: {course_display}\n"
            result += f"Discussion Topic: {topic_title} (ID: {topic_id})\n"
            result += f"Entry ID: {entry_id}\n"
            result += f"Entry Author: {entry_user_name}\n"
            result += f"Posted: {entry_created_at}\n\n"
            result += f"Your Entry:\n{message}\n"
    
            return result
    
        @mcp.tool()
        @validate_params
        async def reply_to_discussion_entry(course_identifier: str | int,
                                          topic_id: str | int,
                                          entry_id: str | int,
                                          message: str) -> str:
            """Reply to a student's discussion entry/comment.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                topic_id: The Canvas discussion topic ID
                entry_id: The Canvas discussion entry ID to reply to
                message: The reply message content
            """
            course_id = await get_course_id(course_identifier)
    
            # Ensure IDs are strings
            topic_id_str = str(topic_id)
            entry_id_str = str(entry_id)
    
            data = {
                "message": message
            }
    
            response = await make_canvas_request(
                "post",
                f"/courses/{course_id}/discussion_topics/{topic_id_str}/entries/{entry_id_str}/replies",
                data=data
            )
    
            if "error" in response:
                return f"Error posting reply: {response['error']}"
    
            reply_id = response.get("id")
            course_display = await get_course_code(course_id) or course_identifier
    
            return f"Reply posted successfully in course {course_display}:\n" + \
                   f"Topic ID: {topic_id}\n" + \
                   f"Original Entry ID: {entry_id}\n" + \
                   f"Reply ID: {reply_id}\n" + \
                   f"Message: {truncate_text(message, 200)}"
    
        @mcp.tool()
        @validate_params
        async def create_discussion_topic(course_identifier: str | int,
                                        title: str,
                                        message: str,
                                        delayed_post_at: str | None = None,
                                        lock_at: str | None = None,
                                        require_initial_post: bool = False,
                                        pinned: bool = False) -> str:
            """Create a new discussion topic for a course.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                title: The title/subject of the discussion topic
                message: The content/body of the discussion topic
                delayed_post_at: Optional ISO 8601 datetime to schedule posting (e.g., "2024-01-15T12:00:00Z")
                lock_at: Optional ISO 8601 datetime to automatically lock the discussion
                require_initial_post: Whether students must post before seeing other posts
                pinned: Whether to pin this discussion topic
            """
            course_id = await get_course_id(course_identifier)
    
            data = {
                "title": title,
                "message": message,
                "published": True,
                "require_initial_post": require_initial_post,
                "pinned": pinned
            }
    
            if delayed_post_at:
                data["delayed_post_at"] = delayed_post_at
    
            if lock_at:
                data["lock_at"] = lock_at
    
            response = await make_canvas_request(
                "post", f"/courses/{course_id}/discussion_topics", data=data
            )
    
            if "error" in response:
                return f"Error creating discussion topic: {response['error']}"
    
            topic_id = response.get("id")
            topic_title = response.get("title", title)
            created_at = format_date(response.get("created_at"))
    
            course_display = await get_course_code(course_id) or course_identifier
            return f"Discussion topic created successfully in course {course_display}:\n\n" + \
                   f"ID: {topic_id}\n" + \
                   f"Title: {topic_title}\n" + \
                   f"Created: {created_at}"
    
        # ===== ANNOUNCEMENT 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 = {
                "include[]": ["announcement"],
                "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:
                announcement_id = announcement.get("id")
                title = announcement.get("title", "Untitled announcement")
                posted_at = format_date(announcement.get("posted_at"))
    
                announcements_info.append(
                    f"ID: {announcement_id}\nTitle: {title}\nPosted: {posted_at}\n"
                )
    
            course_display = await get_course_code(course_id) or course_identifier
            return f"Announcements for Course {course_display}:\n\n" + "\n".join(announcements_info)
    
        @mcp.tool()
        @validate_params
        async def create_announcement(course_identifier: str | int,
                                    title: str,
                                    message: str,
                                    delayed_post_at: str | None = None,
                                    lock_at: str | None = None) -> str:
            """Create a new announcement for a course with optional scheduling.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                title: The title/subject of the announcement
                message: The content/body of the announcement
                delayed_post_at: Optional ISO 8601 datetime to schedule posting (e.g., "2024-01-15T12:00:00Z")
                lock_at: Optional ISO 8601 datetime to automatically lock the announcement
            """
            course_id = await get_course_id(course_identifier)
    
            data = {
                "title": title,
                "message": message,
                "is_announcement": True,
                "published": True
            }
    
            if delayed_post_at:
                data["delayed_post_at"] = delayed_post_at
    
            if lock_at:
                data["lock_at"] = lock_at
    
            response = await make_canvas_request(
                "post", f"/courses/{course_id}/discussion_topics", data=data
            )
    
            if "error" in response:
                return f"Error creating announcement: {response['error']}"
    
            announcement_id = response.get("id")
            announcement_title = response.get("title", title)
            created_at = format_date(response.get("created_at"))
    
            course_display = await get_course_code(course_id) or course_identifier
            return f"Announcement created successfully in course {course_display}:\n\n" + \
                   f"ID: {announcement_id}\n" + \
                   f"Title: {announcement_title}\n" + \
                   f"Created: {created_at}"
    
        # ===== ANNOUNCEMENT DELETION TOOLS =====
    
        @mcp.tool()
        @validate_params
        async def delete_announcement(
            course_identifier: str | int,
            announcement_id: str | int
        ) -> str:
            """
            Delete an announcement from a Canvas course.
    
            Announcements are technically discussion topics in Canvas, so this uses
            the discussion_topics endpoint to delete them.
    
            Args:
                course_identifier: The Canvas course code (e.g., badm_554_120251_246794) or ID
                announcement_id: The Canvas announcement/discussion topic ID to delete
    
            Returns:
                String describing the deletion result with status and title
    
            Raises:
                HTTPError:
                    - 401: User doesn't have permission to delete the announcement
                    - 404: Announcement not found in the specified course
                    - 403: Editing is restricted for this announcement
    
            Example usage:
                result = delete_announcement("60366", "925355")
                print(f"Result: {result}")
            """
            course_id = await get_course_id(course_identifier)
    
            # First, get the announcement details to return meaningful information
            announcement = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{announcement_id}"
            )
    
            if "error" in announcement:
                return f"Error fetching announcement details: {announcement['error']}"
    
            announcement_title = announcement.get("title", "Unknown Title")
    
            # Proceed with deletion
            response = await make_canvas_request(
                "delete", f"/courses/{course_id}/discussion_topics/{announcement_id}"
            )
    
            if "error" in response:
                return f"Error deleting announcement '{announcement_title}': {response['error']}"
    
            course_display = await get_course_code(course_id) or course_identifier
            return f"Announcement deleted successfully from course {course_display}:\n\n" + \
                   f"ID: {announcement_id}\n" + \
                   f"Title: {announcement_title}\n" + \
                   "Status: deleted\n" + \
                   "Message: Announcement deleted successfully"
    
        @mcp.tool()
        @validate_params
        async def bulk_delete_announcements(
            course_identifier: str | int,
            announcement_ids: list[str | int],
            stop_on_error: bool = False
        ) -> str:
            """
            Delete multiple announcements from a Canvas course.
    
            Args:
                course_identifier: The Canvas course code or ID
                announcement_ids: List of announcement IDs to delete
                stop_on_error: If True, stop processing on first error; if False, continue with remaining
    
            Returns:
                String with detailed results including successful and failed deletions
    
            Example usage:
                results = bulk_delete_announcements(
                    "60366",
                    ["925355", "925354", "925353"],
                    stop_on_error=False
                )
            """
            course_id = await get_course_id(course_identifier)
    
            successful = []
            failed = []
    
            for announcement_id in announcement_ids:
                try:
                    # Get announcement details first
                    announcement = await make_canvas_request(
                        "get", f"/courses/{course_id}/discussion_topics/{announcement_id}"
                    )
    
                    if "error" in announcement:
                        failed.append({
                            "id": str(announcement_id),
                            "error": announcement["error"],
                            "message": "Failed to fetch announcement details"
                        })
                        if stop_on_error:
                            break
                        continue
    
                    # Proceed with deletion
                    response = await make_canvas_request(
                        "delete", f"/courses/{course_id}/discussion_topics/{announcement_id}"
                    )
    
                    if "error" in response:
                        failed.append({
                            "id": str(announcement_id),
                            "title": announcement.get("title", "Unknown Title"),
                            "error": response["error"],
                            "message": "Failed to delete announcement"
                        })
                        if stop_on_error:
                            break
                    else:
                        successful.append({
                            "id": str(announcement_id),
                            "title": announcement.get("title", "Unknown Title")
                        })
    
                except Exception as e:
                    failed.append({
                        "id": str(announcement_id),
                        "error": str(e),
                        "message": "Unexpected error during deletion"
                    })
                    if stop_on_error:
                        break
    
            # Format results
            summary = {
                "total": len(announcement_ids),
                "successful": len(successful),
                "failed": len(failed)
            }
    
            course_display = await get_course_code(course_id) or course_identifier
            result = f"Bulk deletion results for course {course_display}:\n\n"
            result += f"Summary: {summary['successful']} successful, {summary['failed']} failed out of {summary['total']} total\n\n"
    
            if successful:
                result += "Successfully deleted:\n"
                for item in successful:
                    result += f"  - ID: {item['id']}, Title: {item['title']}\n"
                result += "\n"
    
            if failed:
                result += "Failed to delete:\n"
                for item in failed:
                    result += f"  - ID: {item['id']}"
                    if 'title' in item:
                        result += f", Title: {item['title']}"
                    result += f", Error: {item['error']}\n"
    
            return result
    
        @mcp.tool()
        @validate_params
        async def delete_announcement_with_confirmation(
            course_identifier: str | int,
            announcement_id: str | int,
            require_title_match: str | None = None,
            dry_run: bool = False
        ) -> str:
            """
            Delete an announcement with optional safety checks.
    
            Args:
                course_identifier: The Canvas course code or ID
                announcement_id: The announcement ID to delete
                require_title_match: If provided, only delete if the announcement title matches exactly
                dry_run: If True, verify but don't actually delete (for testing)
    
            Returns:
                String with operation result including status and title match information
    
            Raises:
                ValueError: If require_title_match is provided and doesn't match the actual title
    
            Example usage:
                # Delete only if title matches exactly (safety check)
                result = delete_announcement_with_confirmation(
                    "60366",
                    "925355",
                    require_title_match="Preparing for the week",
                    dry_run=False
                )
            """
            course_id = await get_course_id(course_identifier)
    
            # First fetch the announcement details
            announcement = await make_canvas_request(
                "get", f"/courses/{course_id}/discussion_topics/{announcement_id}"
            )
    
            if "error" in announcement:
                return f"Error fetching announcement details: {announcement['error']}"
    
            actual_title = announcement.get("title", "Unknown Title")
            title_matched = True
    
            # Check title match if required
            if require_title_match is not None:
                title_matched = actual_title == require_title_match
                if not title_matched:
                    return f"Title mismatch - Expected: '{require_title_match}', Actual: '{actual_title}'. Deletion aborted for safety."
    
            # Handle dry run
            if dry_run:
                course_display = await get_course_code(course_id) or course_identifier
                result = f"DRY RUN - Would delete announcement from course {course_display}:\n\n"
                result += f"ID: {announcement_id}\n"
                result += f"Title: {actual_title}\n"
                result += "Status: dry_run\n"
                result += "Message: Announcement would be deleted (dry run mode)\n"
                if require_title_match:
                    result += f"Title matched: {title_matched}\n"
                return result
    
            # Proceed with actual deletion
            response = await make_canvas_request(
                "delete", f"/courses/{course_id}/discussion_topics/{announcement_id}"
            )
    
            if "error" in response:
                return f"Error deleting announcement '{actual_title}': {response['error']}"
    
            course_display = await get_course_code(course_id) or course_identifier
            result = f"Announcement deleted successfully from course {course_display}:\n\n"
            result += f"ID: {announcement_id}\n"
            result += f"Title: {actual_title}\n"
            result += "Status: deleted\n"
            result += "Message: Announcement deleted successfully\n"
            if require_title_match:
                result += f"Title matched: {title_matched}\n"
    
            return result
    
        @mcp.tool()
        @validate_params
        async def delete_announcements_by_criteria(
            course_identifier: str | int,
            criteria: dict,
            limit: int | None = None,
            dry_run: bool = True
        ) -> str:
            """
            Delete announcements matching specific criteria.
    
            Args:
                course_identifier: The Canvas course code or ID
                criteria: Dict with search criteria:
                    - "title_contains": str - Delete if title contains this text
                    - "older_than": str - Delete if posted before this date (ISO format)
                    - "newer_than": str - Delete if posted after this date (ISO format)
                    - "title_regex": str - Delete if title matches regex pattern
                limit: Maximum number of announcements to delete (safety limit)
                dry_run: If True, show what would be deleted without actually deleting
    
            Returns:
                String with operation results showing matched and deleted announcements
    
            Example usage:
                # Delete all announcements older than 30 days
                from datetime import datetime, timedelta
    
                results = delete_announcements_by_criteria(
                    "60366",
                    criteria={
                        "older_than": (datetime.now() - timedelta(days=30)).isoformat(),
                        "title_contains": "reminder"
                    },
                    limit=10,
                    dry_run=False
                )
            """
            course_id = await get_course_id(course_identifier)
    
            # First list all announcements
            params = {
                "include[]": ["announcement"],
                "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}."
    
            # Filter based on criteria
            matched = []
    
            for announcement in announcements:
                match = True
                announcement_title = announcement.get("title", "")
                posted_at_str = announcement.get("posted_at")
    
                # Check title_contains
                if "title_contains" in criteria:
                    if criteria["title_contains"].lower() not in announcement_title.lower():
                        match = False
    
                # Check title_regex
                if "title_regex" in criteria and match:
                    try:
                        if not re.search(criteria["title_regex"], announcement_title, re.IGNORECASE):
                            match = False
                    except re.error:
                        return f"Invalid regex pattern: {criteria['title_regex']}"
    
                # Check date criteria
                if posted_at_str and match:
                    try:
                        posted_at = datetime.fromisoformat(posted_at_str.replace('Z', '+00:00'))
    
                        if "older_than" in criteria:
                            older_than = datetime.fromisoformat(criteria["older_than"].replace('Z', '+00:00'))
                            if posted_at >= older_than:
                                match = False
    
                        if "newer_than" in criteria and match:
                            newer_than = datetime.fromisoformat(criteria["newer_than"].replace('Z', '+00:00'))
                            if posted_at <= newer_than:
                                match = False
    
                    except ValueError as e:
                        return f"Error parsing date: {e}"
    
                if match:
                    matched.append(announcement)
    
            # Apply limit if specified
            limit_reached = False
            if limit and len(matched) > limit:
                matched = matched[:limit]
                limit_reached = True
    
            course_display = await get_course_code(course_id) or course_identifier
            result = f"Criteria-based deletion results for course {course_display}:\n\n"
            result += f"Search criteria: {json.dumps(criteria, indent=2)}\n\n"
            result += f"Matched {len(matched)} announcements"
            if limit_reached:
                result += f" (limited to {limit})"
            result += "\n\n"
    
            if not matched:
                result += "No announcements matched the specified criteria."
                return result
    
            # Show what was matched
            result += "Matched announcements:\n"
            for announcement in matched:
                result += f"  - ID: {announcement.get('id')}, Title: {announcement.get('title', 'Untitled')}, Posted: {format_date(announcement.get('posted_at'))}\n"
            result += "\n"
    
            if dry_run:
                result += "DRY RUN: No announcements were actually deleted.\n"
                result += "Set dry_run=False to perform actual deletions."
                return result
    
            # Perform actual deletions
            deleted = []
            failed = []
    
            for announcement in matched:
                announcement_id = announcement.get("id")
                try:
                    response = await make_canvas_request(
                        "delete", f"/courses/{course_id}/discussion_topics/{announcement_id}"
                    )
    
                    if "error" in response:
                        failed.append({
                            "id": str(announcement_id),
                            "title": announcement.get("title", "Unknown Title"),
                            "error": response["error"]
                        })
                    else:
                        deleted.append({
                            "id": str(announcement_id),
                            "title": announcement.get("title", "Unknown Title")
                        })
    
                except Exception as e:
                    failed.append({
                        "id": str(announcement_id),
                        "title": announcement.get("title", "Unknown Title"),
                        "error": str(e)
                    })
    
            result += f"Deletion completed: {len(deleted)} successful, {len(failed)} failed\n\n"
    
            if deleted:
                result += "Successfully deleted:\n"
                for item in deleted:
                    result += f"  - ID: {item['id']}, Title: {item['title']}\n"
                result += "\n"
    
            if failed:
                result += "Failed to delete:\n"
                for item in failed:
                    result += f"  - ID: {item['id']}, Title: {item['title']}, Error: {item['error']}\n"
    
            return result
Install Server

Other Tools

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/vishalsachdev/canvas-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server