""" MCP Server exposing a read-only agenda tool for Radicale CalDAV data. Reads .ics files directly from the Radicale data directory, parses VEVENT and VTODO components, expands recurring events (RRULE), and returns them sorted chronologically for a given date range. """ import json import logging import os import re from datetime import date, datetime, timedelta from pathlib import Path from typing import Any, Optional import recurring_ical_events from dateutil import parser as date_parser from dotenv import load_dotenv from icalendar import Calendar from starlette.requests import Request from starlette.responses import JSONResponse from fastmcp import FastMCP load_dotenv() # Configure logging logging.basicConfig( level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s", ) logger = logging.getLogger(__name__) CALDAV_DATA_PATH = os.environ.get("CALDAV_DATA_PATH", "") EXCLUDE_PATTERN = os.environ.get("EXCLUDE_PATTERN", "") BIRTHDAY_PATTERN = os.environ.get("BIRTHDAY_PATTERN", r"^Anniversaire|^Anniv") def _validate_startup_config(caldav_data_path: str = CALDAV_DATA_PATH) -> None: """Validates required startup configuration, raising on missing values.""" if not caldav_data_path: raise ValueError("CALDAV_DATA_PATH environment variable is required.") if __name__ == "__main__" or os.environ.get("MCP_ICS_VALIDATE_ON_IMPORT") == "1": _validate_startup_config() # Compile patterns once at startup _exclude_re: Optional[re.Pattern] = None if EXCLUDE_PATTERN: try: _exclude_re = re.compile(EXCLUDE_PATTERN, re.IGNORECASE) logger.info("Compiled exclude pattern: %s", EXCLUDE_PATTERN) except re.error as exc: logger.error("Invalid EXCLUDE_PATTERN '%s': %s", EXCLUDE_PATTERN, exc) _birthday_re: Optional[re.Pattern] = None if BIRTHDAY_PATTERN: try: _birthday_re = re.compile(BIRTHDAY_PATTERN, re.IGNORECASE) logger.info("Compiled birthday pattern: %s", BIRTHDAY_PATTERN) except re.error as exc: logger.error("Invalid BIRTHDAY_PATTERN '%s': %s", BIRTHDAY_PATTERN, exc) # Initialize FastMCP server mcp = FastMCP("mcp-caldav") @mcp.custom_route("/health", methods=["GET"]) async def health_check(request: Request): """Simple health check endpoint for Kubernetes liveness/readiness probes.""" return JSONResponse({"status": "ok"}) def discover_calendars(data_path: str) -> list[dict[str, Any]]: """ Discovers Radicale calendar collections. Walks the collection-root directory and reads .Radicale.props files to identify VCALENDAR collections (skipping VADDRESSBOOK). Returns a list of dicts with keys: path, name, description, user. """ calendars = [] collection_root = Path(data_path) / "collections" / "collection-root" if not collection_root.is_dir(): logger.warning("Collection root not found: %s", collection_root) return calendars for user_dir in sorted(collection_root.iterdir()): if not user_dir.is_dir() or user_dir.name.startswith("."): continue for cal_dir in sorted(user_dir.iterdir()): if not cal_dir.is_dir() or cal_dir.name.startswith("."): continue props_file = cal_dir / ".Radicale.props" if not props_file.is_file(): continue try: props = json.loads(props_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: logger.warning("Failed to read props for %s: %s", cal_dir, exc) continue # Only process calendar collections if props.get("tag") != "VCALENDAR": continue calendars.append({ "path": str(cal_dir), "name": props.get("D:displayname", cal_dir.name), "description": props.get("C:calendar-description", ""), "user": user_dir.name, }) logger.info("Discovered %d calendar(s)", len(calendars)) for cal in calendars: logger.info(" - %s (%s) [user: %s]", cal["name"], cal["path"], cal["user"]) return calendars def parse_ics_files(calendar_path: str) -> list[Calendar]: """ Reads and parses all .ics files from a calendar directory. Returns a list of icalendar.Calendar objects. """ cal_dir = Path(calendar_path) calendars = [] for ics_file in cal_dir.glob("*.ics"): try: raw = ics_file.read_bytes() cal = Calendar.from_ical(raw) calendars.append(cal) except Exception as exc: logger.warning("Failed to parse %s: %s", ics_file, exc) continue return calendars def component_to_dt(value: Any) -> Optional[datetime]: """ Extracts a datetime from an iCalendar date/datetime property. Handles both date and datetime types, converting dates to datetime at midnight for consistent comparison. """ if value is None: return None dt = value.dt if hasattr(value, "dt") else value if isinstance(dt, datetime): return dt if isinstance(dt, date): return datetime(dt.year, dt.month, dt.day) return None def format_dt(dt: Optional[datetime | date]) -> Optional[str]: """Formats a date or datetime to ISO 8601 string.""" if dt is None: return None if isinstance(dt, datetime): return dt.isoformat() if isinstance(dt, date): return dt.isoformat() return str(dt) def format_component( component: Any, calendar_name: str, include_birthdays: bool = False, ) -> Optional[dict[str, Any]]: """ Formats a VEVENT or VTODO component into a dict for API response. Returns None if the component should be excluded (matches EXCLUDE_PATTERN or matches BIRTHDAY_PATTERN when include_birthdays is False). """ summary = str(component.get("SUMMARY", "")) # Apply exclude filter if _exclude_re and _exclude_re.search(summary): return None # Apply birthday filter (excluded by default, included on demand) if not include_birthdays and _birthday_re and _birthday_re.search(summary): return None comp_type = component.name # "VEVENT" or "VTODO" dtstart = component_to_dt(component.get("DTSTART")) dtend = component_to_dt(component.get("DTEND")) due = component_to_dt(component.get("DUE")) result: dict[str, Any] = { "type": "event" if comp_type == "VEVENT" else "todo", "summary": summary, "start": format_dt(dtstart), "calendar": calendar_name, "uid": str(component.get("UID", "")), } if comp_type == "VEVENT": result["end"] = format_dt(dtend) elif comp_type == "VTODO": result["due"] = format_dt(due) status = str(component.get("STATUS", "")) if status: result["status"] = status location = str(component.get("LOCATION", "")) if location: result["location"] = location description = str(component.get("DESCRIPTION", "")) if description: result["description"] = description return result def get_events_in_range( start: datetime, end: datetime, include_birthdays: bool = False, ) -> list[dict[str, Any]]: """ Returns all VEVENT and VTODO components across all calendars that fall within [start, end], with RRULE expansion. """ calendars = discover_calendars(CALDAV_DATA_PATH) all_items: list[dict[str, Any]] = [] for cal_info in calendars: ics_calendars = parse_ics_files(cal_info["path"]) for cal in ics_calendars: try: events = recurring_ical_events.of(cal, components=["VEVENT", "VTODO"]).between( start, end ) except Exception as exc: logger.warning( "Error expanding events in %s: %s", cal_info["name"], exc ) continue for event in events: formatted = format_component(event, cal_info["name"], include_birthdays) if formatted is not None: all_items.append(formatted) # Sort by start date def sort_key(item: dict[str, Any]) -> str: return item.get("start") or item.get("due") or "" all_items.sort(key=sort_key) return all_items @mcp.tool() def get_agenda( start_date: str, end_date: str = "", include_birthdays: bool = False, ): """ Returns all calendar events and todos within a date range. All calendars are aggregated. Recurring events are automatically expanded. Results are sorted chronologically. Birthdays are excluded by default to reduce noise. Set include_birthdays to True when the user explicitly asks about birthdays or anniversaries. Args: start_date: Start date in ISO 8601 format (e.g. "2026-03-15" or "2026-03-15T09:00:00"). end_date: End date in ISO 8601 format. Defaults to 7 days after start_date if not provided. include_birthdays: Whether to include birthday/anniversary events (default: False). """ logger.info( "Tool get_agenda called: start_date=%r, end_date=%r, include_birthdays=%r", start_date, end_date, include_birthdays, ) try: start = date_parser.parse(start_date) if end_date: end = date_parser.parse(end_date) else: end = start + timedelta(days=7) logger.info("Fetching agenda from %s to %s", start.isoformat(), end.isoformat()) items = get_events_in_range(start, end, include_birthdays) logger.info("Found %d item(s) in range", len(items)) return { "start_date": start.isoformat(), "end_date": end.isoformat(), "count": len(items), "items": items, } except Exception as exc: logger.error("Error in get_agenda: %s", exc, exc_info=True) return {"error": f"get_agenda failed: {exc}"} if __name__ == "__main__": logger.info("Starting SSE server on 0.0.0.0:8000...") mcp.run(transport="sse", host="0.0.0.0", port=8000)