Initial commit — Apple Apps MCP server with native macOS config app

Python MCP server (FastMCP) providing Claude Code/CoWork access to Apple
Reminders, Calendar, Mail, Contacts, Find My, and Maps via AppleScript.
Includes a native SwiftUI config/installer app and a compiled EventKit
helper for fast reminder queries on large databases (8,000+ items).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eric Jungbauer
2026-04-15 15:32:24 -06:00
commit 7e619d0454
16 changed files with 2783 additions and 0 deletions
View File
+287
View File
@@ -0,0 +1,287 @@
"""Apple Calendar tools."""
from datetime import datetime, timedelta
from helpers import run_applescript, safe_applescript_string, parse_applescript_date, today_str
def list_calendars() -> list[dict]:
"""List all calendars."""
script = '''
tell application "Calendar"
set output to ""
repeat with c in calendars
set cName to name of c
set cDesc to ""
try
set cDesc to description of c
end try
set output to output & cName & "|||" & cDesc & linefeed
end repeat
return output
end tell'''
raw = run_applescript(script)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
results.append({
"name": parts[0].strip(),
"description": parts[1].strip() if len(parts) > 1 else "",
})
return results
def get_events(start_date: str | None = None, end_date: str | None = None,
calendar_name: str | None = None, days: int = 7) -> list[dict]:
"""Get calendar events in a date range.
Args:
start_date: ISO date (YYYY-MM-DD). Defaults to today.
end_date: ISO date (YYYY-MM-DD). Defaults to start_date + days.
calendar_name: Filter to a specific calendar.
days: Number of days to look ahead (used if end_date not provided).
"""
if not start_date:
start_date = today_str()
if not end_date:
start_dt = datetime.fromisoformat(start_date)
end_dt = start_dt + timedelta(days=days)
end_date = end_dt.strftime("%Y-%m-%d")
cal_filter = ""
if calendar_name:
safe_cal = safe_applescript_string(calendar_name)
cal_filter = f'set cals to {{calendar "{safe_cal}"}}'
else:
cal_filter = "set cals to calendars"
script = f'''
tell application "Calendar"
{cal_filter}
set startD to current date
set year of startD to {start_date[:4]}
set month of startD to {int(start_date[5:7])}
set day of startD to {int(start_date[8:10])}
set hours of startD to 0
set minutes of startD to 0
set seconds of startD to 0
set endD to current date
set year of endD to {end_date[:4]}
set month of endD to {int(end_date[5:7])}
set day of endD to {int(end_date[8:10])}
set hours of endD to 23
set minutes of endD to 59
set seconds of endD to 59
set output to ""
repeat with c in cals
set calName to name of c
set evts to (every event of c whose start date >= startD and start date <= endD)
repeat with e in evts
set eSummary to summary of e
set eStart to (start date of e) as string
set eEnd to (end date of e) as string
set eLoc to ""
try
set eLoc to location of e
end try
set eAllDay to allday event of e as string
set eNotes to ""
try
set eNotes to description of e
on error
set eNotes to ""
end try
set eId to uid of e
set output to output & calName & ">>>" & eSummary & "|||" & eStart & "|||" & eEnd & "|||" & eLoc & "|||" & eAllDay & "|||" & eNotes & "|||" & eId & linefeed
end repeat
end repeat
return output
end tell'''
raw = run_applescript(script, timeout=60)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) < 7:
continue
name_part = parts[0]
cal = None
if ">>>" in name_part:
cal, name_part = name_part.split(">>>", 1)
results.append({
"summary": name_part.strip(),
"calendar": cal.strip() if cal else None,
"start": parse_applescript_date(parts[1].strip()),
"end": parse_applescript_date(parts[2].strip()),
"location": parts[3].strip() or None,
"all_day": parts[4].strip() == "true",
"notes": parts[5].strip() or None,
"id": parts[6].strip(),
})
return results
def search_events(query: str, days_back: int = 30, days_forward: int = 30) -> list[dict]:
"""Search for events by summary text."""
start_dt = datetime.now() - timedelta(days=days_back)
end_dt = datetime.now() + timedelta(days=days_forward)
start_date = start_dt.strftime("%Y-%m-%d")
end_date = end_dt.strftime("%Y-%m-%d")
safe_query = safe_applescript_string(query.lower())
script = f'''
tell application "Calendar"
set startD to current date
set year of startD to {start_date[:4]}
set month of startD to {int(start_date[5:7])}
set day of startD to {int(start_date[8:10])}
set hours of startD to 0
set minutes of startD to 0
set seconds of startD to 0
set endD to current date
set year of endD to {end_date[:4]}
set month of endD to {int(end_date[5:7])}
set day of endD to {int(end_date[8:10])}
set hours of endD to 23
set minutes of endD to 59
set seconds of endD to 59
set output to ""
repeat with c in calendars
set calName to name of c
set evts to (every event of c whose start date >= startD and start date <= endD)
repeat with e in evts
set eSummary to summary of e
if eSummary contains "{safe_query}" then
set eStart to (start date of e) as string
set eEnd to (end date of e) as string
set eId to uid of e
set output to output & calName & ">>>" & eSummary & "|||" & eStart & "|||" & eEnd & "|||" & eId & linefeed
end if
end repeat
end repeat
return output
end tell'''
raw = run_applescript(script, timeout=60)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) < 4:
continue
name_part = parts[0]
cal = None
if ">>>" in name_part:
cal, name_part = name_part.split(">>>", 1)
results.append({
"summary": name_part.strip(),
"calendar": cal.strip() if cal else None,
"start": parse_applescript_date(parts[1].strip()),
"end": parse_applescript_date(parts[2].strip()),
"id": parts[3].strip(),
})
return results
def create_event(summary: str, start_date: str, end_date: str | None = None,
calendar_name: str | None = None, location: str | None = None,
notes: str | None = None, all_day: bool = False) -> dict:
"""Create a calendar event.
Args:
summary: Event title.
start_date: ISO datetime (e.g., 2026-04-15T10:00:00).
end_date: ISO datetime. Defaults to 1 hour after start.
calendar_name: Which calendar. Defaults to first calendar.
location: Event location.
notes: Event notes/description.
all_day: Whether this is an all-day event.
"""
if not end_date:
start_dt = datetime.fromisoformat(start_date)
end_dt = start_dt + timedelta(hours=1)
end_date = end_dt.isoformat()
start_dt = datetime.fromisoformat(start_date)
end_dt = datetime.fromisoformat(end_date)
safe_summary = safe_applescript_string(summary)
cal_line = f'set theCal to calendar "{safe_applescript_string(calendar_name)}"' if calendar_name else "set theCal to first calendar"
loc_line = ""
if location:
loc_line = f'set location of newEvent to "{safe_applescript_string(location)}"'
notes_line = ""
if notes:
notes_line = f'set description of newEvent to "{safe_applescript_string(notes)}"'
script = f'''
tell application "Calendar"
{cal_line}
set startD to current date
set year of startD to {start_dt.year}
set month of startD to {start_dt.month}
set day of startD to {start_dt.day}
set hours of startD to {start_dt.hour}
set minutes of startD to {start_dt.minute}
set seconds of startD to 0
set endD to current date
set year of endD to {end_dt.year}
set month of endD to {end_dt.month}
set day of endD to {end_dt.day}
set hours of endD to {end_dt.hour}
set minutes of endD to {end_dt.minute}
set seconds of endD to 0
set newEvent to make new event at end of events of theCal with properties {{summary:"{safe_summary}", start date:startD, end date:endD, allday event:{str(all_day).lower()}}}
{loc_line}
{notes_line}
return uid of newEvent
end tell'''
event_id = run_applescript(script)
return {"id": event_id.strip(), "summary": summary, "start": start_date, "end": end_date, "created": True}
def delete_event(event_summary: str, event_date: str | None = None, calendar_name: str | None = None) -> dict:
"""Delete a calendar event by summary (and optionally date)."""
safe_summary = safe_applescript_string(event_summary)
cal_source = f'calendar "{safe_applescript_string(calendar_name)}"' if calendar_name else "first calendar"
if event_date:
dt = datetime.fromisoformat(event_date)
date_filter = f'''
set targetD to current date
set year of targetD to {dt.year}
set month of targetD to {dt.month}
set day of targetD to {dt.day}
set hours of targetD to 0
set minutes of targetD to 0
set seconds of targetD to 0
set nextD to targetD + (1 * days)
set evts to (every event of theCal whose summary is "{safe_summary}" and start date >= targetD and start date < nextD)'''
else:
date_filter = f'set evts to (every event of theCal whose summary is "{safe_summary}")'
script = f'''
tell application "Calendar"
set theCal to {cal_source}
{date_filter}
if (count of evts) > 0 then
delete item 1 of evts
return "deleted"
else
return "not_found"
end if
end tell'''
result = run_applescript(script)
if result == "deleted":
return {"summary": event_summary, "deleted": True}
raise RuntimeError(f"Event '{event_summary}' not found")
+280
View File
@@ -0,0 +1,280 @@
"""Apple Contacts tools."""
from helpers import run_applescript, safe_applescript_string
def search_contacts(query: str) -> list[dict]:
"""Search contacts by name, email, or phone."""
safe_query = safe_applescript_string(query)
script = f'''
tell application "Contacts"
set output to ""
set results to (every person whose name contains "{safe_query}")
repeat with p in results
set pName to name of p
set pId to id of p
set pEmail to ""
try
set pEmail to value of first email of p
end try
set pPhone to ""
try
set pPhone to value of first phone of p
end try
set pOrg to ""
try
set pOrg to organization of p
end try
set output to output & pName & "|||" & pEmail & "|||" & pPhone & "|||" & pOrg & "|||" & pId & linefeed
end repeat
-- Also search by email
set emailResults to (every person whose value of emails contains "{safe_query}")
repeat with p in emailResults
set pName to name of p
set pId to id of p
if output does not contain pId then
set pEmail to ""
try
set pEmail to value of first email of p
end try
set pPhone to ""
try
set pPhone to value of first phone of p
end try
set pOrg to ""
try
set pOrg to organization of p
end try
set output to output & pName & "|||" & pEmail & "|||" & pPhone & "|||" & pOrg & "|||" & pId & linefeed
end if
end repeat
return output
end tell'''
raw = run_applescript(script, timeout=30)
results = []
seen_ids = set()
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) < 5:
continue
cid = parts[4].strip()
if cid in seen_ids:
continue
seen_ids.add(cid)
results.append({
"name": parts[0].strip(),
"email": parts[1].strip() or None,
"phone": parts[2].strip() or None,
"organization": parts[3].strip() or None,
"id": cid,
})
return results
def get_contact(name: str) -> dict:
"""Get detailed info for a contact by name."""
safe_name = safe_applescript_string(name)
script = f'''
tell application "Contacts"
set results to (every person whose name is "{safe_name}")
if (count of results) = 0 then
set results to (every person whose name contains "{safe_name}")
end if
if (count of results) = 0 then
return "NOT_FOUND"
end if
set p to item 1 of results
set pName to name of p
set pFirst to first name of p
set pLast to last name of p
set pId to id of p
set pOrg to ""
try
set pOrg to organization of p
end try
set pTitle to ""
try
set pTitle to job title of p
end try
set pNote to ""
try
set pNote to note of p
end try
-- Emails
set emailList to ""
repeat with e in emails of p
set emailList to emailList & (label of e) & ":" & (value of e) & ","
end repeat
-- Phones
set phoneList to ""
repeat with ph in phones of p
set phoneList to phoneList & (label of ph) & ":" & (value of ph) & ","
end repeat
-- Addresses
set addrList to ""
repeat with a in addresses of p
set addrList to addrList & (label of a) & ":" & (formatted address of a) & ";;;"
end repeat
return pName & "|||" & pFirst & "|||" & pLast & "|||" & pOrg & "|||" & pTitle & "|||" & pNote & "|||" & emailList & "|||" & phoneList & "|||" & addrList & "|||" & pId
end tell'''
raw = run_applescript(script, timeout=30)
if raw == "NOT_FOUND":
raise RuntimeError(f"Contact '{name}' not found")
parts = raw.split("|||")
if len(parts) < 10:
raise RuntimeError("Failed to parse contact details")
emails = []
for e in parts[6].strip().rstrip(",").split(","):
if ":" in e:
label, value = e.split(":", 1)
emails.append({"label": label.strip(), "value": value.strip()})
phones = []
for ph in parts[7].strip().rstrip(",").split(","):
if ":" in ph:
label, value = ph.split(":", 1)
phones.append({"label": label.strip(), "value": value.strip()})
addresses = []
for a in parts[8].strip().split(";;;"):
if ":" in a:
label, value = a.split(":", 1)
addresses.append({"label": label.strip(), "address": value.strip()})
return {
"name": parts[0].strip(),
"first_name": parts[1].strip() or None,
"last_name": parts[2].strip() or None,
"organization": parts[3].strip() or None,
"job_title": parts[4].strip() or None,
"note": parts[5].strip() or None,
"emails": emails,
"phones": phones,
"addresses": addresses,
"id": parts[9].strip(),
}
def list_groups() -> list[dict]:
"""List all contact groups."""
script = '''
tell application "Contacts"
set output to ""
repeat with g in groups
set gName to name of g
set gId to id of g
set gCount to count of people of g
set output to output & gName & "|||" & gId & "|||" & (gCount as string) & linefeed
end repeat
return output
end tell'''
raw = run_applescript(script)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) >= 3:
results.append({
"name": parts[0].strip(),
"id": parts[1].strip(),
"member_count": int(parts[2].strip()) if parts[2].strip().isdigit() else 0,
})
return results
def create_contact(first_name: str, last_name: str | None = None,
email: str | None = None, phone: str | None = None,
organization: str | None = None, job_title: str | None = None,
note: str | None = None) -> dict:
"""Create a new contact."""
safe_first = safe_applescript_string(first_name)
props = [f'first name:"{safe_first}"']
if last_name:
props.append(f'last name:"{safe_applescript_string(last_name)}"')
if organization:
props.append(f'organization:"{safe_applescript_string(organization)}"')
if job_title:
props.append(f'job title:"{safe_applescript_string(job_title)}"')
if note:
props.append(f'note:"{safe_applescript_string(note)}"')
props_str = ", ".join(props)
email_line = ""
if email:
email_line = f'make new email at end of emails of newPerson with properties {{label:"work", value:"{safe_applescript_string(email)}"}}'
phone_line = ""
if phone:
phone_line = f'make new phone at end of phones of newPerson with properties {{label:"mobile", value:"{safe_applescript_string(phone)}"}}'
script = f'''
tell application "Contacts"
set newPerson to make new person with properties {{{props_str}}}
{email_line}
{phone_line}
save
return id of newPerson
end tell'''
contact_id = run_applescript(script)
return {
"id": contact_id.strip(),
"first_name": first_name,
"last_name": last_name,
"created": True,
}
def update_contact(name: str, email: str | None = None, phone: str | None = None,
organization: str | None = None, job_title: str | None = None,
note: str | None = None) -> dict:
"""Update an existing contact by name."""
safe_name = safe_applescript_string(name)
updates = []
if organization is not None:
updates.append(f'set organization of p to "{safe_applescript_string(organization)}"')
if job_title is not None:
updates.append(f'set job title of p to "{safe_applescript_string(job_title)}"')
if note is not None:
updates.append(f'set note of p to "{safe_applescript_string(note)}"')
if email:
updates.append(f'make new email at end of emails of p with properties {{label:"work", value:"{safe_applescript_string(email)}"}}')
if phone:
updates.append(f'make new phone at end of phones of p with properties {{label:"mobile", value:"{safe_applescript_string(phone)}"}}')
if not updates:
raise RuntimeError("No updates specified")
updates_str = "\n ".join(updates)
script = f'''
tell application "Contacts"
set results to (every person whose name contains "{safe_name}")
if (count of results) = 0 then
return "NOT_FOUND"
end if
set p to item 1 of results
{updates_str}
save
return "updated"
end tell'''
result = run_applescript(script)
if result == "NOT_FOUND":
raise RuntimeError(f"Contact '{name}' not found")
return {"name": name, "updated": True}
+134
View File
@@ -0,0 +1,134 @@
"""Apple Find My tools — reads cached location data."""
import json
import plistlib
from pathlib import Path
from datetime import datetime
FINDMY_CACHE = Path.home() / "Library" / "Caches" / "com.apple.findmy.fmipcore"
ITEMS_FILE = FINDMY_CACHE / "Items.data"
DEVICES_FILE = FINDMY_CACHE / "Devices.data"
def _load_cache_file(path: Path) -> list[dict]:
"""Load a Find My cache file (plist format)."""
if not path.exists():
raise RuntimeError(
f"Find My cache not found at {path}. "
"Make sure the Find My app has been opened at least once."
)
try:
f = open(path, "rb")
except PermissionError:
raise RuntimeError(
f"Permission denied reading {path}. "
"Grant Full Disk Access to the terminal/app running the MCP server: "
"System Settings > Privacy & Security > Full Disk Access."
)
with f:
try:
data = plistlib.load(f)
except Exception:
# Some versions use JSON
f.seek(0)
try:
data = json.load(f)
except Exception:
raise RuntimeError(f"Could not parse Find My cache at {path}")
if isinstance(data, list):
return data
return []
def _format_location(item: dict) -> dict | None:
"""Extract location info from a Find My item/device."""
loc = item.get("location")
if not loc:
return None
result = {
"latitude": loc.get("latitude"),
"longitude": loc.get("longitude"),
"altitude": loc.get("altitude"),
"horizontal_accuracy": loc.get("horizontalAccuracy"),
"is_old": loc.get("isOld", False),
"floor_level": loc.get("floorLevel"),
}
timestamp = loc.get("timeStamp")
if timestamp:
try:
if isinstance(timestamp, (int, float)):
# Apple epoch (2001-01-01) or Unix epoch
if timestamp > 1e15: # nanoseconds
timestamp = timestamp / 1e9
if timestamp < 1e9: # Apple epoch
timestamp += 978307200 # seconds between 1970 and 2001
result["timestamp"] = datetime.fromtimestamp(timestamp).isoformat()
else:
result["timestamp"] = str(timestamp)
except Exception:
result["timestamp"] = str(timestamp)
return result
def list_devices() -> list[dict]:
"""List all Apple devices in Find My."""
try:
devices = _load_cache_file(DEVICES_FILE)
except RuntimeError as e:
return [{"error": str(e)}]
results = []
for dev in devices:
location = _format_location(dev)
results.append({
"name": dev.get("name", "Unknown"),
"device_model": dev.get("deviceDisplayName", dev.get("deviceModel", "Unknown")),
"battery_level": dev.get("batteryLevel"),
"battery_status": dev.get("batteryStatus"),
"location": location,
"id": dev.get("baUUID", dev.get("id", "")),
})
return results
def get_device_location(device_name: str) -> dict:
"""Get location of a specific Apple device."""
try:
devices = _load_cache_file(DEVICES_FILE)
except RuntimeError as e:
raise RuntimeError(str(e))
device_name_lower = device_name.lower()
for dev in devices:
name = dev.get("name", "")
if device_name_lower in name.lower():
location = _format_location(dev)
return {
"name": name,
"device_model": dev.get("deviceDisplayName", dev.get("deviceModel", "Unknown")),
"battery_level": dev.get("batteryLevel"),
"location": location,
}
raise RuntimeError(f"Device '{device_name}' not found in Find My")
def list_items() -> list[dict]:
"""List all Find My items (AirTags, third-party trackers)."""
try:
items = _load_cache_file(ITEMS_FILE)
except RuntimeError as e:
return [{"error": str(e)}]
results = []
for item in items:
location = _format_location(item)
results.append({
"name": item.get("name", "Unknown"),
"product_type": item.get("productType", {}).get("type", "Unknown") if isinstance(item.get("productType"), dict) else item.get("productType", "Unknown"),
"serial_number": item.get("serialNumber"),
"battery_status": item.get("batteryStatus"),
"location": location,
"id": item.get("identifier", ""),
})
return results
+270
View File
@@ -0,0 +1,270 @@
"""Apple Mail tools."""
from helpers import run_applescript, safe_applescript_string, parse_applescript_date
def list_mailboxes() -> list[dict]:
"""List all mail accounts and their mailboxes."""
script = '''
tell application "Mail"
set output to ""
repeat with acct in accounts
set acctName to name of acct
repeat with mb in mailboxes of acct
set mbName to name of mb
set msgCount to count of messages of mb
set output to output & acctName & ">>>" & mbName & "|||" & (msgCount as string) & linefeed
end repeat
end repeat
return output
end tell'''
raw = run_applescript(script, timeout=30)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) < 2:
continue
name_part = parts[0]
account = None
if ">>>" in name_part:
account, name_part = name_part.split(">>>", 1)
results.append({
"account": account.strip() if account else None,
"mailbox": name_part.strip(),
"message_count": int(parts[1].strip()) if parts[1].strip().isdigit() else 0,
})
return results
def get_messages(mailbox_name: str = "INBOX", account_name: str | None = None,
count: int = 20) -> list[dict]:
"""Get recent messages from a mailbox.
Args:
mailbox_name: Mailbox name (default INBOX).
account_name: Specific account. If None, uses first account.
count: Number of recent messages to fetch.
"""
safe_mb = safe_applescript_string(mailbox_name)
if account_name:
safe_acct = safe_applescript_string(account_name)
mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"'
else:
mb_ref = f'inbox'
if mailbox_name != "INBOX":
mb_ref = f'mailbox "{safe_mb}" of first account'
script = f'''
tell application "Mail"
set output to ""
set theMB to {mb_ref}
set msgList to messages 1 through {count} of theMB
repeat with m in msgList
set mSubject to subject of m
set mFrom to sender of m
set mDate to (date received of m) as string
set mRead to read status of m as string
set mId to message id of m
set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & "|||" & mId & linefeed
end repeat
return output
end tell'''
raw = run_applescript(script, timeout=30)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) < 5:
continue
results.append({
"subject": parts[0].strip(),
"from": parts[1].strip(),
"date": parse_applescript_date(parts[2].strip()),
"read": parts[3].strip() == "true",
"message_id": parts[4].strip(),
})
return results
def get_message_content(subject: str, mailbox_name: str = "INBOX",
account_name: str | None = None) -> dict:
"""Get full content of a specific message by subject."""
safe_subject = safe_applescript_string(subject)
if account_name:
safe_acct = safe_applescript_string(account_name)
safe_mb = safe_applescript_string(mailbox_name)
mb_ref = f'mailbox "{safe_mb}" of account "{safe_acct}"'
else:
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
script = f'''
tell application "Mail"
set theMB to {mb_ref}
set msgs to (messages of theMB whose subject contains "{safe_subject}")
if (count of msgs) > 0 then
set m to item 1 of msgs
set mSubject to subject of m
set mFrom to sender of m
set mTo to ""
try
set recipList to to recipients of m
set mTo to ""
repeat with r in recipList
set mTo to mTo & (address of r) & ", "
end repeat
end try
set mDate to (date received of m) as string
set mContent to content of m
set mRead to read status of m as string
return mSubject & "|||" & mFrom & "|||" & mTo & "|||" & mDate & "|||" & mRead & "|||" & mContent
else
return "NOT_FOUND"
end if
end tell'''
raw = run_applescript(script, timeout=30)
if raw == "NOT_FOUND":
raise RuntimeError(f"Message with subject containing '{subject}' not found")
parts = raw.split("|||", 5)
if len(parts) < 6:
raise RuntimeError("Failed to parse message")
return {
"subject": parts[0].strip(),
"from": parts[1].strip(),
"to": parts[2].strip().rstrip(", ") or None,
"date": parse_applescript_date(parts[3].strip()),
"read": parts[4].strip() == "true",
"content": parts[5].strip(),
}
def search_mail(query: str, mailbox_name: str = "INBOX", count: int = 20) -> list[dict]:
"""Search mail by subject or sender."""
safe_query = safe_applescript_string(query)
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
script = f'''
tell application "Mail"
set output to ""
set theMB to {mb_ref}
set msgs to (messages of theMB whose subject contains "{safe_query}" or sender contains "{safe_query}")
set maxCount to {count}
set i to 0
repeat with m in msgs
if i >= maxCount then exit repeat
set mSubject to subject of m
set mFrom to sender of m
set mDate to (date received of m) as string
set mRead to read status of m as string
set output to output & mSubject & "|||" & mFrom & "|||" & mDate & "|||" & mRead & linefeed
set i to i + 1
end repeat
return output
end tell'''
raw = run_applescript(script, timeout=60)
results = []
for line in raw.strip().split("\n"):
if not line.strip():
continue
parts = line.split("|||")
if len(parts) < 4:
continue
results.append({
"subject": parts[0].strip(),
"from": parts[1].strip(),
"date": parse_applescript_date(parts[2].strip()),
"read": parts[3].strip() == "true",
})
return results
def send_mail(to: str, subject: str, body: str, cc: str | None = None,
from_account: str | None = None) -> dict:
"""Send an email."""
safe_to = safe_applescript_string(to)
safe_subject = safe_applescript_string(subject)
safe_body = safe_applescript_string(body)
cc_line = ""
if cc:
cc_addrs = [a.strip() for a in cc.split(",")]
cc_lines = "\n".join([f'make new cc recipient at end of cc recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in cc_addrs])
cc_line = cc_lines
account_line = ""
if from_account:
account_line = f', sender:"{safe_applescript_string(from_account)}"'
to_addrs = [a.strip() for a in to.split(",")]
to_lines = "\n".join([f'make new to recipient at end of to recipients with properties {{address:"{safe_applescript_string(a)}"}}' for a in to_addrs])
script = f'''
tell application "Mail"
set newMsg to make new outgoing message with properties {{subject:"{safe_subject}", content:"{safe_body}", visible:false{account_line}}}
tell newMsg
{to_lines}
{cc_line}
end tell
send newMsg
return "sent"
end tell'''
run_applescript(script, timeout=30)
return {"to": to, "subject": subject, "sent": True}
def mark_read(subject: str, mailbox_name: str = "INBOX", read: bool = True) -> dict:
"""Mark a message as read or unread."""
safe_subject = safe_applescript_string(subject)
mb_ref = "inbox" if mailbox_name == "INBOX" else f'mailbox "{safe_applescript_string(mailbox_name)}" of first account'
read_val = "true" if read else "false"
script = f'''
tell application "Mail"
set theMB to {mb_ref}
set msgs to (messages of theMB whose subject contains "{safe_subject}")
if (count of msgs) > 0 then
set read status of item 1 of msgs to {read_val}
return "done"
else
return "not_found"
end if
end tell'''
result = run_applescript(script)
if result == "done":
return {"subject": subject, "read": read}
raise RuntimeError(f"Message '{subject}' not found")
def move_message(subject: str, from_mailbox: str, to_mailbox: str,
account_name: str | None = None) -> dict:
"""Move a message to a different mailbox."""
safe_subject = safe_applescript_string(subject)
safe_from = safe_applescript_string(from_mailbox)
safe_to = safe_applescript_string(to_mailbox)
if account_name:
safe_acct = safe_applescript_string(account_name)
from_ref = f'mailbox "{safe_from}" of account "{safe_acct}"'
to_ref = f'mailbox "{safe_to}" of account "{safe_acct}"'
else:
from_ref = "inbox" if from_mailbox == "INBOX" else f'mailbox "{safe_from}" of first account'
to_ref = f'mailbox "{safe_to}" of first account'
script = f'''
tell application "Mail"
set fromMB to {from_ref}
set toMB to {to_ref}
set msgs to (messages of fromMB whose subject contains "{safe_subject}")
if (count of msgs) > 0 then
move item 1 of msgs to toMB
return "moved"
else
return "not_found"
end if
end tell'''
result = run_applescript(script)
if result == "moved":
return {"subject": subject, "from": from_mailbox, "to": to_mailbox, "moved": True}
raise RuntimeError(f"Message '{subject}' not found in {from_mailbox}")
+146
View File
@@ -0,0 +1,146 @@
"""Apple Maps tools."""
import subprocess
import urllib.parse
from helpers import run_applescript, run_jxa, safe_applescript_string
def search_locations(query: str) -> list[dict]:
"""Search for locations using Maps."""
safe_query = safe_applescript_string(query)
# Use JXA with MapKit-like search via Maps app
script = f'''
var Maps = Application("Maps");
Maps.activate();
// Use URL scheme to search
var app = Application.currentApplication();
app.includeStandardAdditions = true;
app.openLocation("maps://?q=" + encodeURIComponent("{safe_query}"));
// Return the query - Maps will show results in the app
"{safe_query}";
'''
# Maps doesn't expose search results via AppleScript, so we open the search
# and also try to geocode using CoreLocation via JXA
geocode_script = f'''
ObjC.import("CoreLocation");
ObjC.import("Foundation");
var geocoder = $.CLGeocoder.alloc.init;
var query = "{safe_query}";
var results = [];
var done = false;
geocoder.geocodeAddressStringCompletionHandler(query, function(placemarks, error) {{
if (placemarks && placemarks.count > 0) {{
for (var i = 0; i < Math.min(placemarks.count, 5); i++) {{
var pm = placemarks.objectAtIndex(i);
var loc = pm.location;
var name = pm.name ? pm.name.js : query;
var locality = pm.locality ? pm.locality.js : "";
var admin = pm.administrativeArea ? pm.administrativeArea.js : "";
var country = pm.country ? pm.country.js : "";
var postal = pm.postalCode ? pm.postalCode.js : "";
results.push({{
name: name,
latitude: loc.coordinate.latitude,
longitude: loc.coordinate.longitude,
locality: locality,
state: admin,
country: country,
postal_code: postal
}});
}}
}}
done = true;
}});
// Wait for geocoding (up to 10 seconds)
var startTime = new Date().getTime();
while (!done && (new Date().getTime() - startTime) < 10000) {{
$.NSRunLoop.currentRunLoop.runUntilDate($.NSDate.dateWithTimeIntervalSinceNow(0.1));
}}
JSON.stringify(results);
'''
try:
raw = run_jxa(geocode_script, timeout=15)
import json
locations = json.loads(raw)
return locations
except Exception:
# Fallback: just open in Maps
encoded = urllib.parse.quote(query)
subprocess.run(["open", f"maps://?q={encoded}"], capture_output=True)
return [{"query": query, "opened_in_maps": True, "note": "Search opened in Maps app. CoreLocation geocoding unavailable."}]
def get_directions(from_address: str | None = None, to_address: str = "",
mode: str = "driving") -> dict:
"""Get directions between two locations.
Args:
from_address: Starting address. None = current location.
to_address: Destination address.
mode: Travel mode - "driving", "walking", "transit".
"""
mode_map = {"driving": "d", "walking": "w", "transit": "r"}
mode_char = mode_map.get(mode, "d")
params = {"daddr": to_address, "dirflg": mode_char}
if from_address:
params["saddr"] = from_address
url = "maps://?" + urllib.parse.urlencode(params)
subprocess.run(["open", url], capture_output=True)
return {
"from": from_address or "Current Location",
"to": to_address,
"mode": mode,
"opened_in_maps": True,
}
def open_location(address: str | None = None, latitude: float | None = None,
longitude: float | None = None, label: str | None = None) -> dict:
"""Open a location in Apple Maps.
Provide either an address string or lat/lon coordinates.
"""
if latitude is not None and longitude is not None:
params = {"ll": f"{latitude},{longitude}"}
if label:
params["q"] = label
url = "maps://?" + urllib.parse.urlencode(params)
elif address:
params = {"address": address}
if label:
params["q"] = label
url = "maps://?" + urllib.parse.urlencode(params)
else:
raise RuntimeError("Provide either address or latitude/longitude")
subprocess.run(["open", url], capture_output=True)
return {
"address": address,
"latitude": latitude,
"longitude": longitude,
"label": label,
"opened_in_maps": True,
}
def drop_pin(latitude: float, longitude: float, label: str = "Pin") -> dict:
"""Drop a pin at specific coordinates in Maps."""
params = {"ll": f"{latitude},{longitude}", "q": label}
url = "maps://?" + urllib.parse.urlencode(params)
subprocess.run(["open", url], capture_output=True)
return {
"latitude": latitude,
"longitude": longitude,
"label": label,
"dropped_pin": True,
}
+163
View File
@@ -0,0 +1,163 @@
"""Apple Reminders tools — uses compiled EventKit helper for speed."""
import json
import subprocess
from pathlib import Path
from helpers import run_applescript, safe_applescript_string
HELPER_BIN = Path(__file__).parent.parent / "helpers" / "reminders_helper"
def _run_helper(*args: str, timeout: int = 15) -> dict | list:
"""Run the compiled EventKit reminders helper and return parsed JSON."""
result = subprocess.run(
[str(HELPER_BIN)] + list(args),
capture_output=True, text=True, timeout=timeout,
)
if result.returncode != 0:
stderr = result.stderr.strip()
try:
err = json.loads(result.stdout)
raise RuntimeError(err.get("error", stderr))
except (json.JSONDecodeError, TypeError):
raise RuntimeError(f"Reminders helper error: {stderr or result.stdout}")
return json.loads(result.stdout)
def list_lists() -> list[dict]:
"""List all reminder lists (e.g., Reminders, Shopping, Work)."""
return _run_helper("lists")
def get_reminders(list_name: str | None = None, include_completed: bool = False) -> dict:
"""Get reminders, optionally filtered by list name.
Args:
list_name: Filter to a specific list. None = all lists.
include_completed: Include completed reminders.
"""
args = ["get"]
if list_name:
args += ["--list", list_name]
if not include_completed:
pass # Default is incomplete only
return _run_helper(*args, timeout=30)
def search_reminders(query: str) -> dict:
"""Search reminders by name across all lists."""
return _run_helper("search", query, timeout=30)
def get_overdue_reminders() -> dict:
"""Get all overdue (past due) incomplete reminders."""
return _run_helper("get", "--overdue")
def get_due_today() -> dict:
"""Get reminders due today."""
return _run_helper("get", "--due-today")
def get_due_this_week() -> dict:
"""Get reminders due in the next 7 days."""
return _run_helper("get", "--due-this-week")
def create_reminder(name: str, list_name: str = "Reminders", due_date: str | None = None,
body: str | None = None, priority: int = 0) -> dict:
"""Create a new reminder."""
safe_name = safe_applescript_string(name)
safe_list = safe_applescript_string(list_name)
props = [f'name:"{safe_name}"']
if body:
props.append(f'body:"{safe_applescript_string(body)}"')
if priority > 0:
props.append(f'priority:{priority}')
props_str = ", ".join(props)
due_line = ""
if due_date:
due_line = f'''
set due date of newReminder to date "{due_date}"'''
script = f'''
tell application "Reminders"
set theList to list "{safe_list}"
set newReminder to make new reminder at end of theList with properties {{{props_str}}}
{due_line}
return id of newReminder
end tell'''
reminder_id = run_applescript(script)
return {"id": reminder_id.strip(), "name": name, "list": list_name, "created": True}
def complete_reminder(name: str, list_name: str | None = None) -> dict:
"""Mark a reminder as completed by name."""
safe_name = safe_applescript_string(name)
if list_name:
safe_list = safe_applescript_string(list_name)
script = f'''
tell application "Reminders"
set theList to list "{safe_list}"
set theReminders to (every reminder of theList whose name is "{safe_name}" and completed is false)
if (count of theReminders) > 0 then
set completed of item 1 of theReminders to true
return "completed"
else
return "not_found"
end if
end tell'''
else:
script = f'''
tell application "Reminders"
repeat with L in lists
set theReminders to (every reminder of L whose name is "{safe_name}" and completed is false)
if (count of theReminders) > 0 then
set completed of item 1 of theReminders to true
return "completed"
end if
end repeat
return "not_found"
end tell'''
result = run_applescript(script)
if result == "completed":
return {"name": name, "completed": True}
raise RuntimeError(f"Reminder '{name}' not found or already completed")
def delete_reminder(name: str, list_name: str | None = None) -> dict:
"""Delete a reminder by name."""
safe_name = safe_applescript_string(name)
if list_name:
safe_list = safe_applescript_string(list_name)
script = f'''
tell application "Reminders"
set theList to list "{safe_list}"
set theReminders to (every reminder of theList whose name is "{safe_name}")
if (count of theReminders) > 0 then
delete item 1 of theReminders
return "deleted"
else
return "not_found"
end if
end tell'''
else:
script = f'''
tell application "Reminders"
repeat with L in lists
set theReminders to (every reminder of L whose name is "{safe_name}")
if (count of theReminders) > 0 then
delete item 1 of theReminders
return "deleted"
end if
end repeat
return "not_found"
end tell'''
result = run_applescript(script)
if result == "deleted":
return {"name": name, "deleted": True}
raise RuntimeError(f"Reminder '{name}' not found")