loading…
Search for a command to run...
loading…
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
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.
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.
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.
/mcp) + GET /healthlist_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)DR_PROFILE=1):search(query) → basic text search over SUMMARY/DESCRIPTION in a time windowfetch(ids) → fetch raw text/calendar ICS blobs for search resultsMAIL_ENABLED=1):list_mailboxes() — list all folderslist_messages(mailbox, limit, unread_only) — list messages with headersget_message(uid, mailbox) — fetch full message with bodysearch_messages(query, mailbox, limit) — IMAP TEXT searchsend_message(to, subject, body, cc?, bcc?) — send via SMTPdelete_message(uid, mailbox) — move to Trashmark_message(uid, mailbox, read) — mark read/unreadYYYY-MM-DDTHH:MM:SS, with optional Z or timezone offset)https://caldav.icloud.com, imap.mail.me.com, smtp.mail.me.comCreate 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.
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
list_calendars() -> List[Calendar]Returns:
name: str | nullurl: str (preferred identifier for other calls)id: str | nulllist_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 instancesEach 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 URLstart, end: str — ISO datetimes; search is [start, end)expand_recurring: bool — include concrete instances of recurring seriesReturns each event with:
uid: strsummary: strstart: str (ISO)end: str | null (ISO)raw: str (original ICS text)create_event(calendar_name_or_url, summary, start, end, tzid?, description?, location?, recurrence?) -> strCreates 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) -> boolUpdates the whole event identified by uid (for recurring events this updates the series VEVENT, not a single instance).
location:null / not provided), keeps the existing location.recurrence:create_event.clear_recurrence:True, removes any RRULE and converts the event back to a single non-recurring instance.True and recurrence is also provided, clear_recurrence wins (no recurrence).True on success, False if uid not found in ±3-year window.delete_event(calendar_name_or_url, uid) -> boolDeletes the first matching uid in a ±3-year window.
True if deleted, False if not found.Date/Time Notes
Z/offset datetimes (YYYY-MM-DDTHH:MM:SS, optionally Z or -04:00 etc.)DTSTART;TZID=... and DTEND;TZID=... using provided tzid or TZID envLOCATION is emitted when location is provided and non-empty; passing an empty string when updating an event removes the existing location.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: boolget_message(uid, mailbox="INBOX") -> MessageFetches the full message including decoded body (text/plain preferred, HTML stripped as fallback). Returns:
uid, subject, from, to, cc, date, body, readsearch_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) -> boolSends via SMTP (STARTTLS on port 587). to and cc may be comma-separated. Returns True on success.
delete_message(uid, mailbox="INBOX") -> boolCopies to Trash (Deleted Messages by default, override with ICLOUD_TRASH_FOLDER) then expunges. Returns True on success.
mark_message(uid, mailbox="INBOX", read=True) -> boolSets or clears the \Seen flag. Returns True on success.
Set DR_PROFILE=1 to run a read-only tool set for Deep Research. This exposes only:
Example:
DR_PROFILE=1 HOST=127.0.0.1 PORT=8000 python server.py
Notes:
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())
To use this with claude Custom Connectors you need a public HTTPS endpoint that forwards to your local server.
See DEPLOY.md for:
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.
| 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. |
MIT License.
Happy scheduling, I hope this helps!
Добавь это в claude_desktop_config.json и перезапусти Claude Desktop.
{
"mcpServers": {
"icloud-caldav-mcp-connector": {
"command": "npx",
"args": []
}
}
}