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>
This commit is contained in:
Eric Jungbauer
2026-04-15 15:32:24 -06:00
commit 7e619d0454
16 changed files with 2783 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.venv/
__pycache__/
*.pyc
.DS_Store
gui/Apple MCP Config.app/
helpers/reminders_helper
+531
View File
@@ -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()
View File
+287
View File
@@ -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")
+280
View File
@@ -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}
+134
View File
@@ -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
+270
View File
@@ -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}")
+146
View File
@@ -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,
}
+163
View File
@@ -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")
+28
View File
@@ -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"
}
}
}
+494
View File
@@ -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)
}
}
Executable
+82
View File
@@ -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'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Apple MCP Config</string>
<key>CFBundleDisplayName</key>
<string>Apple MCP Config</string>
<key>CFBundleIdentifier</key>
<string>io.jfamily.apple-mcp-config</string>
<key>CFBundleVersion</key>
<string>1.0</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>Apple MCP Config</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<key>NSRemindersUsageDescription</key>
<string>Apple MCP Config needs access to Reminders to provide Claude with reminder data.</string>
</dict>
</plist>
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\""
+95
View File
@@ -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('"', '\\"')
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env swift
// Fast Reminders query via EventKit handles large reminder databases
// Usage: swift reminders_helper.swift <command> [args...]
// lists list all reminder lists
// get [--list NAME] [--overdue] [--due-today] [--due-this-week] [--limit N]
// search <query> [--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)
}
+2
View File
@@ -0,0 +1,2 @@
mcp[cli]>=1.0.0
pydantic>=2.0.0
Executable
+79
View File
@@ -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."