Files
apple-mcp/apple_mcp.py
T
Eric Jungbauer 7e619d0454 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) <noreply@anthropic.com>
2026-04-15 15:32:24 -06:00

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