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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eric Jungbauer
2026-04-15 15:32:24 -06:00
commit 7e619d0454
16 changed files with 2783 additions and 0 deletions
+494
View File
@@ -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)
}
}