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>
495 lines
18 KiB
Swift
495 lines
18 KiB
Swift
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)
|
|
}
|
|
}
|