Files
apple-mcp/apps/findmy.py
T
Eric Jungbauer 7e619d0454 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>
2026-04-15 15:32:24 -06:00

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