"""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()