7e619d0454
Python MCP server (FastMCP) providing Claude Code/CoWork access to Apple Reminders, Calendar, Mail, Contacts, Find My, and Maps via AppleScript. Includes a native SwiftUI config/installer app and a compiled EventKit helper for fast reminder queries on large databases (8,000+ items). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
288 lines
9.9 KiB
Python
288 lines
9.9 KiB
Python
"""Apple Calendar tools."""
|
|
|
|
from datetime import datetime, timedelta
|
|
from helpers import run_applescript, safe_applescript_string, parse_applescript_date, today_str
|
|
|
|
|
|
def list_calendars() -> list[dict]:
|
|
"""List all calendars."""
|
|
script = '''
|
|
tell application "Calendar"
|
|
set output to ""
|
|
repeat with c in calendars
|
|
set cName to name of c
|
|
set cDesc to ""
|
|
try
|
|
set cDesc to description of c
|
|
end try
|
|
set output to output & cName & "|||" & cDesc & linefeed
|
|
end repeat
|
|
return output
|
|
end tell'''
|
|
raw = run_applescript(script)
|
|
results = []
|
|
for line in raw.strip().split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("|||")
|
|
results.append({
|
|
"name": parts[0].strip(),
|
|
"description": parts[1].strip() if len(parts) > 1 else "",
|
|
})
|
|
return results
|
|
|
|
|
|
def get_events(start_date: str | None = None, end_date: str | None = None,
|
|
calendar_name: str | None = None, days: int = 7) -> list[dict]:
|
|
"""Get calendar events in a date range.
|
|
|
|
Args:
|
|
start_date: ISO date (YYYY-MM-DD). Defaults to today.
|
|
end_date: ISO date (YYYY-MM-DD). Defaults to start_date + days.
|
|
calendar_name: Filter to a specific calendar.
|
|
days: Number of days to look ahead (used if end_date not provided).
|
|
"""
|
|
if not start_date:
|
|
start_date = today_str()
|
|
if not end_date:
|
|
start_dt = datetime.fromisoformat(start_date)
|
|
end_dt = start_dt + timedelta(days=days)
|
|
end_date = end_dt.strftime("%Y-%m-%d")
|
|
|
|
cal_filter = ""
|
|
if calendar_name:
|
|
safe_cal = safe_applescript_string(calendar_name)
|
|
cal_filter = f'set cals to {{calendar "{safe_cal}"}}'
|
|
else:
|
|
cal_filter = "set cals to calendars"
|
|
|
|
script = f'''
|
|
tell application "Calendar"
|
|
{cal_filter}
|
|
set startD to current date
|
|
set year of startD to {start_date[:4]}
|
|
set month of startD to {int(start_date[5:7])}
|
|
set day of startD to {int(start_date[8:10])}
|
|
set hours of startD to 0
|
|
set minutes of startD to 0
|
|
set seconds of startD to 0
|
|
|
|
set endD to current date
|
|
set year of endD to {end_date[:4]}
|
|
set month of endD to {int(end_date[5:7])}
|
|
set day of endD to {int(end_date[8:10])}
|
|
set hours of endD to 23
|
|
set minutes of endD to 59
|
|
set seconds of endD to 59
|
|
|
|
set output to ""
|
|
repeat with c in cals
|
|
set calName to name of c
|
|
set evts to (every event of c whose start date >= startD and start date <= endD)
|
|
repeat with e in evts
|
|
set eSummary to summary of e
|
|
set eStart to (start date of e) as string
|
|
set eEnd to (end date of e) as string
|
|
set eLoc to ""
|
|
try
|
|
set eLoc to location of e
|
|
end try
|
|
set eAllDay to allday event of e as string
|
|
set eNotes to ""
|
|
try
|
|
set eNotes to description of e
|
|
on error
|
|
set eNotes to ""
|
|
end try
|
|
set eId to uid of e
|
|
set output to output & calName & ">>>" & eSummary & "|||" & eStart & "|||" & eEnd & "|||" & eLoc & "|||" & eAllDay & "|||" & eNotes & "|||" & eId & linefeed
|
|
end repeat
|
|
end repeat
|
|
return output
|
|
end tell'''
|
|
raw = run_applescript(script, timeout=60)
|
|
results = []
|
|
for line in raw.strip().split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("|||")
|
|
if len(parts) < 7:
|
|
continue
|
|
name_part = parts[0]
|
|
cal = None
|
|
if ">>>" in name_part:
|
|
cal, name_part = name_part.split(">>>", 1)
|
|
results.append({
|
|
"summary": name_part.strip(),
|
|
"calendar": cal.strip() if cal else None,
|
|
"start": parse_applescript_date(parts[1].strip()),
|
|
"end": parse_applescript_date(parts[2].strip()),
|
|
"location": parts[3].strip() or None,
|
|
"all_day": parts[4].strip() == "true",
|
|
"notes": parts[5].strip() or None,
|
|
"id": parts[6].strip(),
|
|
})
|
|
return results
|
|
|
|
|
|
def search_events(query: str, days_back: int = 30, days_forward: int = 30) -> list[dict]:
|
|
"""Search for events by summary text."""
|
|
start_dt = datetime.now() - timedelta(days=days_back)
|
|
end_dt = datetime.now() + timedelta(days=days_forward)
|
|
start_date = start_dt.strftime("%Y-%m-%d")
|
|
end_date = end_dt.strftime("%Y-%m-%d")
|
|
|
|
safe_query = safe_applescript_string(query.lower())
|
|
script = f'''
|
|
tell application "Calendar"
|
|
set startD to current date
|
|
set year of startD to {start_date[:4]}
|
|
set month of startD to {int(start_date[5:7])}
|
|
set day of startD to {int(start_date[8:10])}
|
|
set hours of startD to 0
|
|
set minutes of startD to 0
|
|
set seconds of startD to 0
|
|
|
|
set endD to current date
|
|
set year of endD to {end_date[:4]}
|
|
set month of endD to {int(end_date[5:7])}
|
|
set day of endD to {int(end_date[8:10])}
|
|
set hours of endD to 23
|
|
set minutes of endD to 59
|
|
set seconds of endD to 59
|
|
|
|
set output to ""
|
|
repeat with c in calendars
|
|
set calName to name of c
|
|
set evts to (every event of c whose start date >= startD and start date <= endD)
|
|
repeat with e in evts
|
|
set eSummary to summary of e
|
|
if eSummary contains "{safe_query}" then
|
|
set eStart to (start date of e) as string
|
|
set eEnd to (end date of e) as string
|
|
set eId to uid of e
|
|
set output to output & calName & ">>>" & eSummary & "|||" & eStart & "|||" & eEnd & "|||" & eId & linefeed
|
|
end if
|
|
end repeat
|
|
end repeat
|
|
return output
|
|
end tell'''
|
|
raw = run_applescript(script, timeout=60)
|
|
results = []
|
|
for line in raw.strip().split("\n"):
|
|
if not line.strip():
|
|
continue
|
|
parts = line.split("|||")
|
|
if len(parts) < 4:
|
|
continue
|
|
name_part = parts[0]
|
|
cal = None
|
|
if ">>>" in name_part:
|
|
cal, name_part = name_part.split(">>>", 1)
|
|
results.append({
|
|
"summary": name_part.strip(),
|
|
"calendar": cal.strip() if cal else None,
|
|
"start": parse_applescript_date(parts[1].strip()),
|
|
"end": parse_applescript_date(parts[2].strip()),
|
|
"id": parts[3].strip(),
|
|
})
|
|
return results
|
|
|
|
|
|
def create_event(summary: str, start_date: str, end_date: str | None = None,
|
|
calendar_name: str | None = None, location: str | None = None,
|
|
notes: str | None = None, all_day: bool = False) -> dict:
|
|
"""Create a calendar event.
|
|
|
|
Args:
|
|
summary: Event title.
|
|
start_date: ISO datetime (e.g., 2026-04-15T10:00:00).
|
|
end_date: ISO datetime. Defaults to 1 hour after start.
|
|
calendar_name: Which calendar. Defaults to first calendar.
|
|
location: Event location.
|
|
notes: Event notes/description.
|
|
all_day: Whether this is an all-day event.
|
|
"""
|
|
if not end_date:
|
|
start_dt = datetime.fromisoformat(start_date)
|
|
end_dt = start_dt + timedelta(hours=1)
|
|
end_date = end_dt.isoformat()
|
|
|
|
start_dt = datetime.fromisoformat(start_date)
|
|
end_dt = datetime.fromisoformat(end_date)
|
|
|
|
safe_summary = safe_applescript_string(summary)
|
|
cal_line = f'set theCal to calendar "{safe_applescript_string(calendar_name)}"' if calendar_name else "set theCal to first calendar"
|
|
|
|
loc_line = ""
|
|
if location:
|
|
loc_line = f'set location of newEvent to "{safe_applescript_string(location)}"'
|
|
|
|
notes_line = ""
|
|
if notes:
|
|
notes_line = f'set description of newEvent to "{safe_applescript_string(notes)}"'
|
|
|
|
script = f'''
|
|
tell application "Calendar"
|
|
{cal_line}
|
|
set startD to current date
|
|
set year of startD to {start_dt.year}
|
|
set month of startD to {start_dt.month}
|
|
set day of startD to {start_dt.day}
|
|
set hours of startD to {start_dt.hour}
|
|
set minutes of startD to {start_dt.minute}
|
|
set seconds of startD to 0
|
|
|
|
set endD to current date
|
|
set year of endD to {end_dt.year}
|
|
set month of endD to {end_dt.month}
|
|
set day of endD to {end_dt.day}
|
|
set hours of endD to {end_dt.hour}
|
|
set minutes of endD to {end_dt.minute}
|
|
set seconds of endD to 0
|
|
|
|
set newEvent to make new event at end of events of theCal with properties {{summary:"{safe_summary}", start date:startD, end date:endD, allday event:{str(all_day).lower()}}}
|
|
{loc_line}
|
|
{notes_line}
|
|
return uid of newEvent
|
|
end tell'''
|
|
event_id = run_applescript(script)
|
|
return {"id": event_id.strip(), "summary": summary, "start": start_date, "end": end_date, "created": True}
|
|
|
|
|
|
def delete_event(event_summary: str, event_date: str | None = None, calendar_name: str | None = None) -> dict:
|
|
"""Delete a calendar event by summary (and optionally date)."""
|
|
safe_summary = safe_applescript_string(event_summary)
|
|
cal_source = f'calendar "{safe_applescript_string(calendar_name)}"' if calendar_name else "first calendar"
|
|
|
|
if event_date:
|
|
dt = datetime.fromisoformat(event_date)
|
|
date_filter = f'''
|
|
set targetD to current date
|
|
set year of targetD to {dt.year}
|
|
set month of targetD to {dt.month}
|
|
set day of targetD to {dt.day}
|
|
set hours of targetD to 0
|
|
set minutes of targetD to 0
|
|
set seconds of targetD to 0
|
|
set nextD to targetD + (1 * days)
|
|
set evts to (every event of theCal whose summary is "{safe_summary}" and start date >= targetD and start date < nextD)'''
|
|
else:
|
|
date_filter = f'set evts to (every event of theCal whose summary is "{safe_summary}")'
|
|
|
|
script = f'''
|
|
tell application "Calendar"
|
|
set theCal to {cal_source}
|
|
{date_filter}
|
|
if (count of evts) > 0 then
|
|
delete item 1 of evts
|
|
return "deleted"
|
|
else
|
|
return "not_found"
|
|
end if
|
|
end tell'''
|
|
result = run_applescript(script)
|
|
if result == "deleted":
|
|
return {"summary": event_summary, "deleted": True}
|
|
raise RuntimeError(f"Event '{event_summary}' not found")
|