7e619d0454
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>
135 lines
4.5 KiB
Python
135 lines
4.5 KiB
Python
"""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
|