Command Palette

Search for a command to run...

UnylyUnyly
Browse all

ICloud CalDAV Connector

FreeNot checked

An MCP server that enables interaction with iCloud Calendars via the CalDAV protocol using an app-specific password. It allows users to list, create, update, an

GitHubEmbed

About

An MCP server that enables interaction with iCloud Calendars via the CalDAV protocol using an app-specific password. It allows users to list, create, update, and delete calendar events through MCP-aware clients like ChatGPT.

README

An HTTP Model Context Protocol (MCP) server exposing iCloud services to MCP-aware clients (e.g., claude custom connectors, IDEs) using an iCloud app-specific password.

Supported: iCloud Calendar (CalDAV) + iCloud Mail (IMAP/SMTP).

Unofficial. Keep this service private; it forwards your iCloud app-specific password to Apple’s servers.


Why did I build this?

I built this to use in Claude Custom Connector, so I can change my iCloud Calendar compared to changing it manually. Came up with this idea on a Friday night before a TOP Pset was due, and this turned out to be a fun 1-day project.


Features

  • HTTP MCP server (/mcp) + GET /health
  • Calendar tools (default write-capable profile):
    • list_calendars()
    • list_calendars_with_events(start, end, expand_recurring=True)
    • list_events(calendar_name_or_url, start, end, expand_recurring=True)
    • create_event(calendar_name_or_url, summary, start, end, tzid?, description?, location?, recurrence?)
    • update_event(calendar_name_or_url, uid, summary?, start?, end?, tzid?, description?, location?, recurrence?, clear_recurrence=False)
    • delete_event(calendar_name_or_url, uid)
  • Calendar tools (Deep Research read-only profile, DR_PROFILE=1):
    • search(query) → basic text search over SUMMARY/DESCRIPTION in a time window
    • fetch(ids) → fetch raw text/calendar ICS blobs for search results
  • Mail tools (opt-in, MAIL_ENABLED=1):
    • list_mailboxes() — list all folders
    • list_messages(mailbox, limit, unread_only) — list messages with headers
    • get_message(uid, mailbox) — fetch full message with body
    • search_messages(query, mailbox, limit) — IMAP TEXT search
    • send_message(to, subject, body, cc?, bcc?) — send via SMTP
    • delete_message(uid, mailbox) — move to Trash
    • mark_message(uid, mailbox, read) — mark read/unread
  • ISO datetime input (YYYY-MM-DDTHH:MM:SS, with optional Z or timezone offset)
  • Minimal ICS generation (summary/description escaping), UID matching across a ±3-year window

Requirements

  • Python 3.11+
  • Apple ID (email identity, not phone number)
  • iCloud app-specific password (revocable) — one password works for both calendar and mail
  • Network access to https://caldav.icloud.com, imap.mail.me.com, smtp.mail.me.com

Environment

Create a .env next to server.py (auto-loaded):

[email protected]                 # Use your Apple ID email
ICLOUD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx  # App-specific password (works for both calendar and mail)
CALDAV_URL=https://caldav.icloud.com     # optional, default shown
HOST=127.0.0.1                           # optional
PORT=8000                                # optional
TZID=America/New_York                    # default TZ for new/edited events

# Deep Research: read-only calendar profile (optional)
DR_PROFILE=0                             # Set to 1 to enable DR mode (default 0)
SCAN_DAYS=1095                           # Time window (days) scanned by DR search/fetch (default ~3 years)

# Mail (IMAP / SMTP) — optional, disabled by default
MAIL_ENABLED=1                           # Set to 1 to enable mail tools
IMAP_HOST=imap.mail.me.com              # optional, default shown
IMAP_PORT=993                            # optional, default shown
SMTP_HOST=smtp.mail.me.com              # optional, default shown
SMTP_PORT=587                            # optional, default shown
ICLOUD_TRASH_FOLDER=Deleted Messages     # optional, iCloud trash folder name

Required: APPLE_ID, ICLOUD_APP_PASSWORD.


Quick Start (local)

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

# Ensure .env exists (see above), then:
python server.py
# -> Listening on http://127.0.0.1:8000
curl http://127.0.0.1:8000/health   # OK

MCP endpoint: http://127.0.0.1:8000/mcp


Tool Reference (functional details)

list_calendars() -> List[Calendar]

Returns:

  • name: str | null
  • url: str (preferred identifier for other calls)
  • id: str | null

list_calendars_with_events(start, end, expand_recurring=True) -> List[Calendar]

Returns only the calendars that contain at least one event in the given time window.

Args

  • start, end: str — ISO datetimes; search is [start, end)
  • expand_recurring: bool — treat recurring series as concrete instances

Each returned calendar has the same shape as list_calendars().

list_events(calendar_name_or_url, start, end, expand_recurring=True) -> List[Event]

Args

  • calendar_name_or_url: str — display name or full CalDAV URL
  • start, end: str — ISO datetimes; search is [start, end)
  • expand_recurring: bool — include concrete instances of recurring series

Returns each event with:

  • uid: str
  • summary: str
  • start: str (ISO)
  • end: str | null (ISO)
  • raw: str (original ICS text)

create_event(calendar_name_or_url, summary, start, end, tzid?, description?, location?, recurrence?) -> str

Creates a minimal VEVENT.

  • tzid defaults to TZID env if omitted; naive datetimes are assumed in that zone and stored as UTC.

  • description is optional; omit or pass null to skip it.

  • location is optional; omit or pass null to skip it.

  • recurrence (optional) describes how the event should repeat, for example:

    {
        "frequency": "weekly",              // daily | weekly | monthly | yearly | custom
        "interval": 1,                       // optional, default 1
        "by_weekday": ["MO", "WE"],         // optional; for weekly/custom
        "by_monthday": [1, 15],             // optional; for monthly/custom
        "end": {                            // optional end condition
            "type": "on_date",              // or "after_occurrences"
            "date": "2025-12-31"            // when type == "on_date"
            // or: "count": 10               // when type == "after_occurrences"
        }
        // for custom frequency you can pass a raw RRULE:
        // "frequency": "custom",
        // "rrule": "FREQ=MONTHLY;BYDAY=MO,TU;BYSETPOS=1"
    }
    
  • Returns the generated uid (random hex + @claude-mcp).

update_event(calendar_name_or_url, uid, summary?, start?, end?, tzid?, description?, location?, recurrence?, clear_recurrence=False) -> bool

Updates the whole event identified by uid (for recurring events this updates the series VEVENT, not a single instance).

  • Preserves any omitted fields from the original component.
  • location:
    • If omitted (null / not provided), keeps the existing location.
    • If provided as a non-empty string, updates the event’s location.
    • If provided as an empty string, clears the event’s location.
  • recurrence:
    • If provided, replaces any existing RRULE using the same shape as in create_event.
  • clear_recurrence:
    • If True, removes any RRULE and converts the event back to a single non-recurring instance.
    • If True and recurrence is also provided, clear_recurrence wins (no recurrence).
  • Returns True on success, False if uid not found in ±3-year window.

delete_event(calendar_name_or_url, uid) -> bool

Deletes the first matching uid in a ±3-year window.

  • Returns True if deleted, False if not found.

Date/Time Notes

  • Accepts naive or Z/offset datetimes (YYYY-MM-DDTHH:MM:SS, optionally Z or -04:00 etc.)
  • New/edited events emit DTSTART;TZID=... and DTEND;TZID=... using provided tzid or TZID env
  • Updates attempt to reuse the original TZID when present
  • LOCATION is emitted when location is provided and non-empty; passing an empty string when updating an event removes the existing location.

Mail Tool Reference

Enable with MAIL_ENABLED=1. Uses the same APPLE_ID and ICLOUD_APP_PASSWORD as the calendar. No extra dependencies — pure Python stdlib (imaplib, smtplib).

list_mailboxes() -> List[{name}]

Returns all IMAP folders (INBOX, Sent, Drafts, Junk, Deleted Messages, etc.).

list_messages(mailbox="INBOX", limit=20, unread_only=False) -> List[Message]

Returns newest-first headers for up to limit messages. Each item:

  • uid: str, subject: str, from: str, date: str, read: bool

get_message(uid, mailbox="INBOX") -> Message

Fetches the full message including decoded body (text/plain preferred, HTML stripped as fallback). Returns:

  • uid, subject, from, to, cc, date, body, read

search_messages(query, mailbox="INBOX", limit=20) -> List[Message]

IMAP TEXT search — matches subject and body. Returns same header fields as list_messages.

send_message(to, subject, body, cc=None, bcc=None) -> bool

Sends via SMTP (STARTTLS on port 587). to and cc may be comma-separated. Returns True on success.

delete_message(uid, mailbox="INBOX") -> bool

Copies to Trash (Deleted Messages by default, override with ICLOUD_TRASH_FOLDER) then expunges. Returns True on success.

mark_message(uid, mailbox="INBOX", read=True) -> bool

Sets or clears the \Seen flag. Returns True on success.


Deep Research read-only mode

Set DR_PROFILE=1 to run a read-only tool set for Deep Research. This exposes only:

  • search(query) -> [{ id, title, snippet }]
  • fetch(ids) -> [{ id, mimeType: 'text/calendar', content }]

Example:

DR_PROFILE=1 HOST=127.0.0.1 PORT=8000 python server.py

Notes:

  • Write tools (list_events/create_event/update_event/delete_event) are disabled in this mode.
  • SCAN_DAYS controls the search window around “now” (default: 1095 days ≈ 3 years).
  • Keep this service private or add auth

Example (programmatic client)

import asyncio, json
from fastmcp import Client

MCP_URL = "http://127.0.0.1:8000/mcp"
CAL_URL = "<paste one of your calendar URLs>"

def unwrap(res):
    sc = getattr(res, "structured_content", None)
    if isinstance(sc, dict) and "result" in sc:
        return sc["result"]
    return json.loads(res.content[0].text)

async def main():
    async with Client(MCP_URL) as c:
        cals = unwrap(await c.call_tool("list_calendars", {"confirm": True}))
        print("Calendars:", cals[:2])

        evs = unwrap(await c.call_tool("list_events", {
            "calendar_name_or_url": CAL_URL,
            "start": "2025-09-01T00:00:00",
            "end":   "2025-10-01T00:00:00",
            "expand_recurring": True
        }))
        print("Events:", len(evs))

        uid = unwrap(await c.call_tool("create_event", {
            "calendar_name_or_url": CAL_URL,
            "summary":"Demo",
            "start":"2025-09-29T15:00:00",
            "end":"2025-09-29T15:30:00",
            "tzid":"America/New_York",
            "location": "Bobst Library"
        }))
        print("Created:", uid)

asyncio.run(main())

Deployment / Public HTTPS

To use this with claude Custom Connectors you need a public HTTPS endpoint that forwards to your local server.

See DEPLOY.md for:

  • Cloudflare Tunnel (stable hostname, free)
  • ngrok (quick test)
  • VPS + Caddy/Nginx (permanent)

Security: add auth (Cloudflare Access, Basic Auth proxy, IP allowlist). Do NOT expose this unauthenticated; it holds live calendar write access. You need a public HTTPS URL that forwards to your local http://127.0.0.1:8000.


Troubleshooting

Symptom Likely Cause / Fix
401 Unauthorized Wrong Apple ID or app-specific password; ensure .env uses email, not phone.
Empty event results Wrong calendar URL or time window; remember end is exclusive.
Update/Delete no-ops UID not in ±3-year scan window or different calendar than you’re querying.
Timezone drift Pass tzid explicitly (e.g., America/New_York) or use UTC ...Z.

Security

  • Use app-specific passwords and rotate as needed
  • Keep this server private (tunnel ACLs, IP allowlists, auth proxy)
  • This project rewrites minimal VEVENTs; advanced fields (attendees, alarms, recurrence exceptions) are not preserved on update

License

MIT License.


Happy scheduling, I hope this helps!

from github.com/alexey-max-fedorov/icloud-mcp

Installing ICloud CalDAV Connector

This server has no published package — it is built from source. Open the repository and follow its README.

▸ github.com/alexey-max-fedorov/icloud-mcp

FAQ

Is ICloud CalDAV Connector MCP free?

Yes, ICloud CalDAV Connector MCP is free — one-click install via Unyly at no cost.

Does ICloud CalDAV Connector need an API key?

No, ICloud CalDAV Connector runs without API keys or environment variables.

Is ICloud CalDAV Connector hosted or self-hosted?

Self-hosted: the server runs locally on your machine via the install command above.

How do I install ICloud CalDAV Connector in Claude Desktop, Claude Code or Cursor?

Open ICloud CalDAV Connector on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.

Related MCPs

Compare ICloud CalDAV Connector with

Not sure what to pick?

Find your stack in 60 seconds

Author?

Embed badge for your README

Browse similar

All productivity MCPs