import os
from datetime import datetime, timedelta, timezone
from dotenv import load_dotenv
from caldav import DAVClient
from caldav.elements import dav
from icalendar import Calendar as ICal, Event as ICalEvent
from fastmcp import FastMCP
load_dotenv()
APPLE_ID = os.getenv("ICLOUD_APPLE_ID")
APP_PWD = os.getenv("ICLOUD_APP_PWD")
# Connect once and reuse
_client = DAVClient(
url="https://caldav.icloud.com/",
username=APPLE_ID,
password=APP_PWD,
)
_principal = _client.principal()
_calendars = _principal.calendars()
# Find the AI calendar, or fall back to the first calendar
_default_cal = None
for cal in _calendars:
props = cal.get_properties([dav.DisplayName()])
cal_name = str(props.get("{DAV:}displayname", "")) if props else ""
if cal_name == "AI":
_default_cal = cal
break
if _default_cal is None:
_default_cal = _calendars[0] # fallback to first calendar
mcp = FastMCP("icloud-calendar")
@mcp.tool()
def list_events(days_ahead: int = 7) -> list[dict]:
"""
Return events in the next N days from *all* calendars.
Includes day of week and readable date formats to help with date understanding.
"""
now = datetime.now(timezone.utc)
end = now + timedelta(days=days_ahead)
out = []
for cal in _calendars:
try:
props = cal.get_properties([dav.DisplayName()])
cal_name = str(props.get("{DAV:}displayname", "Unnamed")) if props else "Unnamed"
events = cal.date_search(start=now, end=end)
for ev in events:
ics = ICal.from_ical(ev.data)
for component in ics.walk("VEVENT"):
start_dt = component.decoded("dtstart")
end_dt = component.decoded("dtend") if component.get("dtend") else None
# Convert to datetime if it's a date object
if hasattr(start_dt, 'date') and not hasattr(start_dt, 'hour'):
# It's a date object, convert to datetime
start_dt = datetime.combine(start_dt, datetime.min.time()).replace(tzinfo=timezone.utc)
# Format dates with day of week
start_str = start_dt.strftime("%Y-%m-%d %H:%M") if hasattr(start_dt, 'hour') else str(start_dt)
day_of_week = start_dt.strftime("%A") if hasattr(start_dt, 'strftime') else ""
readable_date = start_dt.strftime("%B %d, %Y (%A)") if hasattr(start_dt, 'strftime') else str(start_dt)
out.append({
"calendar": cal_name,
"uid": str(component.get("uid")),
"summary": str(component.get("summary")),
"start": start_str,
"end": str(end_dt) if end_dt else None,
"day_of_week": day_of_week,
"readable_date": readable_date,
"location": str(component.get("location")) if component.get("location") else None,
"description": str(component.get("description")) if component.get("description") else "",
})
except Exception as e:
print(f"Skipping calendar due to error: {e}")
# sort by start time
out.sort(key=lambda ev: ev["start"])
return out
@mcp.tool()
def create_event(summary: str, start_iso: str, end_iso: str, location: str | None = None, description: str | None = None) -> str:
"""
Create a new event in the default calendar.
Automatically tags events as AI-created.
"""
if _default_cal is None:
raise ValueError("No calendar available. Please check your iCloud calendar configuration.")
cal = ICal()
cal.add("prodid", "-//MCP-iCloud//EN")
cal.add("version", "2.0")
ev = ICalEvent()
ev.add("summary", f"AI - {summary}") # Add AI prefix
ev.add("dtstart", datetime.fromisoformat(start_iso))
ev.add("dtend", datetime.fromisoformat(end_iso))
# Add description tag
desc = "Added by AI"
if description:
desc += f"\n\n{description}"
ev.add("description", desc)
if location:
ev.add("location", location)
cal.add_component(ev)
_default_cal.add_event(cal.to_ical())
return f"Created event '{summary}' (AI-tagged) in '{_default_cal.url}'"
@mcp.tool()
def list_calendars() -> list[dict]:
"""
List all calendars with names and URLs.
"""
out = []
for c in _calendars:
props = c.get_properties([dav.DisplayName()])
out.append({
"name": str(props.get("{DAV:}displayname", "Unnamed")) if props else "Unnamed",
"url": str(c.url),
"readonly": False,
})
return out
if __name__ == "__main__":
import sys
print("icloud-calendar MCP server running...", file=sys.stderr)
mcp.run()