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:
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.DS_Store
|
||||
gui/Apple MCP Config.app/
|
||||
helpers/reminders_helper
|
||||
+531
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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('"', '\\"')
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
mcp[cli]>=1.0.0
|
||||
pydantic>=2.0.0
|
||||
@@ -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."
|
||||
Reference in New Issue
Block a user