An HTTP Model Context Protocol (MCP) server exposing iCloud Calendar (CalDAV) tools so MCP-aware clients (e.g., ChatGPT custom connectors, IDEs) 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 - 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)
- 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) - Minimal ICS generation (summary/description escaping), UID matching across a ±3-year window
- 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
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().
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 series
Returns 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?) -> str
Creates a minimal VEVENT.
-
tziddefaults toTZIDenv if omitted; naive datetimes are assumed in that zone and stored as UTC. -
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 +@chatgpt-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.
- 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 and converts the event back to a single non-recurring instance. - If
Trueandrecurrenceis also provided,clear_recurrencewins (no recurrence).
- If
- Returns
Trueon success,Falseifuidnot found in ±3-year window.
Deletes the first matching uid in a ±3-year window.
- Returns
Trueif deleted,Falseif not found.
Date/Time Notes
- Accepts naive or
Z/offset datetimes (YYYY-MM-DDTHH:MM:SS, optionallyZor-04:00etc.) - New/edited events emit
DTSTART;TZID=...andDTEND;TZID=...using providedtzidorTZIDenv - Updates attempt to reuse the original TZID when present
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 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. |
- 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
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" }