commit 7e619d04542f88186599103b76a637adf252a9f1 Author: Eric Jungbauer Date: Wed Apr 15 15:32:24 2026 -0600 Initial commit — Apple Apps MCP server with native macOS config app 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f0c747a --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.venv/ +__pycache__/ +*.pyc +.DS_Store +gui/Apple MCP Config.app/ +helpers/reminders_helper diff --git a/apple_mcp.py b/apple_mcp.py new file mode 100644 index 0000000..a1467c9 --- /dev/null +++ b/apple_mcp.py @@ -0,0 +1,531 @@ +"""Apple Apps MCP Server for Claude Code/CoWork. + +Provides access to Apple Reminders, Calendar, Mail, Contacts, Find My, and Maps +via AppleScript automation. Per-app permissions are controlled by config.json. +""" + +import json +import sys +from pathlib import Path + +# Add project root to path so helpers and apps can be imported +sys.path.insert(0, str(Path(__file__).parent)) + +from mcp.server.fastmcp import FastMCP +from helpers import load_config, is_app_enabled, is_write_allowed + +mcp = FastMCP( + "Apple Apps", + instructions="Access Apple Reminders, Calendar, Mail, Contacts, Find My, and Maps. Per-app permissions are controlled by config.json.", +) + +config = load_config() + + +# ─── Reminders ────────────────────────────────────────────────────────────── + +if is_app_enabled("reminders"): + from apps.reminders import ( + list_lists, get_reminders, search_reminders, + get_overdue_reminders, get_due_today, get_due_this_week, + create_reminder, complete_reminder, delete_reminder, + ) + + @mcp.tool() + def reminders_list_lists() -> list[dict]: + """List all reminder lists (e.g., Reminders, Shopping, Work).""" + return list_lists() + + @mcp.tool() + def reminders_get_reminders( + list_name: str | None = None, + include_completed: bool = False, + ) -> list[dict]: + """Get reminders, optionally filtered by list name. + + Args: + list_name: Filter to a specific list. None = all lists. + include_completed: Include completed reminders. + """ + return get_reminders(list_name=list_name, include_completed=include_completed) + + @mcp.tool() + def reminders_search(query: str) -> list[dict]: + """Search reminders by name across all lists.""" + return search_reminders(query) + + @mcp.tool() + def reminders_overdue() -> dict: + """Get all overdue (past due) incomplete reminders.""" + return get_overdue_reminders() + + @mcp.tool() + def reminders_due_today() -> dict: + """Get reminders due today.""" + return get_due_today() + + @mcp.tool() + def reminders_due_this_week() -> dict: + """Get reminders due in the next 7 days.""" + return get_due_this_week() + + if is_write_allowed("reminders"): + @mcp.tool() + def reminders_create( + name: str, + list_name: str = "Reminders", + due_date: str | None = None, + body: str | None = None, + priority: int = 0, + ) -> dict: + """Create a new reminder. + + Args: + name: Reminder title. + list_name: Which list to add to (default: Reminders). + due_date: Due date as "MM/DD/YYYY HH:MM:SS AM/PM". + body: Optional notes/body text. + priority: 0 (none), 1 (high), 5 (medium), 9 (low). + """ + return create_reminder(name, list_name, due_date, body, priority) + + @mcp.tool() + def reminders_complete(name: str, list_name: str | None = None) -> dict: + """Mark a reminder as completed. + + Args: + name: Exact name of the reminder. + list_name: Optional list to search in. + """ + return complete_reminder(name, list_name) + + @mcp.tool() + def reminders_delete(name: str, list_name: str | None = None) -> dict: + """Delete a reminder by name. + + Args: + name: Exact name of the reminder. + list_name: Optional list to search in. + """ + return delete_reminder(name, list_name) + + +# ─── Calendar ─────────────────────────────────────────────────────────────── + +if is_app_enabled("calendar"): + from apps.calendar import ( + list_calendars, get_events, search_events, + create_event, delete_event, + ) + + @mcp.tool() + def calendar_list_calendars() -> list[dict]: + """List all calendars (Home, Work, Holidays, etc.).""" + return list_calendars() + + @mcp.tool() + def calendar_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: Start date as YYYY-MM-DD (default: today). + end_date: End date as YYYY-MM-DD (default: start + days). + calendar_name: Filter to a specific calendar. + days: Days to look ahead if end_date not specified. + """ + return get_events(start_date, end_date, calendar_name, days) + + @mcp.tool() + def calendar_search_events( + query: str, + days_back: int = 30, + days_forward: int = 30, + ) -> list[dict]: + """Search for events by title/summary. + + Args: + query: Text to search for in event titles. + days_back: How far back to search. + days_forward: How far forward to search. + """ + return search_events(query, days_back, days_forward) + + if is_write_allowed("calendar"): + @mcp.tool() + def calendar_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: Start as ISO datetime (e.g., 2026-04-15T10:00:00). + end_date: End as ISO datetime. Defaults to 1 hour after start. + calendar_name: Which calendar. Defaults to first calendar. + location: Event location. + notes: Event description/notes. + all_day: Whether this is an all-day event. + """ + return create_event(summary, start_date, end_date, calendar_name, location, notes, all_day) + + @mcp.tool() + def calendar_delete_event( + event_summary: str, + event_date: str | None = None, + calendar_name: str | None = None, + ) -> dict: + """Delete a calendar event by title. + + Args: + event_summary: Exact event title. + event_date: Date of the event as YYYY-MM-DD (helps narrow results). + calendar_name: Which calendar to search. + """ + return delete_event(event_summary, event_date, calendar_name) + + +# ─── Mail ─────────────────────────────────────────────────────────────────── + +if is_app_enabled("mail"): + from apps.mail import ( + list_mailboxes, get_messages, get_message_content, search_mail, + send_mail, mark_read, move_message, + ) + + @mcp.tool() + def mail_list_mailboxes() -> list[dict]: + """List all mail accounts and their mailboxes with message counts.""" + return list_mailboxes() + + @mcp.tool() + def mail_get_messages( + mailbox_name: str = "INBOX", + account_name: str | None = None, + count: int = 20, + ) -> list[dict]: + """Get recent messages from a mailbox. + + Args: + mailbox_name: Mailbox name (default: INBOX). + account_name: Specific email account. + count: Number of messages to fetch (most recent first). + """ + return get_messages(mailbox_name, account_name, count) + + @mcp.tool() + def mail_get_message_content( + subject: str, + mailbox_name: str = "INBOX", + account_name: str | None = None, + ) -> dict: + """Get the full content of a specific email message. + + Args: + subject: Subject text to search for (partial match). + mailbox_name: Mailbox to search in. + account_name: Specific email account. + """ + return get_message_content(subject, mailbox_name, account_name) + + @mcp.tool() + def mail_search( + query: str, + mailbox_name: str = "INBOX", + count: int = 20, + ) -> list[dict]: + """Search mail by subject or sender. + + Args: + query: Search text (matches subject or sender). + mailbox_name: Mailbox to search in. + count: Max results. + """ + return search_mail(query, mailbox_name, count) + + if is_write_allowed("mail"): + @mcp.tool() + def mail_send( + to: str, + subject: str, + body: str, + cc: str | None = None, + from_account: str | None = None, + ) -> dict: + """Send an email. + + Args: + to: Recipient email (comma-separated for multiple). + subject: Email subject. + body: Email body text. + cc: CC recipients (comma-separated). + from_account: Sending account name. + """ + return send_mail(to, subject, body, cc, from_account) + + @mcp.tool() + def mail_mark_read( + subject: str, + mailbox_name: str = "INBOX", + read: bool = True, + ) -> dict: + """Mark a message as read or unread. + + Args: + subject: Subject text to find the message. + mailbox_name: Mailbox to search in. + read: True = mark read, False = mark unread. + """ + return mark_read(subject, mailbox_name, read) + + @mcp.tool() + def mail_move_message( + subject: str, + from_mailbox: str, + to_mailbox: str, + account_name: str | None = None, + ) -> dict: + """Move a message to a different mailbox/folder. + + Args: + subject: Subject text to find the message. + from_mailbox: Source mailbox. + to_mailbox: Destination mailbox. + account_name: Email account name. + """ + return move_message(subject, from_mailbox, to_mailbox, account_name) + + +# ─── Contacts ─────────────────────────────────────────────────────────────── + +if is_app_enabled("contacts"): + from apps.contacts import ( + search_contacts, get_contact, list_groups, + create_contact, update_contact, + ) + + @mcp.tool() + def contacts_search(query: str) -> list[dict]: + """Search contacts by name, email, or phone number. + + Args: + query: Search text (matches name or email). + """ + return search_contacts(query) + + @mcp.tool() + def contacts_get_contact(name: str) -> dict: + """Get detailed info for a contact by name. + + Returns emails, phones, addresses, organization, job title, notes. + + Args: + name: Contact name (exact or partial match). + """ + return get_contact(name) + + @mcp.tool() + def contacts_list_groups() -> list[dict]: + """List all contact groups with member counts.""" + return list_groups() + + if is_write_allowed("contacts"): + @mcp.tool() + def contacts_create( + first_name: str, + last_name: str | None = None, + email: str | None = None, + phone: str | None = None, + organization: str | None = None, + job_title: str | None = None, + note: str | None = None, + ) -> dict: + """Create a new contact. + + Args: + first_name: First name. + last_name: Last name. + email: Email address. + phone: Phone number. + organization: Company/organization name. + job_title: Job title. + note: Notes about the contact. + """ + return create_contact(first_name, last_name, email, phone, organization, job_title, note) + + @mcp.tool() + def contacts_update( + name: str, + email: str | None = None, + phone: str | None = None, + organization: str | None = None, + job_title: str | None = None, + note: str | None = None, + ) -> dict: + """Update an existing contact. + + Args: + name: Contact name to find (partial match). + email: New email to add. + phone: New phone to add. + organization: Update organization. + job_title: Update job title. + note: Update notes. + """ + return update_contact(name, email, phone, organization, job_title, note) + + +# ─── Find My ─────────────────────────────────────────────────────────────── + +if is_app_enabled("findmy"): + from apps.findmy import list_devices, get_device_location, list_items + + @mcp.tool() + def findmy_list_devices() -> list[dict]: + """List all Apple devices in Find My with their locations. + + Returns device name, model, battery level, and last known location. + Note: Find My app must have been opened recently for fresh data. + """ + return list_devices() + + @mcp.tool() + def findmy_get_device_location(device_name: str) -> dict: + """Get location of a specific Apple device. + + Args: + device_name: Device name or partial match (e.g., "iPhone", "MacBook"). + """ + return get_device_location(device_name) + + @mcp.tool() + def findmy_list_items() -> list[dict]: + """List all Find My items (AirTags and third-party trackers). + + Returns item name, type, battery status, and last known location. + """ + return list_items() + + +# ─── Maps ─────────────────────────────────────────────────────────────────── + +if is_app_enabled("maps"): + from apps.maps import search_locations, get_directions, open_location, drop_pin + + @mcp.tool() + def maps_search(query: str) -> list[dict]: + """Search for locations using Apple Maps. + + Uses CoreLocation geocoding and opens results in Maps. + + Args: + query: Place name, address, or search query. + """ + return search_locations(query) + + @mcp.tool() + def maps_get_directions( + to_address: str, + from_address: str | None = None, + mode: str = "driving", + ) -> dict: + """Get directions in Apple Maps. + + Args: + to_address: Destination address. + from_address: Starting address (default: current location). + mode: Travel mode — "driving", "walking", or "transit". + """ + return get_directions(from_address, to_address, mode) + + if is_write_allowed("maps"): + @mcp.tool() + def maps_open_location( + address: str | None = None, + latitude: float | None = None, + longitude: float | None = None, + label: str | None = None, + ) -> dict: + """Open a location in Apple Maps. + + Provide either an address or lat/lon coordinates. + + Args: + address: Street address or place name. + latitude: Latitude coordinate. + longitude: Longitude coordinate. + label: Pin label. + """ + return open_location(address, latitude, longitude, label) + + @mcp.tool() + def maps_drop_pin( + latitude: float, + longitude: float, + label: str = "Pin", + ) -> dict: + """Drop a pin at specific coordinates in Apple Maps. + + Args: + latitude: Latitude coordinate. + longitude: Longitude coordinate. + label: Pin label text. + """ + return drop_pin(latitude, longitude, label) + + +# ─── Config Management ───────────────────────────────────────────────────── + +@mcp.tool() +def apple_get_config() -> dict: + """Get the current Apple apps access configuration. + + Shows which apps are enabled and their access mode (read or read-write). + """ + return load_config() + + +@mcp.tool() +def apple_set_config(app_name: str, enabled: bool | None = None, mode: str | None = None) -> dict: + """Update access configuration for an Apple app. + + Changes take effect on next server restart. + + Args: + app_name: App name (reminders, calendar, mail, contacts, findmy, maps). + enabled: Set to true/false to enable/disable the app. + mode: Set to "read" or "read-write". + """ + config_path = Path(__file__).parent / "config.json" + with open(config_path) as f: + cfg = json.load(f) + + if app_name not in cfg.get("apps", {}): + raise RuntimeError(f"Unknown app: {app_name}. Valid: {list(cfg['apps'].keys())}") + + if enabled is not None: + cfg["apps"][app_name]["enabled"] = enabled + if mode is not None: + if mode not in ("read", "read-write"): + raise RuntimeError(f"Invalid mode: {mode}. Use 'read' or 'read-write'.") + cfg["apps"][app_name]["mode"] = mode + + with open(config_path, "w") as f: + json.dump(cfg, f, indent=2) + f.write("\n") + + return {"app": app_name, "config": cfg["apps"][app_name], "note": "Restart server for changes to take effect"} + + +if __name__ == "__main__": + mcp.run() diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/calendar.py b/apps/calendar.py new file mode 100644 index 0000000..d9f2d93 --- /dev/null +++ b/apps/calendar.py @@ -0,0 +1,287 @@ +"""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") diff --git a/apps/contacts.py b/apps/contacts.py new file mode 100644 index 0000000..939d5a9 --- /dev/null +++ b/apps/contacts.py @@ -0,0 +1,280 @@ +"""Apple Contacts tools.""" + +from helpers import run_applescript, safe_applescript_string + + +def search_contacts(query: str) -> list[dict]: + """Search contacts by name, email, or phone.""" + safe_query = safe_applescript_string(query) + script = f''' +tell application "Contacts" + set output to "" + set results to (every person whose name contains "{safe_query}") + repeat with p in results + set pName to name of p + set pId to id of p + set pEmail to "" + try + set pEmail to value of first email of p + end try + set pPhone to "" + try + set pPhone to value of first phone of p + end try + set pOrg to "" + try + set pOrg to organization of p + end try + set output to output & pName & "|||" & pEmail & "|||" & pPhone & "|||" & pOrg & "|||" & pId & linefeed + end repeat + + -- Also search by email + set emailResults to (every person whose value of emails contains "{safe_query}") + repeat with p in emailResults + set pName to name of p + set pId to id of p + if output does not contain pId then + set pEmail to "" + try + set pEmail to value of first email of p + end try + set pPhone to "" + try + set pPhone to value of first phone of p + end try + set pOrg to "" + try + set pOrg to organization of p + end try + set output to output & pName & "|||" & pEmail & "|||" & pPhone & "|||" & pOrg & "|||" & pId & linefeed + end if + end repeat + + return output +end tell''' + raw = run_applescript(script, timeout=30) + results = [] + seen_ids = set() + for line in raw.strip().split("\n"): + if not line.strip(): + continue + parts = line.split("|||") + if len(parts) < 5: + continue + cid = parts[4].strip() + if cid in seen_ids: + continue + seen_ids.add(cid) + results.append({ + "name": parts[0].strip(), + "email": parts[1].strip() or None, + "phone": parts[2].strip() or None, + "organization": parts[3].strip() or None, + "id": cid, + }) + return results + + +def get_contact(name: str) -> dict: + """Get detailed info for a contact by name.""" + safe_name = safe_applescript_string(name) + script = f''' +tell application "Contacts" + set results to (every person whose name is "{safe_name}") + if (count of results) = 0 then + set results to (every person whose name contains "{safe_name}") + end if + if (count of results) = 0 then + return "NOT_FOUND" + end if + set p to item 1 of results + + set pName to name of p + set pFirst to first name of p + set pLast to last name of p + set pId to id of p + + set pOrg to "" + try + set pOrg to organization of p + end try + + set pTitle to "" + try + set pTitle to job title of p + end try + + set pNote to "" + try + set pNote to note of p + end try + + -- Emails + set emailList to "" + repeat with e in emails of p + set emailList to emailList & (label of e) & ":" & (value of e) & "," + end repeat + + -- Phones + set phoneList to "" + repeat with ph in phones of p + set phoneList to phoneList & (label of ph) & ":" & (value of ph) & "," + end repeat + + -- Addresses + set addrList to "" + repeat with a in addresses of p + set addrList to addrList & (label of a) & ":" & (formatted address of a) & ";;;" + end repeat + + return pName & "|||" & pFirst & "|||" & pLast & "|||" & pOrg & "|||" & pTitle & "|||" & pNote & "|||" & emailList & "|||" & phoneList & "|||" & addrList & "|||" & pId +end tell''' + raw = run_applescript(script, timeout=30) + if raw == "NOT_FOUND": + raise RuntimeError(f"Contact '{name}' not found") + + parts = raw.split("|||") + if len(parts) < 10: + raise RuntimeError("Failed to parse contact details") + + emails = [] + for e in parts[6].strip().rstrip(",").split(","): + if ":" in e: + label, value = e.split(":", 1) + emails.append({"label": label.strip(), "value": value.strip()}) + + phones = [] + for ph in parts[7].strip().rstrip(",").split(","): + if ":" in ph: + label, value = ph.split(":", 1) + phones.append({"label": label.strip(), "value": value.strip()}) + + addresses = [] + for a in parts[8].strip().split(";;;"): + if ":" in a: + label, value = a.split(":", 1) + addresses.append({"label": label.strip(), "address": value.strip()}) + + return { + "name": parts[0].strip(), + "first_name": parts[1].strip() or None, + "last_name": parts[2].strip() or None, + "organization": parts[3].strip() or None, + "job_title": parts[4].strip() or None, + "note": parts[5].strip() or None, + "emails": emails, + "phones": phones, + "addresses": addresses, + "id": parts[9].strip(), + } + + +def list_groups() -> list[dict]: + """List all contact groups.""" + script = ''' +tell application "Contacts" + set output to "" + repeat with g in groups + set gName to name of g + set gId to id of g + set gCount to count of people of g + set output to output & gName & "|||" & gId & "|||" & (gCount as string) & 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("|||") + if len(parts) >= 3: + results.append({ + "name": parts[0].strip(), + "id": parts[1].strip(), + "member_count": int(parts[2].strip()) if parts[2].strip().isdigit() else 0, + }) + return results + + +def create_contact(first_name: str, last_name: str | None = None, + email: str | None = None, phone: str | None = None, + organization: str | None = None, job_title: str | None = None, + note: str | None = None) -> dict: + """Create a new contact.""" + safe_first = safe_applescript_string(first_name) + props = [f'first name:"{safe_first}"'] + + if last_name: + props.append(f'last name:"{safe_applescript_string(last_name)}"') + if organization: + props.append(f'organization:"{safe_applescript_string(organization)}"') + if job_title: + props.append(f'job title:"{safe_applescript_string(job_title)}"') + if note: + props.append(f'note:"{safe_applescript_string(note)}"') + + props_str = ", ".join(props) + + email_line = "" + if email: + email_line = f'make new email at end of emails of newPerson with properties {{label:"work", value:"{safe_applescript_string(email)}"}}' + + phone_line = "" + if phone: + phone_line = f'make new phone at end of phones of newPerson with properties {{label:"mobile", value:"{safe_applescript_string(phone)}"}}' + + script = f''' +tell application "Contacts" + set newPerson to make new person with properties {{{props_str}}} + {email_line} + {phone_line} + save + return id of newPerson +end tell''' + contact_id = run_applescript(script) + return { + "id": contact_id.strip(), + "first_name": first_name, + "last_name": last_name, + "created": True, + } + + +def update_contact(name: str, email: str | None = None, phone: str | None = None, + organization: str | None = None, job_title: str | None = None, + note: str | None = None) -> dict: + """Update an existing contact by name.""" + safe_name = safe_applescript_string(name) + updates = [] + if organization is not None: + updates.append(f'set organization of p to "{safe_applescript_string(organization)}"') + if job_title is not None: + updates.append(f'set job title of p to "{safe_applescript_string(job_title)}"') + if note is not None: + updates.append(f'set note of p to "{safe_applescript_string(note)}"') + if email: + updates.append(f'make new email at end of emails of p with properties {{label:"work", value:"{safe_applescript_string(email)}"}}') + if phone: + updates.append(f'make new phone at end of phones of p with properties {{label:"mobile", value:"{safe_applescript_string(phone)}"}}') + + if not updates: + raise RuntimeError("No updates specified") + + updates_str = "\n ".join(updates) + + script = f''' +tell application "Contacts" + set results to (every person whose name contains "{safe_name}") + if (count of results) = 0 then + return "NOT_FOUND" + end if + set p to item 1 of results + {updates_str} + save + return "updated" +end tell''' + result = run_applescript(script) + if result == "NOT_FOUND": + raise RuntimeError(f"Contact '{name}' not found") + return {"name": name, "updated": True} diff --git a/apps/findmy.py b/apps/findmy.py new file mode 100644 index 0000000..e17aded --- /dev/null +++ b/apps/findmy.py @@ -0,0 +1,134 @@ +"""Apple Find My tools — reads cached location data.""" + +import json +import plistlib +from pathlib import Path +from datetime import datetime + + +FINDMY_CACHE = Path.home() / "Library" / "Caches" / "com.apple.findmy.fmipcore" +ITEMS_FILE = FINDMY_CACHE / "Items.data" +DEVICES_FILE = FINDMY_CACHE / "Devices.data" + + +def _load_cache_file(path: Path) -> list[dict]: + """Load a Find My cache file (plist format).""" + if not path.exists(): + raise RuntimeError( + f"Find My cache not found at {path}. " + "Make sure the Find My app has been opened at least once." + ) + try: + f = open(path, "rb") + except PermissionError: + raise RuntimeError( + f"Permission denied reading {path}. " + "Grant Full Disk Access to the terminal/app running the MCP server: " + "System Settings > Privacy & Security > Full Disk Access." + ) + with f: + try: + data = plistlib.load(f) + except Exception: + # Some versions use JSON + f.seek(0) + try: + data = json.load(f) + except Exception: + raise RuntimeError(f"Could not parse Find My cache at {path}") + if isinstance(data, list): + return data + return [] + + +def _format_location(item: dict) -> dict | None: + """Extract location info from a Find My item/device.""" + loc = item.get("location") + if not loc: + return None + result = { + "latitude": loc.get("latitude"), + "longitude": loc.get("longitude"), + "altitude": loc.get("altitude"), + "horizontal_accuracy": loc.get("horizontalAccuracy"), + "is_old": loc.get("isOld", False), + "floor_level": loc.get("floorLevel"), + } + timestamp = loc.get("timeStamp") + if timestamp: + try: + if isinstance(timestamp, (int, float)): + # Apple epoch (2001-01-01) or Unix epoch + if timestamp > 1e15: # nanoseconds + timestamp = timestamp / 1e9 + if timestamp < 1e9: # Apple epoch + timestamp += 978307200 # seconds between 1970 and 2001 + result["timestamp"] = datetime.fromtimestamp(timestamp).isoformat() + else: + result["timestamp"] = str(timestamp) + except Exception: + result["timestamp"] = str(timestamp) + return result + + +def list_devices() -> list[dict]: + """List all Apple devices in Find My.""" + try: + devices = _load_cache_file(DEVICES_FILE) + except RuntimeError as e: + return [{"error": str(e)}] + + results = [] + for dev in devices: + location = _format_location(dev) + results.append({ + "name": dev.get("name", "Unknown"), + "device_model": dev.get("deviceDisplayName", dev.get("deviceModel", "Unknown")), + "battery_level": dev.get("batteryLevel"), + "battery_status": dev.get("batteryStatus"), + "location": location, + "id": dev.get("baUUID", dev.get("id", "")), + }) + return results + + +def get_device_location(device_name: str) -> dict: + """Get location of a specific Apple device.""" + try: + devices = _load_cache_file(DEVICES_FILE) + except RuntimeError as e: + raise RuntimeError(str(e)) + + device_name_lower = device_name.lower() + for dev in devices: + name = dev.get("name", "") + if device_name_lower in name.lower(): + location = _format_location(dev) + return { + "name": name, + "device_model": dev.get("deviceDisplayName", dev.get("deviceModel", "Unknown")), + "battery_level": dev.get("batteryLevel"), + "location": location, + } + raise RuntimeError(f"Device '{device_name}' not found in Find My") + + +def list_items() -> list[dict]: + """List all Find My items (AirTags, third-party trackers).""" + try: + items = _load_cache_file(ITEMS_FILE) + except RuntimeError as e: + return [{"error": str(e)}] + + results = [] + for item in items: + location = _format_location(item) + results.append({ + "name": item.get("name", "Unknown"), + "product_type": item.get("productType", {}).get("type", "Unknown") if isinstance(item.get("productType"), dict) else item.get("productType", "Unknown"), + "serial_number": item.get("serialNumber"), + "battery_status": item.get("batteryStatus"), + "location": location, + "id": item.get("identifier", ""), + }) + return results diff --git a/apps/mail.py b/apps/mail.py new file mode 100644 index 0000000..8995d65 --- /dev/null +++ b/apps/mail.py @@ -0,0 +1,270 @@ +"""Apple Mail tools.""" + +from helpers import run_applescript, safe_applescript_string, parse_applescript_date + + +def list_mailboxes() -> list[dict]: + """List all mail accounts and their mailboxes.""" + script = ''' +tell application "Mail" + set output to "" + repeat with acct in accounts + set acctName to name of acct + repeat with mb in mailboxes of acct + set mbName to name of mb + set msgCount to count of messages of mb + set output to output & acctName & ">>>" & mbName & "|||" & (msgCount as string) & linefeed + end repeat + end repeat + return output +end tell''' + raw = run_applescript(script, timeout=30) + results = [] + for line in raw.strip().split("\n"): + if not line.strip(): + continue + parts = line.split("|||") + if len(parts) < 2: + continue + name_part = parts[0] + account = None + if ">>>" in name_part: + account, name_part = name_part.split(">>>", 1) + results.append({ + "account": account.strip() if account else None, + "mailbox": name_part.strip(), + "message_count": int(parts[1].strip()) if parts[1].strip().isdigit() else 0, + }) + return results + + +def get_messages(mailbox_name: str = "INBOX", account_name: str | None = None, + count: int = 20) -> list[dict]: + """Get recent messages from a mailbox. + + Args: + mailbox_name: Mailbox name (default INBOX). + account_name: Specific account. If None, uses first account. + count: Number of recent messages to fetch. + """ + safe_mb = safe_applescript_string(mailbox_name) + if account_name: + safe_acct = safe_applescript_string(account_name) + mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"' + else: + mb_ref = f'inbox' + if mailbox_name != "INBOX": + mb_ref = f'mailbox "{safe_mb}" of first account' + + script = f''' +tell application "Mail" + set output to "" + set theMB to {mb_ref} + set msgList to messages 1 through {count} of theMB + repeat with m in msgList + set mSubject to subject of m + set mFrom to sender of m + set mDate to (date received of m) as string + set mRead to read status of m as string + set mId to message id of m + set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & "|||" & mId & linefeed + end repeat + return output +end tell''' + raw = run_applescript(script, timeout=30) + results = [] + for line in raw.strip().split("\n"): + if not line.strip(): + continue + parts = line.split("|||") + if len(parts) < 5: + continue + results.append({ + "subject": parts[0].strip(), + "from": parts[1].strip(), + "date": parse_applescript_date(parts[2].strip()), + "read": parts[3].strip() == "true", + "message_id": parts[4].strip(), + }) + return results + + +def get_message_content(subject: str, mailbox_name: str = "INBOX", + account_name: str | None = None) -> dict: + """Get full content of a specific message by subject.""" + safe_subject = safe_applescript_string(subject) + if account_name: + safe_acct = safe_applescript_string(account_name) + safe_mb = safe_applescript_string(mailbox_name) + mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"' + else: + mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account' + + script = f''' +tell application "Mail" + set theMB to {mb_ref} + set msgs to (messages of theMB whose subject contains "{safe_subject}") + if (count of msgs) > 0 then + set m to item 1 of msgs + set mSubject to subject of m + set mFrom to sender of m + set mTo to "" + try + set recipList to to recipients of m + set mTo to "" + repeat with r in recipList + set mTo to mTo & (address of r) & ", " + end repeat + end try + set mDate to (date received of m) as string + set mContent to content of m + set mRead to read status of m as string + return mSubject & "|||" & mFrom & "|||" & mTo & "|||" & mDate & "|||" & mRead & "|||" & mContent + else + return "NOT_FOUND" + end if +end tell''' + raw = run_applescript(script, timeout=30) + if raw == "NOT_FOUND": + raise RuntimeError(f"Message with subject containing '{subject}' not found") + parts = raw.split("|||", 5) + if len(parts) < 6: + raise RuntimeError("Failed to parse message") + return { + "subject": parts[0].strip(), + "from": parts[1].strip(), + "to": parts[2].strip().rstrip(", ") or None, + "date": parse_applescript_date(parts[3].strip()), + "read": parts[4].strip() == "true", + "content": parts[5].strip(), + } + + +def search_mail(query: str, mailbox_name: str = "INBOX", count: int = 20) -> list[dict]: + """Search mail by subject or sender.""" + safe_query = safe_applescript_string(query) + mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account' + + script = f''' +tell application "Mail" + set output to "" + set theMB to {mb_ref} + set msgs to (messages of theMB whose subject contains "{safe_query}" or sender contains "{safe_query}") + set maxCount to {count} + set i to 0 + repeat with m in msgs + if i >= maxCount then exit repeat + set mSubject to subject of m + set mFrom to sender of m + set mDate to (date received of m) as string + set mRead to read status of m as string + set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & linefeed + set i to i + 1 + 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 + results.append({ + "subject": parts[0].strip(), + "from": parts[1].strip(), + "date": parse_applescript_date(parts[2].strip()), + "read": parts[3].strip() == "true", + }) + return results + + +def send_mail(to: str, subject: str, body: str, cc: str | None = None, + from_account: str | None = None) -> dict: + """Send an email.""" + safe_to = safe_applescript_string(to) + safe_subject = safe_applescript_string(subject) + safe_body = safe_applescript_string(body) + + cc_line = "" + if cc: + cc_addrs = [a.strip() for a in cc.split(",")] + cc_lines = "\n".join([f'make new cc recipient at end of cc recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in cc_addrs]) + cc_line = cc_lines + + account_line = "" + if from_account: + account_line = f', sender:"{safe_applescript_string(from_account)}"' + + to_addrs = [a.strip() for a in to.split(",")] + to_lines = "\n".join([f'make new to recipient at end of to recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in to_addrs]) + + script = f''' +tell application "Mail" + set newMsg to make new outgoing message with properties {{subject:"{safe_subject}", content:"{safe_body}", visible:false{account_line}}} + tell newMsg + {to_lines} + {cc_line} + end tell + send newMsg + return "sent" +end tell''' + run_applescript(script, timeout=30) + return {"to": to, "subject": subject, "sent": True} + + +def mark_read(subject: str, mailbox_name: str = "INBOX", read: bool = True) -> dict: + """Mark a message as read or unread.""" + safe_subject = safe_applescript_string(subject) + mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account' + read_val = "true" if read else "false" + + script = f''' +tell application "Mail" + set theMB to {mb_ref} + set msgs to (messages of theMB whose subject contains "{safe_subject}") + if (count of msgs) > 0 then + set read status of item 1 of msgs to {read_val} + return "done" + else + return "not_found" + end if +end tell''' + result = run_applescript(script) + if result == "done": + return {"subject": subject, "read": read} + raise RuntimeError(f"Message '{subject}' not found") + + +def move_message(subject: str, from_mailbox: str, to_mailbox: str, + account_name: str | None = None) -> dict: + """Move a message to a different mailbox.""" + safe_subject = safe_applescript_string(subject) + safe_from = safe_applescript_string(from_mailbox) + safe_to = safe_applescript_string(to_mailbox) + + if account_name: + safe_acct = safe_applescript_string(account_name) + from_ref = f'mailbox "{safe_from}" of account "{safe_acct}"' + to_ref = f'mailbox "{safe_to}" of account "{safe_acct}"' + else: + from_ref = "inbox" if from_mailbox == "INBOX" else f'mailbox "{safe_from}" of first account' + to_ref = f'mailbox "{safe_to}" of first account' + + script = f''' +tell application "Mail" + set fromMB to {from_ref} + set toMB to {to_ref} + set msgs to (messages of fromMB whose subject contains "{safe_subject}") + if (count of msgs) > 0 then + move item 1 of msgs to toMB + return "moved" + else + return "not_found" + end if +end tell''' + result = run_applescript(script) + if result == "moved": + return {"subject": subject, "from": from_mailbox, "to": to_mailbox, "moved": True} + raise RuntimeError(f"Message '{subject}' not found in {from_mailbox}") diff --git a/apps/maps.py b/apps/maps.py new file mode 100644 index 0000000..9d1dcb8 --- /dev/null +++ b/apps/maps.py @@ -0,0 +1,146 @@ +"""Apple Maps tools.""" + +import subprocess +import urllib.parse +from helpers import run_applescript, run_jxa, safe_applescript_string + + +def search_locations(query: str) -> list[dict]: + """Search for locations using Maps.""" + safe_query = safe_applescript_string(query) + + # Use JXA with MapKit-like search via Maps app + script = f''' + var Maps = Application("Maps"); + Maps.activate(); + + // Use URL scheme to search + var app = Application.currentApplication(); + app.includeStandardAdditions = true; + app.openLocation("maps://?q=" + encodeURIComponent("{safe_query}")); + + // Return the query - Maps will show results in the app + "{safe_query}"; + ''' + # Maps doesn't expose search results via AppleScript, so we open the search + # and also try to geocode using CoreLocation via JXA + geocode_script = f''' +ObjC.import("CoreLocation"); +ObjC.import("Foundation"); + +var geocoder = $.CLGeocoder.alloc.init; +var query = "{safe_query}"; +var results = []; +var done = false; + +geocoder.geocodeAddressStringCompletionHandler(query, function(placemarks, error) {{ + if (placemarks && placemarks.count > 0) {{ + for (var i = 0; i < Math.min(placemarks.count, 5); i++) {{ + var pm = placemarks.objectAtIndex(i); + var loc = pm.location; + var name = pm.name ? pm.name.js : query; + var locality = pm.locality ? pm.locality.js : ""; + var admin = pm.administrativeArea ? pm.administrativeArea.js : ""; + var country = pm.country ? pm.country.js : ""; + var postal = pm.postalCode ? pm.postalCode.js : ""; + results.push({{ + name: name, + latitude: loc.coordinate.latitude, + longitude: loc.coordinate.longitude, + locality: locality, + state: admin, + country: country, + postal_code: postal + }}); + }} + }} + done = true; +}}); + +// Wait for geocoding (up to 10 seconds) +var startTime = new Date().getTime(); +while (!done && (new Date().getTime() - startTime) < 10000) {{ + $.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1)); +}} + +JSON.stringify(results); +''' + try: + raw = run_jxa(geocode_script, timeout=15) + import json + locations = json.loads(raw) + return locations + except Exception: + # Fallback: just open in Maps + encoded = urllib.parse.quote(query) + subprocess.run(["open", f"maps://?q={encoded}"], capture_output=True) + return [{"query": query, "opened_in_maps": True, "note": "Search opened in Maps app. CoreLocation geocoding unavailable."}] + + +def get_directions(from_address: str | None = None, to_address: str = "", + mode: str = "driving") -> dict: + """Get directions between two locations. + + Args: + from_address: Starting address. None = current location. + to_address: Destination address. + mode: Travel mode - "driving", "walking", "transit". + """ + mode_map = {"driving": "d", "walking": "w", "transit": "r"} + mode_char = mode_map.get(mode, "d") + + params = {"daddr": to_address, "dirflg": mode_char} + if from_address: + params["saddr"] = from_address + + url = "maps://?" + urllib.parse.urlencode(params) + subprocess.run(["open", url], capture_output=True) + + return { + "from": from_address or "Current Location", + "to": to_address, + "mode": mode, + "opened_in_maps": True, + } + + +def open_location(address: str | None = None, latitude: float | None = None, + longitude: float | None = None, label: str | None = None) -> dict: + """Open a location in Apple Maps. + + Provide either an address string or lat/lon coordinates. + """ + if latitude is not None and longitude is not None: + params = {"ll": f"{latitude},{longitude}"} + if label: + params["q"] = label + url = "maps://?" + urllib.parse.urlencode(params) + elif address: + params = {"address": address} + if label: + params["q"] = label + url = "maps://?" + urllib.parse.urlencode(params) + else: + raise RuntimeError("Provide either address or latitude/longitude") + + subprocess.run(["open", url], capture_output=True) + return { + "address": address, + "latitude": latitude, + "longitude": longitude, + "label": label, + "opened_in_maps": True, + } + + +def drop_pin(latitude: float, longitude: float, label: str = "Pin") -> dict: + """Drop a pin at specific coordinates in Maps.""" + params = {"ll": f"{latitude},{longitude}", "q": label} + url = "maps://?" + urllib.parse.urlencode(params) + subprocess.run(["open", url], capture_output=True) + return { + "latitude": latitude, + "longitude": longitude, + "label": label, + "dropped_pin": True, + } diff --git a/apps/reminders.py b/apps/reminders.py new file mode 100644 index 0000000..bef6044 --- /dev/null +++ b/apps/reminders.py @@ -0,0 +1,163 @@ +"""Apple Reminders tools — uses compiled EventKit helper for speed.""" + +import json +import subprocess +from pathlib import Path + +from helpers import run_applescript, safe_applescript_string + +HELPER_BIN = Path(__file__).parent.parent / "helpers" / "reminders_helper" + + +def _run_helper(*args: str, timeout: int = 15) -> dict | list: + """Run the compiled EventKit reminders helper and return parsed JSON.""" + result = subprocess.run( + [str(HELPER_BIN)] + list(args), + capture_output=True, text=True, timeout=timeout, + ) + if result.returncode != 0: + stderr = result.stderr.strip() + try: + err = json.loads(result.stdout) + raise RuntimeError(err.get("error", stderr)) + except (json.JSONDecodeError, TypeError): + raise RuntimeError(f"Reminders helper error: {stderr or result.stdout}") + return json.loads(result.stdout) + + +def list_lists() -> list[dict]: + """List all reminder lists (e.g., Reminders, Shopping, Work).""" + return _run_helper("lists") + + +def get_reminders(list_name: str | None = None, include_completed: bool = False) -> dict: + """Get reminders, optionally filtered by list name. + + Args: + list_name: Filter to a specific list. None = all lists. + include_completed: Include completed reminders. + """ + args = ["get"] + if list_name: + args += ["--list", list_name] + if not include_completed: + pass # Default is incomplete only + return _run_helper(*args, timeout=30) + + +def search_reminders(query: str) -> dict: + """Search reminders by name across all lists.""" + return _run_helper("search", query, timeout=30) + + +def get_overdue_reminders() -> dict: + """Get all overdue (past due) incomplete reminders.""" + return _run_helper("get", "--overdue") + + +def get_due_today() -> dict: + """Get reminders due today.""" + return _run_helper("get", "--due-today") + + +def get_due_this_week() -> dict: + """Get reminders due in the next 7 days.""" + return _run_helper("get", "--due-this-week") + + +def create_reminder(name: str, list_name: str = "Reminders", due_date: str | None = None, + body: str | None = None, priority: int = 0) -> dict: + """Create a new reminder.""" + safe_name = safe_applescript_string(name) + safe_list = safe_applescript_string(list_name) + + props = [f'name:"{safe_name}"'] + if body: + props.append(f'body:"{safe_applescript_string(body)}"') + if priority > 0: + props.append(f'priority:{priority}') + + props_str = ", ".join(props) + + due_line = "" + if due_date: + due_line = f''' + set due date of newReminder to date "{due_date}"''' + + script = f''' +tell application "Reminders" + set theList to list "{safe_list}" + set newReminder to make new reminder at end of theList with properties {{{props_str}}} + {due_line} + return id of newReminder +end tell''' + reminder_id = run_applescript(script) + return {"id": reminder_id.strip(), "name": name, "list": list_name, "created": True} + + +def complete_reminder(name: str, list_name: str | None = None) -> dict: + """Mark a reminder as completed by name.""" + safe_name = safe_applescript_string(name) + if list_name: + safe_list = safe_applescript_string(list_name) + script = f''' +tell application "Reminders" + set theList to list "{safe_list}" + set theReminders to (every reminder of theList whose name is "{safe_name}" and completed is false) + if (count of theReminders) > 0 then + set completed of item 1 of theReminders to true + return "completed" + else + return "not_found" + end if +end tell''' + else: + script = f''' +tell application "Reminders" + repeat with L in lists + set theReminders to (every reminder of L whose name is "{safe_name}" and completed is false) + if (count of theReminders) > 0 then + set completed of item 1 of theReminders to true + return "completed" + end if + end repeat + return "not_found" +end tell''' + result = run_applescript(script) + if result == "completed": + return {"name": name, "completed": True} + raise RuntimeError(f"Reminder '{name}' not found or already completed") + + +def delete_reminder(name: str, list_name: str | None = None) -> dict: + """Delete a reminder by name.""" + safe_name = safe_applescript_string(name) + if list_name: + safe_list = safe_applescript_string(list_name) + script = f''' +tell application "Reminders" + set theList to list "{safe_list}" + set theReminders to (every reminder of theList whose name is "{safe_name}") + if (count of theReminders) > 0 then + delete item 1 of theReminders + return "deleted" + else + return "not_found" + end if +end tell''' + else: + script = f''' +tell application "Reminders" + repeat with L in lists + set theReminders to (every reminder of L whose name is "{safe_name}") + if (count of theReminders) > 0 then + delete item 1 of theReminders + return "deleted" + end if + end repeat + return "not_found" +end tell''' + result = run_applescript(script) + if result == "deleted": + return {"name": name, "deleted": True} + raise RuntimeError(f"Reminder '{name}' not found") diff --git a/config.json b/config.json new file mode 100644 index 0000000..7ba023c --- /dev/null +++ b/config.json @@ -0,0 +1,28 @@ +{ + "apps" : { + "calendar" : { + "enabled" : true, + "mode" : "read" + }, + "contacts" : { + "enabled" : true, + "mode" : "read" + }, + "findmy" : { + "enabled" : true, + "mode" : "read" + }, + "mail" : { + "enabled" : true, + "mode" : "read" + }, + "maps" : { + "enabled" : true, + "mode" : "read" + }, + "reminders" : { + "enabled" : true, + "mode" : "read" + } + } +} \ No newline at end of file diff --git a/gui/AppleMCPConfig.swift b/gui/AppleMCPConfig.swift new file mode 100644 index 0000000..f486ab0 --- /dev/null +++ b/gui/AppleMCPConfig.swift @@ -0,0 +1,494 @@ +import SwiftUI +import AppKit +import Foundation + +// MARK: - Data Model + +struct AppConfig: Codable, Identifiable { + let id: String + var enabled: Bool + var mode: String + + var isReadWrite: Bool { + get { mode == "read-write" } + set { mode = newValue ? "read-write" : "read" } + } +} + +struct ConfigFile: Codable { + var apps: [String: AppEntry] + + struct AppEntry: Codable { + var enabled: Bool + var mode: String + } +} + +// MARK: - App Metadata + +struct AppInfo { + let key: String + let name: String + let icon: String + let description: String +} + +let appList: [AppInfo] = [ + AppInfo(key: "reminders", name: "Reminders", icon: "checklist", description: "Create, complete, search, and manage reminders"), + AppInfo(key: "calendar", name: "Calendar", icon: "calendar", description: "View, create, and manage calendar events"), + AppInfo(key: "mail", name: "Mail", icon: "envelope", description: "Read, search, send, and organize email"), + AppInfo(key: "contacts", name: "Contacts", icon: "person.crop.circle", description: "Search, view, create, and update contacts"), + AppInfo(key: "findmy", name: "Find My", icon: "location.circle", description: "Locate Apple devices and AirTags"), + AppInfo(key: "maps", name: "Maps", icon: "map", description: "Search locations, get directions, drop pins"), +] + +// MARK: - Path Helpers + +let serverInstallDir: URL = { + let home = FileManager.default.homeDirectoryForCurrentUser + return home + .appendingPathComponent("Library/Mobile Documents/com~apple~CloudDocs/Claude Working Folder/apple-mcp") +}() + +let configPath: URL = serverInstallDir.appendingPathComponent("config.json") +let venvPython: URL = serverInstallDir.appendingPathComponent(".venv/bin/python") +let claudeJsonPath: URL = { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent(".claude.json") +}() + +let claudeDesktopConfigPath: URL = { + let home = FileManager.default.homeDirectoryForCurrentUser + return home.appendingPathComponent("Library/Application Support/Claude/claude_desktop_config.json") +}() + +// MARK: - Config Manager + +class ConfigManager: ObservableObject { + @Published var configs: [String: AppConfig] = [:] + @Published var isInstalled = false + @Published var isInstalling = false + @Published var installLog = "" + @Published var installError: String? = nil + @Published var pythonVersion: String = "Not installed" + + init() { + checkInstallation() + loadConfig() + } + + func checkInstallation() { + let fm = FileManager.default + isInstalled = fm.fileExists(atPath: venvPython.path) + && fm.fileExists(atPath: configPath.path) + + if isInstalled { + let task = Process() + let pipe = Pipe() + task.executableURL = venvPython + task.arguments = ["--version"] + task.standardOutput = pipe + task.standardError = pipe + try? task.run() + task.waitUntilExit() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + pythonVersion = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "Unknown" + } + } + + func loadConfig() { + guard FileManager.default.fileExists(atPath: configPath.path) else { return } + guard let data = try? Data(contentsOf: configPath), + let configFile = try? JSONDecoder().decode(ConfigFile.self, from: data) else { return } + + var result: [String: AppConfig] = [:] + for (key, entry) in configFile.apps { + result[key] = AppConfig(id: key, enabled: entry.enabled, mode: entry.mode) + } + configs = result + } + + func saveConfig() { + var entries: [String: ConfigFile.AppEntry] = [:] + for (key, config) in configs { + entries[key] = ConfigFile.AppEntry(enabled: config.enabled, mode: config.mode) + } + let configFile = ConfigFile(apps: entries) + guard let data = try? JSONEncoder().encode(configFile) else { return } + + // Pretty-print the JSON + guard let json = try? JSONSerialization.jsonObject(with: data), + let pretty = try? JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) else { return } + try? pretty.write(to: configPath) + } + + func toggleEnabled(key: String) { + configs[key]?.enabled.toggle() + saveConfig() + } + + func setMode(key: String, readWrite: Bool) { + configs[key]?.mode = readWrite ? "read-write" : "read" + saveConfig() + } + + func install() { + isInstalling = true + installLog = "" + installError = nil + + DispatchQueue.global(qos: .userInitiated).async { [self] in + do { + try performInstall() + DispatchQueue.main.async { + self.isInstalling = false + self.checkInstallation() + self.loadConfig() + } + } catch { + DispatchQueue.main.async { + self.installError = error.localizedDescription + self.isInstalling = false + } + } + } + } + + private func log(_ msg: String) { + DispatchQueue.main.async { self.installLog += msg + "\n" } + } + + private func performInstall() throws { + let fm = FileManager.default + let home = fm.homeDirectoryForCurrentUser + + // Step 1: Copy server files from bundle Resources if available, or verify they exist + log("Checking server files...") + let serverMainFile = serverInstallDir.appendingPathComponent("apple_mcp.py") + if !fm.fileExists(atPath: serverMainFile.path) { + // Try to copy from app bundle + if let bundleServerDir = Bundle.main.resourceURL?.appendingPathComponent("server") { + if fm.fileExists(atPath: bundleServerDir.path) { + log("Copying server files from app bundle...") + try? fm.createDirectory(at: serverInstallDir, withIntermediateDirectories: true) + let items = try fm.contentsOfDirectory(at: bundleServerDir, includingPropertiesForKeys: nil) + for item in items { + let dest = serverInstallDir.appendingPathComponent(item.lastPathComponent) + if fm.fileExists(atPath: dest.path) { + try fm.removeItem(at: dest) + } + try fm.copyItem(at: item, to: dest) + } + log("Server files copied.") + } else { + throw NSError(domain: "", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Server files not found. Place the apple-mcp project at:\n\(serverInstallDir.path)" + ]) + } + } else { + throw NSError(domain: "", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Server files not found at \(serverInstallDir.path) and not bundled in app." + ]) + } + } else { + log("Server files found at \(serverInstallDir.path)") + } + + // Step 2: Install uv if needed + let uvPath = home.appendingPathComponent(".local/bin/uv") + if !fm.fileExists(atPath: uvPath.path) { + log("Installing uv...") + try runShell("curl -LsSf https://astral.sh/uv/install.sh | sh") + log("uv installed.") + } else { + log("uv already installed.") + } + + // Step 3: Create venv with Python 3.13 + let venvDir = serverInstallDir.appendingPathComponent(".venv") + if !fm.fileExists(atPath: venvDir.path) { + log("Creating Python 3.13 virtual environment...") + try runShell("export PATH=\"$HOME/.local/bin:$PATH\" && cd \"\(serverInstallDir.path)\" && uv venv --python 3.13 .venv") + log("Virtual environment created.") + } else { + log("Virtual environment already exists.") + } + + // Step 4: Install dependencies + log("Installing dependencies...") + try runShell("export PATH=\"$HOME/.local/bin:$PATH\" && cd \"\(serverInstallDir.path)\" && uv pip install -r requirements.txt") + log("Dependencies installed.") + + // Step 5: Create default config.json if missing + if !fm.fileExists(atPath: configPath.path) { + log("Creating default config.json...") + let defaultConfig = """ + { + "apps": { + "reminders": { "enabled": true, "mode": "read-write" }, + "calendar": { "enabled": true, "mode": "read-write" }, + "mail": { "enabled": true, "mode": "read" }, + "contacts": { "enabled": true, "mode": "read" }, + "findmy": { "enabled": true, "mode": "read" }, + "maps": { "enabled": true, "mode": "read" } + } + } + """ + try defaultConfig.write(to: configPath, atomically: true, encoding: .utf8) + log("Default config created.") + } + + // Step 6: Compile EventKit reminders helper + let helperDir = serverInstallDir.appendingPathComponent("helpers") + let helperSwift = helperDir.appendingPathComponent("reminders_helper.swift") + let helperBin = helperDir.appendingPathComponent("reminders_helper") + if fm.fileExists(atPath: helperSwift.path) && !fm.fileExists(atPath: helperBin.path) { + log("Compiling EventKit reminders helper...") + try runShell("swiftc \"\(helperSwift.path)\" -o \"\(helperBin.path)\" -framework EventKit -target arm64-apple-macosx14.0 -Osize") + log("Reminders helper compiled.") + } else if fm.fileExists(atPath: helperBin.path) { + log("Reminders helper already compiled.") + } + + // Step 7: Register in Claude Code (~/.claude.json) + log("Registering MCP server in Claude Code...") + try registerMCPServer(at: claudeJsonPath, label: "~/.claude.json") + + // Step 8: Register in Claude Desktop / CoWork + log("Registering MCP server in Claude Desktop / CoWork...") + try registerMCPServer(at: claudeDesktopConfigPath, label: "claude_desktop_config.json") + + log("") + log("Setup complete! Restart Claude Code and/or the Claude Desktop App to activate.") + } + + private func registerMCPServer(at configURL: URL, label: String) throws { + let fm = FileManager.default + let serverEntry: [String: Any] = [ + "command": venvPython.path, + "args": [serverInstallDir.appendingPathComponent("apple_mcp.py").path] + ] + + if fm.fileExists(atPath: configURL.path) { + let data = try Data(contentsOf: configURL) + if var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { + var mcpServers = json["mcpServers"] as? [String: Any] ?? [:] + mcpServers["apple-apps"] = serverEntry + json["mcpServers"] = mcpServers + let updated = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try updated.write(to: configURL) + log(" Registered in \(label)") + } + } else { + // Create parent directory if needed + try? fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + let json: [String: Any] = ["mcpServers": ["apple-apps": serverEntry]] + let data = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) + try data.write(to: configURL) + log(" Created \(label) with MCP server registration.") + } + } + + @discardableResult + private func runShell(_ command: String) throws -> String { + let task = Process() + let pipe = Pipe() + task.executableURL = URL(fileURLWithPath: "/bin/zsh") + task.arguments = ["-c", command] + task.standardOutput = pipe + task.standardError = pipe + task.environment = ProcessInfo.processInfo.environment + try task.run() + task.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + + if task.terminationStatus != 0 { + throw NSError(domain: "", code: Int(task.terminationStatus), userInfo: [ + NSLocalizedDescriptionKey: "Command failed: \(command)\n\(output)" + ]) + } + return output + } +} + +// MARK: - Views + +struct AppRowView: View { + let info: AppInfo + @ObservedObject var manager: ConfigManager + + var config: AppConfig? { manager.configs[info.key] } + + var body: some View { + HStack(spacing: 12) { + Image(systemName: info.icon) + .font(.title2) + .foregroundColor(config?.enabled == true ? .accentColor : .secondary) + .frame(width: 28) + + VStack(alignment: .leading, spacing: 2) { + Text(info.name) + .font(.headline) + Text(info.description) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if let config = config { + Picker("", selection: Binding( + get: { config.mode }, + set: { manager.setMode(key: info.key, readWrite: $0 == "read-write") } + )) { + Text("Read").tag("read") + Text("Read & Write").tag("read-write") + } + .pickerStyle(.segmented) + .frame(width: 160) + .disabled(!config.enabled) + .opacity(config.enabled ? 1.0 : 0.4) + + Toggle("", isOn: Binding( + get: { config.enabled }, + set: { _ in manager.toggleEnabled(key: info.key) } + )) + .toggleStyle(.switch) + .labelsHidden() + } + } + .padding(.vertical, 6) + } +} + +struct SetupStatusView: View { + @ObservedObject var manager: ConfigManager + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + HStack { + Image(systemName: manager.isInstalled ? "checkmark.circle.fill" : "xmark.circle.fill") + .foregroundColor(manager.isInstalled ? .green : .red) + .font(.title2) + VStack(alignment: .leading) { + Text(manager.isInstalled ? "Server Installed" : "Server Not Installed") + .font(.headline) + if manager.isInstalled { + Text(manager.pythonVersion) + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + Button(manager.isInstalled ? "Reinstall" : "Install") { + manager.install() + } + .disabled(manager.isInstalling) + .buttonStyle(.borderedProminent) + .tint(manager.isInstalled ? .secondary : .accentColor) + } + + if manager.isInstalling { + ProgressView() + .progressViewStyle(.linear) + } + + if !manager.installLog.isEmpty { + ScrollView { + Text(manager.installLog) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .textSelection(.enabled) + } + .frame(maxHeight: 150) + .background(Color(nsColor: .textBackgroundColor)) + .cornerRadius(6) + } + + if let error = manager.installError { + Text(error) + .font(.caption) + .foregroundColor(.red) + } + } + .padding(4) + } label: { + Label("Setup", systemImage: "gear") + } + } +} + +struct ContentView: View { + @StateObject private var manager = ConfigManager() + + var body: some View { + VStack(spacing: 0) { + // Header + HStack { + Image(systemName: "apple.logo") + .font(.largeTitle) + .foregroundColor(.accentColor) + VStack(alignment: .leading) { + Text("Apple Apps MCP") + .font(.title2.bold()) + Text("Configure Claude's access to Apple apps") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + } + .padding() + + Divider() + + ScrollView { + VStack(spacing: 16) { + // Setup section + SetupStatusView(manager: manager) + + // App permissions section + GroupBox { + VStack(spacing: 0) { + ForEach(Array(appList.enumerated()), id: \.element.key) { index, info in + AppRowView(info: info, manager: manager) + if index < appList.count - 1 { + Divider() + } + } + } + .padding(4) + } label: { + Label("App Permissions", systemImage: "lock.shield") + } + + // Footer note + HStack { + Image(systemName: "info.circle") + .foregroundColor(.secondary) + Text("Changes are saved automatically. Restart Claude Code for permission changes to take effect.") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal) + } + .padding() + } + } + .frame(width: 580, height: 620) + } +} + +// MARK: - App Entry Point + +@main +struct AppleMCPConfigApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + .windowResizability(.contentSize) + } +} diff --git a/gui/build.sh b/gui/build.sh new file mode 100755 index 0000000..8ef7a1b --- /dev/null +++ b/gui/build.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Build the Apple MCP Config .app bundle +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="Apple MCP Config" +APP_BUNDLE="$SCRIPT_DIR/$APP_NAME.app" +SERVER_DIR="$(dirname "$SCRIPT_DIR")" + +echo "=== Building $APP_NAME ===" + +# Clean previous build +rm -rf "$APP_BUNDLE" + +# Create .app bundle structure +mkdir -p "$APP_BUNDLE/Contents/MacOS" +mkdir -p "$APP_BUNDLE/Contents/Resources/server/apps" +mkdir -p "$APP_BUNDLE/Contents/Resources/server/helpers" + +# Compile Swift +echo "Compiling SwiftUI app..." +swiftc "$SCRIPT_DIR/AppleMCPConfig.swift" \ + -o "$APP_BUNDLE/Contents/MacOS/$APP_NAME" \ + -target arm64-apple-macosx14.0 \ + -framework SwiftUI \ + -framework AppKit \ + -parse-as-library \ + -Osize + +# Create Info.plist +cat > "$APP_BUNDLE/Contents/Info.plist" << 'PLIST' + + + + + CFBundleName + Apple MCP Config + CFBundleDisplayName + Apple MCP Config + CFBundleIdentifier + io.jfamily.apple-mcp-config + CFBundleVersion + 1.0 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + APPL + CFBundleExecutable + Apple MCP Config + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.utilities + NSRemindersUsageDescription + Apple MCP Config needs access to Reminders to provide Claude with reminder data. + + +PLIST + +# Bundle server files into Resources +echo "Bundling server files..." +cp "$SERVER_DIR/apple_mcp.py" "$APP_BUNDLE/Contents/Resources/server/" +cp "$SERVER_DIR/helpers.py" "$APP_BUNDLE/Contents/Resources/server/" +cp "$SERVER_DIR/requirements.txt" "$APP_BUNDLE/Contents/Resources/server/" +cp "$SERVER_DIR/config.json" "$APP_BUNDLE/Contents/Resources/server/" +cp "$SERVER_DIR/apps/"*.py "$APP_BUNDLE/Contents/Resources/server/apps/" + +# Bundle helpers (Swift source — compiled during install) +cp "$SERVER_DIR/helpers/reminders_helper.swift" "$APP_BUNDLE/Contents/Resources/server/helpers/" +# Also include pre-compiled binary if it exists +if [ -f "$SERVER_DIR/helpers/reminders_helper" ]; then + cp "$SERVER_DIR/helpers/reminders_helper" "$APP_BUNDLE/Contents/Resources/server/helpers/" +fi + +echo "" +echo "=== Build Complete ===" +echo "App: $APP_BUNDLE" +echo "" +echo "To use: double-click '$APP_NAME.app' or run:" +echo " open \"$APP_BUNDLE\"" diff --git a/helpers.py b/helpers.py new file mode 100644 index 0000000..ce8897b --- /dev/null +++ b/helpers.py @@ -0,0 +1,95 @@ +"""Shared utilities for Apple MCP server.""" + +import json +import subprocess +from datetime import datetime, date +from pathlib import Path +from typing import Any + + +CONFIG_PATH = Path(__file__).parent / "config.json" + + +def load_config() -> dict: + """Load the app permissions config.""" + with open(CONFIG_PATH) as f: + return json.load(f) + + +def is_app_enabled(app_name: str) -> bool: + """Check if an app is enabled in config.""" + cfg = load_config() + app = cfg.get("apps", {}).get(app_name, {}) + return app.get("enabled", False) + + +def is_write_allowed(app_name: str) -> bool: + """Check if write access is allowed for an app.""" + cfg = load_config() + app = cfg.get("apps", {}).get(app_name, {}) + return app.get("enabled", False) and app.get("mode") == "read-write" + + +def run_applescript(script: str, timeout: int = 30) -> str: + """Run an AppleScript and return stdout. + + Raises RuntimeError on failure. + """ + result = subprocess.run( + ["osascript", "-e", script], + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError(f"AppleScript error: {result.stderr.strip()}") + return result.stdout.strip() + + +def run_jxa(script: str, timeout: int = 30) -> str: + """Run a JXA (JavaScript for Automation) script and return stdout.""" + result = subprocess.run( + ["osascript", "-l", "JavaScript", "-e", script], + capture_output=True, + text=True, + timeout=timeout, + ) + if result.returncode != 0: + raise RuntimeError(f"JXA error: {result.stderr.strip()}") + return result.stdout.strip() + + +def parse_applescript_date(date_str: str) -> str | None: + """Try to parse a macOS AppleScript date string into ISO format.""" + if not date_str or date_str == "missing value": + return None + formats = [ + "%A, %B %d, %Y at %I:%M:%S %p", + "%A, %B %d, %Y at %H:%M:%S", + "%m/%d/%Y %I:%M:%S %p", + "%m/%d/%Y %H:%M:%S", + "%Y-%m-%d %H:%M:%S %z", + ] + for fmt in formats: + try: + dt = datetime.strptime(date_str, fmt) + return dt.isoformat() + except ValueError: + continue + return date_str + + +def format_date_for_applescript(iso_date: str) -> str: + """Convert ISO date string to AppleScript date format.""" + dt = datetime.fromisoformat(iso_date) + return dt.strftime("%m/%d/%Y %I:%M:%S %p") + + +def today_str() -> str: + """Return today's date as YYYY-MM-DD.""" + return date.today().isoformat() + + +def safe_applescript_string(s: str) -> str: + """Escape a string for safe embedding in AppleScript.""" + return s.replace("\\", "\\\\").replace('"', '\\"') diff --git a/helpers/reminders_helper.swift b/helpers/reminders_helper.swift new file mode 100644 index 0000000..1a00fa9 --- /dev/null +++ b/helpers/reminders_helper.swift @@ -0,0 +1,186 @@ +#!/usr/bin/env swift +// Fast Reminders query via EventKit — handles large reminder databases +// Usage: swift reminders_helper.swift [args...] +// lists — list all reminder lists +// get [--list NAME] [--overdue] [--due-today] [--due-this-week] [--limit N] +// search [--limit N] + +import EventKit +import Foundation + +let store = EKEventStore() +let semaphore = DispatchSemaphore(value: 0) + +// Request access +var accessGranted = false +if #available(macOS 14.0, *) { + store.requestFullAccessToReminders { granted, error in + accessGranted = granted + semaphore.signal() + } +} else { + store.requestAccess(to: .reminder) { granted, error in + accessGranted = granted + semaphore.signal() + } +} +semaphore.wait() + +guard accessGranted else { + let err: [String: Any] = ["error": "Reminders access denied. Grant permission in System Settings > Privacy & Security > Reminders."] + print(String(data: try! JSONSerialization.data(withJSONObject: err), encoding: .utf8)!) + exit(1) +} + +let args = CommandLine.arguments +let command = args.count > 1 ? args[1] : "lists" + +func jsonDate(_ date: Date?) -> Any { + guard let d = date else { return NSNull() } + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f.string(from: d) +} + +func printJSON(_ obj: Any) { + let data = try! JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]) + print(String(data: data, encoding: .utf8)!) +} + +func getCalendars() -> [EKCalendar] { + return store.calendars(for: .reminder) +} + +func argValue(_ flag: String) -> String? { + if let idx = args.firstIndex(of: flag), idx + 1 < args.count { + return args[idx + 1] + } + return nil +} + +func hasFlag(_ flag: String) -> Bool { + return args.contains(flag) +} + +switch command { +case "lists": + let cals = getCalendars() + let result = cals.map { cal -> [String: Any] in + // Count incomplete reminders using a predicate + let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: [cal]) + var count = 0 + let countSem = DispatchSemaphore(value: 0) + store.fetchReminders(matching: pred) { reminders in + count = reminders?.count ?? 0 + countSem.signal() + } + countSem.wait() + return ["name": cal.title, "id": cal.calendarIdentifier, "incomplete_count": count] + } + printJSON(result) + +case "get": + let listName = argValue("--list") + let isOverdue = hasFlag("--overdue") + let isDueToday = hasFlag("--due-today") + let isDueThisWeek = hasFlag("--due-this-week") + let limit = Int(argValue("--limit") ?? "50") ?? 50 + + var calendars: [EKCalendar]? = nil + if let name = listName { + calendars = getCalendars().filter { $0.title.lowercased() == name.lowercased() } + if calendars?.isEmpty == true { calendars = nil } + } + + let now = Date() + let calendar = Calendar.current + var startDate: Date? = nil + var endDate: Date? = nil + + if isOverdue { + // Reminders due before now + endDate = now + startDate = calendar.date(byAdding: .year, value: -10, to: now) + } else if isDueToday { + startDate = calendar.startOfDay(for: now) + endDate = calendar.date(byAdding: .day, value: 1, to: startDate!) + } else if isDueThisWeek { + startDate = calendar.startOfDay(for: now) + endDate = calendar.date(byAdding: .day, value: 7, to: startDate!) + } + + let pred = store.predicateForIncompleteReminders( + withDueDateStarting: startDate, + ending: endDate, + calendars: calendars + ) + + let fetchSem = DispatchSemaphore(value: 0) + var fetchedReminders: [EKReminder] = [] + store.fetchReminders(matching: pred) { reminders in + fetchedReminders = reminders ?? [] + fetchSem.signal() + } + fetchSem.wait() + + // Sort by due date (overdue first, then soonest) + fetchedReminders.sort { a, b in + let da = a.dueDateComponents?.date ?? Date.distantFuture + let db = b.dueDateComponents?.date ?? Date.distantFuture + return da < db + } + + let limited = Array(fetchedReminders.prefix(limit)) + let result = limited.map { r -> [String: Any] in + let dueComps = r.dueDateComponents + let dueDate: Date? = dueComps?.date + return [ + "name": r.title ?? "Untitled", + "list": r.calendar.title, + "due_date": jsonDate(dueDate), + "priority": r.priority, + "completed": r.isCompleted, + "has_notes": (r.notes != nil && !r.notes!.isEmpty), + "id": r.calendarItemIdentifier + ] + } + printJSON(["count": fetchedReminders.count, "showing": limited.count, "reminders": result]) + +case "search": + let query = args.count > 2 ? args[2].lowercased() : "" + let limit = Int(argValue("--limit") ?? "50") ?? 50 + + guard !query.isEmpty else { + printJSON(["error": "Search query required"]) + exit(1) + } + + let pred = store.predicateForIncompleteReminders(withDueDateStarting: nil, ending: nil, calendars: nil) + let fetchSem = DispatchSemaphore(value: 0) + var fetchedReminders: [EKReminder] = [] + store.fetchReminders(matching: pred) { reminders in + fetchedReminders = (reminders ?? []).filter { + ($0.title ?? "").lowercased().contains(query) + } + fetchSem.signal() + } + fetchSem.wait() + + let limited = Array(fetchedReminders.prefix(limit)) + let result = limited.map { r -> [String: Any] in + let dueComps = r.dueDateComponents + let dueDate: Date? = dueComps?.date + return [ + "name": r.title ?? "Untitled", + "list": r.calendar.title, + "due_date": jsonDate(dueDate), + "completed": r.isCompleted, + "id": r.calendarItemIdentifier + ] + } + printJSON(["count": fetchedReminders.count, "showing": limited.count, "reminders": result]) + +default: + printJSON(["error": "Unknown command: \(command). Use: lists, get, search"]) + exit(1) +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb991ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +mcp[cli]>=1.0.0 +pydantic>=2.0.0 diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..c20de27 --- /dev/null +++ b/setup.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Apple MCP Server — Setup Script +# Creates venv via uv, installs deps, registers in ~/.claude.json + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +CLAUDE_JSON="$HOME/.claude.json" +export PATH="$HOME/.local/bin:$PATH" + +echo "=== Apple MCP Server Setup ===" +echo "" + +# 0. Check for uv +if ! command -v uv &>/dev/null; then + echo "Installing uv (Python package manager)..." + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$HOME/.local/bin:$PATH" +fi + +# 1. Create virtual environment with Python 3.13 +echo "[1/3] Creating Python 3.13 virtual environment..." +if [ ! -d "$SCRIPT_DIR/.venv" ]; then + uv venv --python 3.13 "$SCRIPT_DIR/.venv" + echo " Created .venv with Python 3.13" +else + echo " .venv already exists, skipping" +fi + +# 2. Install dependencies +echo "[2/3] Installing dependencies..." +cd "$SCRIPT_DIR" +uv pip install -r requirements.txt +echo " Installed mcp[cli] and pydantic" + +# 3. Register in ~/.claude.json (user-scope MCP server) +echo "[3/3] Registering MCP server in Claude Code..." + +PYTHON_PATH="$SCRIPT_DIR/.venv/bin/python" +SERVER_PATH="$SCRIPT_DIR/apple_mcp.py" + +"$PYTHON_PATH" << PYEOF +import json +from pathlib import Path + +claude_json = Path("$CLAUDE_JSON") +data = {} +if claude_json.exists(): + with open(claude_json) as f: + data = json.load(f) + +if "mcpServers" not in data: + data["mcpServers"] = {} + +data["mcpServers"]["apple-apps"] = { + "command": "$PYTHON_PATH", + "args": ["$SERVER_PATH"] +} + +with open(claude_json, "w") as f: + json.dump(data, f, indent=2) + f.write("\n") + +print(" Registered 'apple-apps' in ~/.claude.json") +PYEOF + +echo "" +echo "=== Setup Complete ===" +echo "" +echo "Config file: $SCRIPT_DIR/config.json" +echo " Edit this to control which apps Claude can access and read/write permissions." +echo "" +echo "Current config:" +cat "$SCRIPT_DIR/config.json" +echo "" +echo "Restart Claude Code for the MCP server to start." +echo "" +echo "NOTE: On first use, macOS will ask for permission to access Reminders," +echo "Calendar, Mail, Contacts, etc. Click 'OK' to grant access."