7e619d0454
Python MCP server (FastMCP) providing Claude Code/CoWork access to Apple Reminders, Calendar, Mail, Contacts, Find My, and Maps via AppleScript. Includes a native SwiftUI config/installer app and a compiled EventKit helper for fast reminder queries on large databases (8,000+ items). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
532 lines
18 KiB
Python
532 lines
18 KiB
Python
"""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()
|