A Model Context Protocol (MCP) server (HTTP or stdio) exposing iCloud Calendar (CalDAV) tools so MCP-aware clients (e.g., ChatGPT custom connectors, Claude Code, Claude Desktop) can list calendars, read events, and create/update/delete events using an iCloud app-specific password.
Unofficial. Calendar only. Keep this service private; it forwards your iCloud app-specific password to Apple's CalDAV endpoint.
I built this to use in ChatGPT 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.
- HTTP MCP server (
/mcp) +GET /health, or stdio for local clients (--transport stdio) - Tools (default write-capable profile):
list_calendars()list_calendars_with_events(start, end, expand_recurring=True)list_events(start, end, calendar_name_or_url?, expand_recurring=True, query?, include_raw=False)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, occurrence_start?)
- Tools (Deep Research read-only profile):
search(query)-> basic text search over SUMMARY/DESCRIPTION in a time windowfetch(ids)-> fetch rawtext/calendarICS blobs for search results
- ISO datetime input (
YYYY-MM-DDTHH:MM:SS, with optionalZor timezone offset); bare dates for all-day events - Updates edit the stored event in place, so alarms, attendees and recurrence exceptions survive
- Finds events by their
<uid>.icsURL, then by UID query, then by a +/-3-year scan (iCloud rejects UID queries)
- Python 3.11+
- Apple ID (email identity, not phone number)
- iCloud app-specific password (revocable)
- Network access to
https://caldav.icloud.com
Create a .env next to server.py (auto-loaded):
APPLE_ID=you@example.com # Use your Apple ID email
ICLOUD_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx # App-specific password
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 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)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 # OKMCP endpoint: http://127.0.0.1:8000/mcp
Local clients can launch the server on demand over stdio, so nothing has to stay running. The .env next to server.py is still used for credentials.
Claude Code:
claude mcp add -s user icloud-calendar -- /path/to/icloud-mcp/.venv/bin/python /path/to/icloud-mcp/server.py --transport stdioClaude Desktop (~/Library/Application Support/Claude/claude_desktop_config.json), then restart Claude:
{
"mcpServers": {
"icloud-calendar": {
"command": "/path/to/icloud-mcp/.venv/bin/python",
"args": ["/path/to/icloud-mcp/server.py", "--transport", "stdio"]
}
}
}MCP_TRANSPORT=stdio in the environment does the same as --transport stdio.
Returns:
name: str | nullurl: str(preferred identifier for other calls)id: str | null
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(start, end, calendar_name_or_url?, expand_recurring=True, query?, include_raw=False) -> List[Event]
Args
start, end: str: ISO datetimes; search is [start, end) (naive times are inTZID)calendar_name_or_url: str | null: display name or full CalDAV URL; omit to search all calendarsexpand_recurring: bool: include concrete instances of recurring seriesquery: str | null: case-insensitive filter on summary, location and descriptioninclude_raw: bool: also return the ICS text (large; off by default)
Returns events sorted by start, each with:
uid: str(shared by every occurrence of a recurring series)summary: strstart: str,end: str | null: ISO, expressed inTZID; date-only for all-day events (end exclusive)all_day: boollocation: str | nulldescription: str | null(first 500 characters)calendar: str(display name)recurring: boolraw: str(only withinclude_raw=true)
create_event(calendar_name_or_url, summary, start, end, tzid?, description?, location?, recurrence?) -> str
Creates a VEVENT.
-
start/endas dates (YYYY-MM-DD) create an all-day event;endis exclusive (a single day isDtoD+1;end == startis treated as one day). -
tziddefaults toTZIDenv if omitted; naive datetimes are assumed in that zone (stored asDTSTART;TZID=...). -
An unrecognized
recurrenceis rejected rather than silently ignored. -
descriptionis optional; omit or passnullto skip it. -
locationis optional; omit or passnullto skip it. -
recurrence(optional) describes how the event should repeat, for example: -
Returns the generated
uid(random hex +@icloud-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).
- Edits the stored event in place: anything not passed (alarms, attendees, URL, EXDATEs, moved occurrences, ...) is kept.
start/end:- Only
startgiven: the event keeps its duration. - Dates (
YYYY-MM-DD) make it all-day; datetimes make it timed. - Moving a recurring series' start shifts its EXDATEs and moved occurrences by the same amount so they stay attached.
- Only
description: omit to keep,""to clear.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.
- If omitted (
recurrence:- If provided, replaces any existing RRULE using the same shape as in
create_event.
- If provided, replaces any existing RRULE using the same shape as in
clear_recurrence:- If
True, removes any RRULE/RDATE/EXDATE and moved occurrences, converting the event back to a single non-recurring instance. - If
Trueandrecurrenceis also provided,clear_recurrencewins (no recurrence).
- If
- Returns
Trueon success,Falseifuidis not in that calendar.
Deletes the event with uid.
- Without
occurrence_start: deletes the whole event (the entire series if recurring). - With
occurrence_start(thestartthatlist_eventsreturned for that occurrence, or just its date): deletes only that occurrence by adding an EXDATE (and dropping its override if it had been moved). The rest of the series stays. - Returns
Trueif deleted,Falseif the event or occurrence is not found.
Date/Time Notes
- Accepts naive or
Z/offset datetimes (YYYY-MM-DDTHH:MM:SS, optionallyZor-04:00etc.) YYYY-MM-DDmeans an all-day event (create/update) or a whole day (occurrence_start)- New/rescheduled events emit
DTSTART;TZID=...andDTEND;TZID=...using providedtzidorTZIDenv - Updates leave
DTSTART/DTEND(and their TZID) untouched unlessstart/endare passed LOCATIONis emitted whenlocationis provided and non-empty; passing an empty string when updating an event removes the existing location.
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.pyNotes:
- 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
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 ChatGPT 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.
| 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 belongs to a different calendar than the one you passed. |
| Timezone drift | Pass tzid explicitly (e.g., America/New_York) or use UTC ...Z. |
- Use app-specific passwords and rotate as needed
- Keep this server private (tunnel ACLs, IP allowlists, auth proxy)
- Updates edit the stored event in place; the server only changes the fields you pass (plus DTSTAMP/LAST-MODIFIED)
MIT License.
Happy scheduling, I hope this helps!
{ "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" }