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