├── .cursor └── rules │ └── ignore-sourcekit-warnings.mdc ├── .gitignore ├── App ├── App.entitlements ├── App.swift ├── Assets.xcassets │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ ├── Contents.json │ │ ├── Icon-macOS-128x128.png │ │ ├── Icon-macOS-256x256.png │ │ ├── Icon-macOS-32x32.png │ │ ├── Icon-macOS-512x512.png │ │ ├── Icon-macOS-512x512@2x.png │ │ └── Icon-macOS-64x64.png │ ├── Contents.json │ ├── MenuIcon-Off.imageset │ │ ├── Contents.json │ │ ├── MenuIcon - Dark.svg │ │ └── MenuIcon - Light.svg │ └── MenuIcon-On.imageset │ │ ├── Contents.json │ │ └── MenuIcon.svg ├── Controllers │ └── ServerController.swift ├── Extensions │ ├── AppKit+Extensions.swift │ ├── Bundle+Extensions.swift │ ├── Contacts+Extensions.swift │ ├── EventKit+Extensions.swift │ ├── Foundation+Extensions.swift │ ├── Logger+Extensions.swift │ └── MapKit+Extensions.swift ├── Info.plist ├── Integrations │ └── ClaudeDesktop.swift ├── Models │ ├── Service.swift │ ├── Tool.swift │ └── Value.swift ├── Services │ ├── Calendar.swift │ ├── Contacts.swift │ ├── Location.swift │ ├── Maps.swift │ ├── Messages.swift │ ├── Reminders.swift │ ├── Utilities.swift │ └── Weather.swift └── Views │ ├── AboutWindow.swift │ ├── ConnectionApprovalView.swift │ ├── ContentView.swift │ ├── ServiceToggleView.swift │ └── SettingsView.swift ├── Assets ├── calendar.svg ├── claude-desktop-screenshot-message.png ├── claude-desktop-screenshot-tool-use.png ├── claude-desktop-screenshot-tools-enabled.png ├── companion-screenshot-add-server.png ├── contacts.svg ├── hero-dark.svg ├── hero-light.svg ├── icon.svg ├── imcp-screenshot-all-services-active.png ├── imcp-screenshot-approve-connection.png ├── imcp-screenshot-configure-claude-desktop.png ├── imcp-screenshot-first-launch.png ├── imcp-screenshot-grant-permission.png ├── location.svg ├── maps.svg ├── messages.svg ├── reminders.svg └── weather.svg ├── CLI ├── .gitignore └── main.swift ├── LICENSE ├── README.md └── iMCP.xcodeproj ├── project.pbxproj ├── project.xcworkspace ├── contents.xcworkspacedata └── xcshareddata │ ├── WorkspaceSettings.xcsettings │ └── swiftpm │ └── Package.resolved ├── xcshareddata └── xcschemes │ └── iMCP.xcscheme └── xcuserdata ├── carlpeaslee.xcuserdatad └── xcschemes │ └── xcschememanagement.plist └── mattt.xcuserdatad ├── xcdebugger └── Breakpoints_v2.xcbkptlist └── xcschemes └── xcschememanagement.plist /.cursor/rules/ignore-sourcekit-warnings.mdc: -------------------------------------------------------------------------------- 1 | --- 2 | description: Ignore spurious SourceKit warnings 3 | globs: *.swift 4 | alwaysApply: false 5 | --- 6 | Ignore warnings erroneously generated by SourceKit like "Cannot find type '____' in scope" or "No such module '___'". 7 | 8 | Assume types exist and modules are correctly installed. 9 | 10 | Don't attempt to install new Swift packages. 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Obj-C/Swift specific 9 | *.hmap 10 | 11 | ## App packaging 12 | *.ipa 13 | *.dSYM.zip 14 | *.dSYM 15 | 16 | ## Playgrounds 17 | timeline.xctimeline 18 | playground.xcworkspace 19 | 20 | # Swift Package Manager 21 | # 22 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 23 | # Packages/ 24 | # Package.pins 25 | # Package.resolved 26 | # *.xcodeproj 27 | # 28 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 29 | # hence it is not needed unless you have added a package configuration file to your project 30 | # .swiftpm 31 | 32 | .build/ 33 | 34 | # CocoaPods 35 | # 36 | # We recommend against adding the Pods directory to your .gitignore. However 37 | # you should judge for yourself, the pros and cons are mentioned at: 38 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 39 | # 40 | # Pods/ 41 | # 42 | # Add this line if you want to avoid checking in source code from the Xcode workspace 43 | # *.xcworkspace 44 | 45 | # Carthage 46 | # 47 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 48 | # Carthage/Checkouts 49 | 50 | Carthage/Build/ 51 | 52 | # fastlane 53 | # 54 | # It is recommended to not store the screenshots in the git repo. 55 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 56 | # For more information about the recommended setup visit: 57 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 58 | 59 | fastlane/report.xml 60 | fastlane/Preview.html 61 | fastlane/screenshots/**/*.png 62 | fastlane/test_output -------------------------------------------------------------------------------- /App/App.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.weatherkit 6 | 7 | com.apple.security.app-sandbox 8 | 9 | com.apple.security.automation.apple-events 10 | 11 | com.apple.security.files.user-selected.read-write 12 | 13 | com.apple.security.inherit 14 | 15 | com.apple.security.network.client 16 | 17 | com.apple.security.network.server 18 | 19 | com.apple.security.personal-information.addressbook 20 | 21 | com.apple.security.personal-information.calendars 22 | 23 | com.apple.security.personal-information.health 24 | 25 | com.apple.security.personal-information.location 26 | 27 | com.apple.security.temporary-exception.apple-events 28 | 29 | com.apple.Terminal 30 | 31 | com.apple.security.temporary-exception.files.absolute-path.read-write 32 | 33 | /Users/*/Library/Messages/ 34 | 35 | com.apple.security.temporary-exception.mach-lookup.global-name 36 | 37 | $(PRODUCT_BUNDLE_IDENTIFIER)-spks 38 | $(PRODUCT_BUNDLE_IDENTIFIER)-spki 39 | 40 | 41 | -------------------------------------------------------------------------------- /App/App.swift: -------------------------------------------------------------------------------- 1 | import MenuBarExtraAccess 2 | import SwiftUI 3 | 4 | @main 5 | struct App: SwiftUI.App { 6 | @StateObject private var serverController = ServerController() 7 | @AppStorage("isEnabled") private var isEnabled = true 8 | @State private var isMenuPresented = false 9 | 10 | var body: some Scene { 11 | MenuBarExtra("iMCP", image: #"MenuIcon-\#(isEnabled ? "On" : "Off")"#) { 12 | ContentView( 13 | serverManager: serverController, 14 | isEnabled: $isEnabled, 15 | isMenuPresented: $isMenuPresented 16 | ) 17 | } 18 | .menuBarExtraStyle(.window) 19 | .menuBarExtraAccess(isPresented: $isMenuPresented) 20 | 21 | Settings { 22 | SettingsView(serverController: serverController) 23 | } 24 | 25 | .commands { 26 | CommandGroup(replacing: .appTermination) { 27 | Button("Quit") { 28 | NSApplication.shared.terminate(nil) 29 | } 30 | .keyboardShortcut("q", modifiers: .command) 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "idiom": "mac", 5 | "scale": "1x", 6 | "size": "16x16" 7 | }, 8 | { 9 | "filename": "Icon-macOS-32x32.png", 10 | "idiom": "mac", 11 | "scale": "2x", 12 | "size": "16x16" 13 | }, 14 | { 15 | "filename": "Icon-macOS-32x32.png", 16 | "idiom": "mac", 17 | "scale": "1x", 18 | "size": "32x32" 19 | }, 20 | { 21 | "filename": "Icon-macOS-64x64.png", 22 | "idiom": "mac", 23 | "scale": "2x", 24 | "size": "32x32" 25 | }, 26 | { 27 | "filename": "Icon-macOS-128x128.png", 28 | "idiom": "mac", 29 | "scale": "1x", 30 | "size": "128x128" 31 | }, 32 | { 33 | "filename": "Icon-macOS-256x256.png", 34 | "idiom": "mac", 35 | "scale": "2x", 36 | "size": "128x128" 37 | }, 38 | { 39 | "filename": "Icon-macOS-256x256.png", 40 | "idiom": "mac", 41 | "scale": "1x", 42 | "size": "256x256" 43 | }, 44 | { 45 | "filename": "Icon-macOS-512x512.png", 46 | "idiom": "mac", 47 | "scale": "2x", 48 | "size": "256x256" 49 | }, 50 | { 51 | "filename": "Icon-macOS-512x512.png", 52 | "idiom": "mac", 53 | "scale": "1x", 54 | "size": "512x512" 55 | }, 56 | { 57 | "filename": "Icon-macOS-512x512@2x.png", 58 | "idiom": "mac", 59 | "scale": "2x", 60 | "size": "512x512" 61 | } 62 | ], 63 | "info": { 64 | "author": "xcode", 65 | "version": 1 66 | } 67 | } -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-128x128.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-256x256.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-32x32.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-512x512@2x.png -------------------------------------------------------------------------------- /App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/App/Assets.xcassets/AppIcon.appiconset/Icon-macOS-64x64.png -------------------------------------------------------------------------------- /App/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /App/Assets.xcassets/MenuIcon-Off.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images": [ 3 | { 4 | "filename": "MenuIcon - Light.svg", 5 | "idiom": "universal" 6 | }, 7 | { 8 | "appearances": [ 9 | { 10 | "appearance": "luminosity", 11 | "value": "dark" 12 | } 13 | ], 14 | "filename": "MenuIcon - Dark.svg", 15 | "idiom": "universal" 16 | } 17 | ], 18 | "info": { 19 | "author": "xcode", 20 | "version": 1 21 | }, 22 | "properties": { 23 | "preserves-vector-representation": true 24 | } 25 | } -------------------------------------------------------------------------------- /App/Assets.xcassets/MenuIcon-Off.imageset/MenuIcon - Dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /App/Assets.xcassets/MenuIcon-Off.imageset/MenuIcon - Light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /App/Assets.xcassets/MenuIcon-On.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "MenuIcon.svg", 5 | "idiom" : "universal" 6 | } 7 | ], 8 | "info" : { 9 | "author" : "xcode", 10 | "version" : 1 11 | }, 12 | "properties" : { 13 | "preserves-vector-representation" : true, 14 | "template-rendering-intent" : "template" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /App/Assets.xcassets/MenuIcon-On.imageset/MenuIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /App/Extensions/AppKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | 3 | enum Sound: String, Hashable, CaseIterable { 4 | static let `default`: Sound = .sosumi 5 | 6 | case basso = "Basso" 7 | case blow = "Blow" 8 | case bottle = "Bottle" 9 | case frog = "Frog" 10 | case funk = "Funk" 11 | case glass = "Glass" 12 | case hero = "Hero" 13 | case morse = "Morse" 14 | case ping = "Ping" 15 | case pop = "Pop" 16 | case purr = "Purr" 17 | case sosumi = "Sosumi" 18 | case submarine = "Submarine" 19 | case tink = "Tink" 20 | } 21 | 22 | extension NSSound { 23 | static func play(_ sound: Sound) -> Bool { 24 | guard let nsSound = NSSound(named: sound.rawValue) else { 25 | return false 26 | } 27 | return nsSound.play() 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /App/Extensions/Bundle+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension Bundle { 4 | var name: String? { 5 | infoDictionary?["CFBundleName"] as? String 6 | } 7 | 8 | var shortVersionString: String? { 9 | infoDictionary?["CFBundleShortVersionString"] as? String 10 | } 11 | 12 | var copyright: String? { 13 | infoDictionary?["NSHumanReadableCopyright"] as? String 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /App/Extensions/Contacts+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Contacts 2 | import Ontology 3 | 4 | /// Helper for working with contact label constants 5 | enum CNContactLabel { 6 | static func from(string: String) -> String { 7 | switch string.lowercased() { 8 | case "mobile": return CNLabelPhoneNumberMobile 9 | case "work": return CNLabelWork 10 | case "home": return CNLabelHome 11 | default: return string 12 | } 13 | } 14 | 15 | static var allPhoneLabels: [String] { 16 | return [CNLabelPhoneNumberMobile, CNLabelWork, CNLabelHome] 17 | } 18 | 19 | static var allEmailLabels: [String] { 20 | return [CNLabelWork, CNLabelHome] 21 | } 22 | 23 | static var allAddressLabels: [String] { 24 | return [CNLabelWork, CNLabelHome] 25 | } 26 | } 27 | 28 | extension CNMutableContact { 29 | /// Populate a contact from provided arguments dictionary 30 | func populate(from arguments: [String: Value]) { 31 | // Set given name 32 | if case let .string(givenName) = arguments["givenName"] { 33 | self.givenName = givenName 34 | } 35 | 36 | // Set family name 37 | if case let .string(familyName) = arguments["familyName"] { 38 | self.familyName = familyName 39 | } 40 | 41 | // Set organization name 42 | if case let .string(organizationName) = arguments["organizationName"] { 43 | self.organizationName = organizationName 44 | } 45 | 46 | // Set job title 47 | if case let .string(jobTitle) = arguments["jobTitle"] { 48 | self.jobTitle = jobTitle 49 | } 50 | 51 | // Set phone numbers 52 | if case let .object(phoneNumbers) = arguments["phoneNumbers"] { 53 | self.phoneNumbers = phoneNumbers.compactMap { entry in 54 | guard case let .string(value) = entry.value, !value.isEmpty else { return nil } 55 | return CNLabeledValue( 56 | label: CNContactLabel.from(string: entry.key), 57 | value: CNPhoneNumber(stringValue: value) 58 | ) 59 | } 60 | } 61 | 62 | // Set email addresses 63 | if case let .object(emailAddresses) = arguments["emailAddresses"] { 64 | self.emailAddresses = emailAddresses.compactMap { entry in 65 | guard case let .string(value) = entry.value, !value.isEmpty else { return nil } 66 | return CNLabeledValue( 67 | label: CNContactLabel.from(string: entry.key), 68 | value: value as NSString 69 | ) 70 | } 71 | } 72 | 73 | // Set postal addresses 74 | if case let .object(postalAddresses) = arguments["postalAddresses"] { 75 | self.postalAddresses = postalAddresses.compactMap { entry in 76 | guard case let .object(addressData) = entry.value else { return nil } 77 | 78 | let postalAddress = CNMutablePostalAddress() 79 | 80 | if case let .string(street) = addressData["street"] { 81 | postalAddress.street = street 82 | } 83 | if case let .string(city) = addressData["city"] { 84 | postalAddress.city = city 85 | } 86 | if case let .string(state) = addressData["state"] { 87 | postalAddress.state = state 88 | } 89 | if case let .string(postalCode) = addressData["postalCode"] { 90 | postalAddress.postalCode = postalCode 91 | } 92 | if case let .string(country) = addressData["country"] { 93 | postalAddress.country = country 94 | } 95 | 96 | return CNLabeledValue( 97 | label: CNContactLabel.from(string: entry.key), 98 | value: postalAddress 99 | ) 100 | } 101 | } 102 | 103 | // Set birthday 104 | if case let .object(birthdayData) = arguments["birthday"], 105 | case let .int(day) = birthdayData["day"], 106 | case let .int(month) = birthdayData["month"] 107 | { 108 | var dateComponents = DateComponents() 109 | dateComponents.day = day 110 | dateComponents.month = month 111 | 112 | if case let .int(year) = birthdayData["year"] { 113 | dateComponents.year = year 114 | } 115 | 116 | self.birthday = dateComponents 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /App/Extensions/EventKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | 3 | extension EKEventAvailability { 4 | init(_ string: String) { 5 | switch string.lowercased() { 6 | case "busy": self = .busy 7 | case "free": self = .free 8 | case "tentative": self = .tentative 9 | case "unavailable": self = .unavailable 10 | default: self = .busy 11 | } 12 | } 13 | 14 | static var allCases: [EKEventAvailability] { 15 | return [.busy, .free, .tentative, .unavailable] 16 | } 17 | 18 | var stringValue: String { 19 | switch self { 20 | case .busy: return "busy" 21 | case .free: return "free" 22 | case .tentative: return "tentative" 23 | case .unavailable: return "unavailable" 24 | default: return "unknown" 25 | } 26 | } 27 | } 28 | 29 | extension EKEventStatus { 30 | init(_ string: String) { 31 | switch string.lowercased() { 32 | case "none": self = .none 33 | case "tentative": self = .tentative 34 | case "confirmed": self = .confirmed 35 | case "canceled": self = .canceled 36 | default: self = .none 37 | } 38 | } 39 | } 40 | 41 | extension EKRecurrenceFrequency { 42 | init(_ string: String) { 43 | switch string.lowercased() { 44 | case "daily": self = .daily 45 | case "weekly": self = .weekly 46 | case "monthly": self = .monthly 47 | case "yearly": self = .yearly 48 | default: self = .daily 49 | } 50 | } 51 | } 52 | 53 | extension EKReminderPriority { 54 | static func from(string: String) -> EKReminderPriority { 55 | switch string.lowercased() { 56 | case "high": return .high 57 | case "medium": return .medium 58 | case "low": return .low 59 | default: return .none 60 | } 61 | } 62 | 63 | static var allCases: [EKReminderPriority] { 64 | return [.none, .low, .medium, .high] 65 | } 66 | 67 | var stringValue: String { 68 | switch self { 69 | case .high: return "high" 70 | case .medium: return "medium" 71 | case .low: return "low" 72 | case .none: return "none" 73 | @unknown default: return "unknown" 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /App/Extensions/Foundation+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension ISO8601DateFormatter { 4 | /// Attempts to parse a date string using several common ISO 8601 format options. 5 | /// - Parameters: 6 | /// - dateString: The string representation of the date. 7 | /// - Returns: A `Date` object if parsing is successful with any format, otherwise `nil`. 8 | static func parseFlexibleISODate(_ dateString: String) -> Date? { 9 | let formatter = ISO8601DateFormatter() 10 | 11 | let optionsToTry: [ISO8601DateFormatter.Options] = [ 12 | [.withInternetDateTime, .withFractionalSeconds], // Handles yyyy-MM-dd\'T\'HH:mm:ss.SSSZ and yyyy-MM-dd\'T\'HH:mm:ss.SSSZZZZZ 13 | [.withInternetDateTime], // Handles yyyy-MM-dd\'T\'HH:mm:ssZ and yyyy-MM-dd\'T\'HH:mm:ssZZZZZ 14 | [.withFullDate, .withFullTime, .withFractionalSeconds], // Handles yyyy-MM-dd\'T\'HH:mm:ss.SSS (no Z or offset) 15 | [.withFullDate, .withFullTime], // Handles yyyy-MM-dd\'T\'HH:mm:ss (no Z or offset) 16 | [.withFullDate, .withFullTime, .withSpaceBetweenDateAndTime, .withFractionalSeconds], // Handles yyyy-MM-dd HH:mm:ss.SSSZZZZZ etc. 17 | [.withFullDate, .withFullTime, .withSpaceBetweenDateAndTime], // Handles yyyy-MM-dd HH:mm:ssZZZZZ etc. 18 | ] 19 | 20 | for options in optionsToTry { 21 | formatter.formatOptions = options 22 | if let date = formatter.date(from: dateString) { 23 | return date 24 | } 25 | } 26 | 27 | return nil 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /App/Extensions/Logger+Extensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import OSLog 3 | 4 | /// Logging configuration following Apple's recommended practices 5 | extension Logger { 6 | /// Using bundle identifier as recommended by Apple for a unique identifier 7 | private static var subsystem = Bundle.main.bundleIdentifier ?? "com.loopwork.iMCP" 8 | 9 | /// Server-related logs including connection management and state changes 10 | static let server = Logger(subsystem: subsystem, category: "server") 11 | 12 | /// Service-related logs for various system services (Calendar, Contacts, etc.) 13 | static func service(_ name: String) -> Logger { 14 | Logger(subsystem: subsystem, category: "services.\(name)") 15 | } 16 | 17 | /// Service-related logs for various system services (Calendar, Contacts, etc.) 18 | static func integration(_ name: String) -> Logger { 19 | Logger(subsystem: subsystem, category: "integrations.\(name)") 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /App/Extensions/MapKit+Extensions.swift: -------------------------------------------------------------------------------- 1 | import MapKit 2 | 3 | extension MKPointOfInterestCategory { 4 | static func from(string: String) -> MKPointOfInterestCategory? { 5 | switch string { 6 | case "airport": return .airport 7 | case "restaurant": return .restaurant 8 | case "gas": return .gasStation 9 | case "parking": return .parking 10 | case "hotel": return .hotel 11 | case "hospital": return .hospital 12 | case "police": return .police 13 | case "fire": return .fireStation 14 | case "store": return .store 15 | case "museum": return .museum 16 | case "park": return .park 17 | case "school": return .school 18 | case "library": return .library 19 | case "theater": return .theater 20 | case "bank": return .bank 21 | case "atm": return .atm 22 | case "cafe": return .cafe 23 | case "pharmacy": return .pharmacy 24 | case "gym": return .fitnessCenter 25 | case "laundry": return .laundry 26 | default: return nil 27 | } 28 | } 29 | 30 | static var allCases: [MKPointOfInterestCategory] { 31 | return [ 32 | .airport, .restaurant, .gasStation, .parking, .hotel, .hospital, 33 | .police, .fireStation, .store, .museum, .park, .school, 34 | .library, .theater, .bank, .atm, .cafe, .pharmacy, .fitnessCenter, .laundry, 35 | ] 36 | } 37 | 38 | var stringValue: String { 39 | switch self { 40 | case .airport: return "airport" 41 | case .restaurant: return "restaurant" 42 | case .gasStation: return "gas" 43 | case .parking: return "parking" 44 | case .hotel: return "hotel" 45 | case .hospital: return "hospital" 46 | case .police: return "police" 47 | case .fireStation: return "fire" 48 | case .store: return "store" 49 | case .museum: return "museum" 50 | case .park: return "park" 51 | case .school: return "school" 52 | case .library: return "library" 53 | case .theater: return "theater" 54 | case .bank: return "bank" 55 | case .atm: return "atm" 56 | case .cafe: return "cafe" 57 | case .pharmacy: return "pharmacy" 58 | case .fitnessCenter: return "gym" 59 | case .laundry: return "laundry" 60 | default: return "unknown" 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /App/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSHumanReadableCopyright 6 | © 2025 Loopwork Limited. All rights reserved. 7 | 8 | LSUIElement 9 | 10 | 11 | NSBonjourServices 12 | 13 | _mcp._tcp 14 | 15 | 16 | NSContactsUsageDescription 17 | ${PRODUCT_NAME} needs access to provide contact information to the MCP server. 18 | 19 | NSCalendarsFullAccessUsageDescription 20 | ${PRODUCT_NAME} needs access to provide event information to the MCP server. 21 | 22 | NSLocalNetworkUsageDescription 23 | ${PRODUCT_NAME} uses the local network to connect to the MCP server. 24 | 25 | NSLocationWhenInUseUsageDescription 26 | ${PRODUCT_NAME} needs access to provide location information to the MCP server. 27 | 28 | NSRemindersFullAccessUsageDescription 29 | ${PRODUCT_NAME} needs access to provide reminders information to the MCP server. 30 | 31 | SUEnableInstallerLauncherService 32 | 33 | 34 | SUFeedURL 35 | https://downloads.imcp.app/appcast.xml 36 | 37 | SUPublicEDKey 38 | +2ibtYfPSNKpUJsn1R9Ywc1GnjQA5vemVN95d0EzGxg= 39 | 40 | -------------------------------------------------------------------------------- /App/Integrations/ClaudeDesktop.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import Foundation 3 | import MCP 4 | import OSLog 5 | 6 | private let log = Logger.integration("claude-desktop") 7 | private let configPath = 8 | "/Users/\(NSUserName())/Library/Application Support/Claude/claude_desktop_config.json" 9 | private let configBookmarkKey = "com.loopwork.iMCP.claudeConfigBookmark" 10 | 11 | private let jsonEncoder: JSONEncoder = { 12 | let encoder = JSONEncoder() 13 | encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] 14 | return encoder 15 | }() 16 | 17 | private let jsonDecoder = JSONDecoder() 18 | 19 | enum ClaudeDesktop { 20 | struct Config: Codable { 21 | struct MCPServer: Codable { 22 | var command: String 23 | var args: [String]? 24 | var env: [String: String]? 25 | } 26 | 27 | var mcpServers: [String: MCPServer] 28 | } 29 | 30 | enum Error: LocalizedError { 31 | case noLocationSelected 32 | 33 | var errorDescription: String? { 34 | switch self { 35 | case .noLocationSelected: 36 | return "No location selected to save config" 37 | } 38 | } 39 | } 40 | 41 | static func showConfigurationPanel() { 42 | do { 43 | log.debug("Loading existing Claude Desktop configuration") 44 | let (config, imcpServer) = try loadConfig() 45 | 46 | let fileExists = FileManager.default.fileExists(atPath: configPath) 47 | 48 | let alert = NSAlert() 49 | alert.messageText = "Set Up iMCP Server" 50 | alert.informativeText = """ 51 | This will \(fileExists ? "update" : "create") the iMCP server settings in Claude Desktop. 52 | 53 | Location: \(configPath) 54 | 55 | Your existing server configurations won't be affected. 56 | """ 57 | 58 | alert.addButton(withTitle: "Set Up") 59 | alert.addButton(withTitle: "Cancel") 60 | 61 | NSApp.activate(ignoringOtherApps: true) 62 | 63 | let alertResponse = alert.runModal() 64 | if alertResponse == .alertFirstButtonReturn { 65 | log.debug("User clicked Save, updating configuration") 66 | try updateConfig(config, upserting: imcpServer) 67 | log.notice("Configuration updated successfully") 68 | } else { 69 | log.debug("User cancelled configuration update") 70 | } 71 | } catch { 72 | log.error("Error configuring Claude Desktop: \(error.localizedDescription)") 73 | let alert = NSAlert() 74 | alert.messageText = "Configuration Error" 75 | alert.informativeText = error.localizedDescription 76 | alert.alertStyle = .critical 77 | alert.runModal() 78 | } 79 | } 80 | } 81 | 82 | private func getSecurityScopedConfigURL() throws -> URL? { 83 | log.debug("Attempting to get security-scoped config URL") 84 | guard let bookmarkData = UserDefaults.standard.data(forKey: configBookmarkKey) else { 85 | log.debug("No bookmark data found in UserDefaults") 86 | return nil 87 | } 88 | 89 | var isStale = false 90 | let url = try URL( 91 | resolvingBookmarkData: bookmarkData, 92 | options: .withSecurityScope, 93 | relativeTo: nil, 94 | bookmarkDataIsStale: &isStale) 95 | 96 | if isStale { 97 | log.debug("Bookmark data is stale but URL was resolved: \(url.path). Attempting to use it.") 98 | // We will still return the URL and let the caller try to use it and refresh the bookmark. 99 | } 100 | 101 | log.debug("Successfully retrieved security-scoped URL: \(url.path)") 102 | return url 103 | } 104 | 105 | private func saveSecurityScopedAccess(for url: URL) throws { 106 | log.debug("Creating security-scoped bookmark for URL: \(url.path)") 107 | let bookmarkData = try url.bookmarkData( 108 | options: .withSecurityScope, 109 | includingResourceValuesForKeys: nil, 110 | relativeTo: nil 111 | ) 112 | UserDefaults.standard.set(bookmarkData, forKey: configBookmarkKey) 113 | log.debug("Successfully saved security-scoped bookmark") 114 | } 115 | 116 | private func loadConfig() throws -> ([String: Value], ClaudeDesktop.Config.MCPServer) { 117 | log.debug("Creating default iMCP server configuration") 118 | let imcpServer = ClaudeDesktop.Config.MCPServer( 119 | command: Bundle.main.bundleURL 120 | .appendingPathComponent("Contents/MacOS/imcp-server") 121 | .path) 122 | 123 | var loadedConfiguration: [String: Value]? 124 | 125 | // 1. Try to load using security-scoped URL 126 | if let secureURL = try? getSecurityScopedConfigURL() { 127 | log.debug("Attempting to load from security-scoped URL: \(secureURL.path)") 128 | if secureURL.startAccessingSecurityScopedResource() { 129 | defer { secureURL.stopAccessingSecurityScopedResource() } 130 | if FileManager.default.fileExists(atPath: secureURL.path) { 131 | do { 132 | log.debug("Loading existing configuration from: \(secureURL.path)") 133 | let data = try Data(contentsOf: secureURL) 134 | loadedConfiguration = try jsonDecoder.decode([String: Value].self, from: data) 135 | log.debug( 136 | "Successfully loaded from security-scoped URL. Attempting to refresh bookmark." 137 | ) 138 | try saveSecurityScopedAccess(for: secureURL) // Refresh bookmark 139 | } catch { 140 | log.error( 141 | "Failed to load or decode from security-scoped URL \(secureURL.path): \(error.localizedDescription)" 142 | ) 143 | } 144 | } else { 145 | log.debug( 146 | "Security-scoped URL \(secureURL.path) does not point to an existing file.") 147 | } 148 | } else { 149 | log.debug( 150 | "Failed to start accessing security-scoped resource for URL: \(secureURL.path)") 151 | } 152 | } else { 153 | log.debug("No security-scoped URL obtained or an error occurred retrieving it.") 154 | } 155 | 156 | // 2. If config is still nil (not loaded via security scope), try to load from default direct path 157 | if loadedConfiguration == nil { 158 | let defaultURL = URL(fileURLWithPath: configPath) 159 | log.debug("Attempting to load from default direct path: \(defaultURL.path)") 160 | if FileManager.default.fileExists(atPath: defaultURL.path) { 161 | do { 162 | let data = try Data(contentsOf: defaultURL) 163 | loadedConfiguration = try jsonDecoder.decode([String: Value].self, from: data) 164 | log.debug( 165 | "Successfully loaded from default path. Attempting to save security bookmark for it." 166 | ) 167 | try saveSecurityScopedAccess(for: defaultURL) // Establish bookmark if loaded directly 168 | } catch { 169 | log.error( 170 | "Failed to load or decode from default path \(defaultURL.path): \(error.localizedDescription)" 171 | ) 172 | } 173 | } else { 174 | log.debug("Default config file \(defaultURL.path) does not exist.") 175 | } 176 | } 177 | 178 | // 3. Use loaded configuration or fall back to default if still nil 179 | let finalConfig = 180 | loadedConfiguration 181 | ?? { 182 | log.notice( 183 | "No existing config found or accessible after all attempts. Creating a new default configuration." 184 | ) 185 | return ["mcpServers": .object([:])] 186 | }() 187 | 188 | return (finalConfig, imcpServer) 189 | } 190 | 191 | private func updateConfig( 192 | _ config: [String: Value], 193 | upserting imcpServer: ClaudeDesktop.Config.MCPServer 194 | ) 195 | throws 196 | { 197 | // Update the iMCP server entry 198 | var updatedConfig = config 199 | let imcpServerValue = try Value(imcpServer) 200 | 201 | if var mcpServers = config["mcpServers"]?.objectValue { 202 | mcpServers["iMCP"] = imcpServerValue 203 | updatedConfig["mcpServers"] = .object(mcpServers) 204 | } else { 205 | updatedConfig["mcpServers"] = .object(["iMCP": imcpServerValue]) 206 | } 207 | 208 | // First try with the security-scoped URL if available 209 | if let secureURL = try? getSecurityScopedConfigURL() { 210 | if secureURL.startAccessingSecurityScopedResource() { 211 | defer { secureURL.stopAccessingSecurityScopedResource() } 212 | do { 213 | try writeConfig(updatedConfig, to: secureURL) 214 | return 215 | } catch { 216 | log.error("Failed to write to security-scoped URL: \(error.localizedDescription)") 217 | // Continue to fallback options 218 | } 219 | } else { 220 | log.error("Failed to access security-scoped resource") 221 | } 222 | } 223 | 224 | // Then try to use the default path directly if it exists and is writable 225 | let defaultURL = URL(fileURLWithPath: configPath) 226 | if FileManager.default.fileExists(atPath: configPath) { 227 | do { 228 | // Test if we can write to this file 229 | if FileManager.default.isWritableFile(atPath: configPath) { 230 | try writeConfig(updatedConfig, to: defaultURL) 231 | 232 | // Since we succeeded with direct path, create a bookmark for future use 233 | try? saveSecurityScopedAccess(for: defaultURL) 234 | return 235 | } 236 | } catch { 237 | log.error("Failed to write to default config path: \(error.localizedDescription)") 238 | // Continue to show save panel 239 | } 240 | } 241 | 242 | // Finally, show save panel as a last resort 243 | log.debug("Showing save panel for new configuration location") 244 | let savePanel = NSSavePanel() 245 | savePanel.message = "Choose where to save the iMCP server settings." 246 | savePanel.prompt = "Set Up" 247 | savePanel.allowedContentTypes = [.json] 248 | savePanel.directoryURL = URL(fileURLWithPath: configPath).deletingLastPathComponent() 249 | savePanel.nameFieldStringValue = "claude_desktop_config.json" 250 | savePanel.canCreateDirectories = true 251 | savePanel.showsHiddenFiles = false 252 | 253 | guard savePanel.runModal() == .OK, let selectedURL = savePanel.url else { 254 | log.error("No location selected to save configuration") 255 | throw ClaudeDesktop.Error.noLocationSelected 256 | } 257 | 258 | // Create the file first 259 | log.debug("Creating configuration at selected URL: \(selectedURL.path)") 260 | do { 261 | try writeConfig(updatedConfig, to: selectedURL) 262 | 263 | // Then create the security-scoped bookmark 264 | log.debug("Creating security-scoped access for selected URL") 265 | try saveSecurityScopedAccess(for: selectedURL) 266 | } catch { 267 | log.error("Failed to write config to selected URL: \(error)") 268 | throw error 269 | } 270 | } 271 | 272 | private func writeConfig(_ config: [String: Value], to url: URL) throws { 273 | log.debug("Creating directory if needed: \(url.deletingLastPathComponent().path)") 274 | try FileManager.default.createDirectory( 275 | at: url.deletingLastPathComponent(), 276 | withIntermediateDirectories: true, 277 | attributes: nil 278 | ) 279 | 280 | log.debug("Encoding and writing configuration") 281 | let data = try jsonEncoder.encode(config) 282 | try data.write(to: url, options: .atomic) 283 | log.notice("Successfully saved config to \(url.path)") 284 | } 285 | -------------------------------------------------------------------------------- /App/Models/Service.swift: -------------------------------------------------------------------------------- 1 | @preconcurrency 2 | protocol Service { 3 | @ToolBuilder var tools: [Tool] { get } 4 | 5 | var isActivated: Bool { get async } 6 | func activate() async throws 7 | } 8 | 9 | extension Service { 10 | var isActivated: Bool { 11 | get async { 12 | return true 13 | } 14 | } 15 | 16 | func activate() async throws {} 17 | 18 | func call(tool name: String, with arguments: [String: Value]) async throws -> Value? { 19 | for tool in tools where tool.name == name { 20 | return try await tool.callAsFunction(arguments) 21 | } 22 | 23 | return nil 24 | } 25 | } 26 | 27 | @resultBuilder 28 | struct ToolBuilder { 29 | static func buildBlock(_ tools: Tool...) -> [Tool] { 30 | tools 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /App/Models/Tool.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import JSONSchema 3 | import MCP 4 | import Ontology 5 | 6 | public struct Tool: Sendable { 7 | let name: String 8 | let description: String 9 | let inputSchema: JSONSchema 10 | let annotations: MCP.Tool.Annotations 11 | private let implementation: @Sendable ([String: Value]) async throws -> Value 12 | 13 | public init( 14 | name: String, 15 | description: String, 16 | inputSchema: JSONSchema, 17 | annotations: MCP.Tool.Annotations, 18 | implementation: @Sendable @escaping ([String: Value]) async throws -> T 19 | ) { 20 | self.name = name 21 | self.description = description 22 | self.inputSchema = inputSchema 23 | self.annotations = annotations 24 | self.implementation = { input in 25 | let result = try await implementation(input) 26 | 27 | let encoder = JSONEncoder() 28 | encoder.userInfo[Ontology.DateTime.timeZoneOverrideKey] = 29 | TimeZone.current 30 | encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] 31 | 32 | let data = try encoder.encode(result) 33 | 34 | let decoder = JSONDecoder() 35 | return try decoder.decode(Value.self, from: data) 36 | } 37 | } 38 | 39 | public func callAsFunction(_ input: [String: Value]) async throws -> Value { 40 | try await implementation(input) 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /App/Models/Value.swift: -------------------------------------------------------------------------------- 1 | @_exported import enum MCP.Value 2 | -------------------------------------------------------------------------------- /App/Services/Calendar.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import CoreLocation 3 | import EventKit 4 | import Foundation 5 | import OSLog 6 | import Ontology 7 | 8 | private let log = Logger.service("calendar") 9 | 10 | final class CalendarService: Service { 11 | private let eventStore = EKEventStore() 12 | 13 | static let shared = CalendarService() 14 | 15 | var isActivated: Bool { 16 | get async { 17 | return EKEventStore.authorizationStatus(for: .event) == .fullAccess 18 | } 19 | } 20 | 21 | func activate() async throws { 22 | try await eventStore.requestFullAccessToEvents() 23 | } 24 | 25 | var tools: [Tool] { 26 | Tool( 27 | name: "calendars_list", 28 | description: "List available calendars", 29 | inputSchema: .object( 30 | properties: [:], 31 | additionalProperties: false 32 | ), 33 | annotations: .init( 34 | title: "List Calendars", 35 | readOnlyHint: true, 36 | openWorldHint: false 37 | ) 38 | ) { arguments in 39 | guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { 40 | log.error("Calendar access not authorized") 41 | throw NSError( 42 | domain: "CalendarError", code: 1, 43 | userInfo: [NSLocalizedDescriptionKey: "Calendar access not authorized"] 44 | ) 45 | } 46 | 47 | let calendars = self.eventStore.calendars(for: .event) 48 | 49 | return calendars.map { calendar in 50 | Value.object([ 51 | "title": .string(calendar.title), 52 | "source": .string(calendar.source.title), 53 | "color": .string(calendar.color.accessibilityName), 54 | "isEditable": .bool(calendar.allowsContentModifications), 55 | "isSubscribed": .bool(calendar.isSubscribed), 56 | ]) 57 | } 58 | } 59 | 60 | Tool( 61 | name: "events_fetch", 62 | description: "Get events from the calendar with flexible filtering options", 63 | inputSchema: .object( 64 | properties: [ 65 | "start": .string( 66 | description: "Start date of the range (defaults to now)", 67 | format: .dateTime 68 | ), 69 | "end": .string( 70 | description: "End date of the range (defaults to one week from start)", 71 | format: .dateTime 72 | ), 73 | "calendars": .array( 74 | description: 75 | "Names of calendars to fetch from; if empty, fetches from all calendars", 76 | items: .string(), 77 | ), 78 | "query": .string( 79 | description: "Text to search for in event titles and locations" 80 | ), 81 | "includeAllDay": .boolean( 82 | default: true 83 | ), 84 | "status": .string( 85 | description: "Filter by event status", 86 | enum: ["none", "tentative", "confirmed", "canceled"] 87 | ), 88 | "availability": .string( 89 | description: "Filter by availability status", 90 | enum: EKEventAvailability.allCases.map { .string($0.stringValue) } 91 | ), 92 | "hasAlarms": .boolean(), 93 | "isRecurring": .boolean(), 94 | ], 95 | additionalProperties: false 96 | ), 97 | annotations: .init( 98 | title: "Fetch Events", 99 | readOnlyHint: true, 100 | openWorldHint: false 101 | ) 102 | ) { arguments in 103 | guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { 104 | log.error("Calendar access not authorized") 105 | throw NSError( 106 | domain: "CalendarError", code: 1, 107 | userInfo: [NSLocalizedDescriptionKey: "Calendar access not authorized"] 108 | ) 109 | } 110 | 111 | // Filter calendars based on provided names 112 | var calendars = self.eventStore.calendars(for: .event) 113 | if case let .array(calendarNames) = arguments["calendars"], 114 | !calendarNames.isEmpty 115 | { 116 | let requestedNames = Set(calendarNames.compactMap { $0.stringValue?.lowercased() }) 117 | calendars = calendars.filter { requestedNames.contains($0.title.lowercased()) } 118 | } 119 | 120 | // Parse dates and set defaults 121 | let now = Date() 122 | var startDate = now 123 | var endDate = Calendar.current.date(byAdding: .weekOfYear, value: 1, to: now)! 124 | 125 | if case let .string(start) = arguments["start"], 126 | let parsedStart = ISO8601DateFormatter.parseFlexibleISODate(start) 127 | { 128 | startDate = parsedStart 129 | } 130 | 131 | if case let .string(end) = arguments["end"], 132 | let parsedEnd = ISO8601DateFormatter.parseFlexibleISODate(end) 133 | { 134 | endDate = parsedEnd 135 | } 136 | 137 | // Create base predicate for date range and calendars 138 | let predicate = self.eventStore.predicateForEvents( 139 | withStart: startDate, 140 | end: endDate, 141 | calendars: calendars 142 | ) 143 | 144 | // Fetch events 145 | var events = self.eventStore.events(matching: predicate) 146 | 147 | // Apply additional filters 148 | if case let .bool(includeAllDay) = arguments["includeAllDay"], 149 | !includeAllDay 150 | { 151 | events = events.filter { !$0.isAllDay } 152 | } 153 | 154 | if case let .string(searchText) = arguments["query"], 155 | !searchText.isEmpty 156 | { 157 | events = events.filter { 158 | ($0.title?.localizedCaseInsensitiveContains(searchText) == true) 159 | || ($0.location?.localizedCaseInsensitiveContains(searchText) == true) 160 | } 161 | } 162 | 163 | if case let .string(status) = arguments["status"] { 164 | let statusValue = EKEventStatus(status) 165 | events = events.filter { $0.status == statusValue } 166 | } 167 | 168 | if case let .string(availability) = arguments["availability"] { 169 | let availabilityValue = EKEventAvailability(availability) 170 | events = events.filter { $0.availability == availabilityValue } 171 | } 172 | 173 | if case let .bool(hasAlarms) = arguments["hasAlarms"] { 174 | events = events.filter { ($0.hasAlarms) == hasAlarms } 175 | } 176 | 177 | if case let .bool(isRecurring) = arguments["isRecurring"] { 178 | events = events.filter { ($0.hasRecurrenceRules) == isRecurring } 179 | } 180 | 181 | return events.map { Event($0) } 182 | } 183 | Tool( 184 | name: "events_create", 185 | description: "Create a new calendar event with specified properties", 186 | inputSchema: .object( 187 | properties: [ 188 | "title": .string(), 189 | "start": .string( 190 | format: .dateTime 191 | ), 192 | "end": .string( 193 | format: .dateTime 194 | ), 195 | "calendar": .string( 196 | description: "Calendar to use (uses default if not specified)" 197 | ), 198 | "location": .string(), 199 | "notes": .string(), 200 | "url": .string( 201 | format: .uri 202 | ), 203 | "isAllDay": .boolean( 204 | default: false 205 | ), 206 | "availability": .string( 207 | description: "Availability status", 208 | default: .string(EKEventAvailability.busy.stringValue), 209 | enum: EKEventAvailability.allCases.map { .string($0.stringValue) } 210 | ), 211 | "alarms": .array( 212 | description: "Alarm configurations for the event", 213 | items: .anyOf( 214 | [ 215 | // Relative alarm (minutes before event) 216 | .object( 217 | properties: [ 218 | "type": .string( 219 | const: "relative", 220 | ), 221 | "minutes": .integer( 222 | description: 223 | "Minutes offset from event start (negative for before, positive for after)" 224 | ), 225 | "sound": .string( 226 | description: "Sound name to play when alarm triggers", 227 | enum: Sound.allCases.map { .string($0.rawValue) } 228 | ), 229 | "emailAddress": .string( 230 | description: "Email address to send notification to" 231 | ), 232 | ], 233 | required: ["minutes"], 234 | additionalProperties: false 235 | ), 236 | // Absolute alarm (specific date/time) 237 | .object( 238 | properties: [ 239 | "type": .string( 240 | const: "absolute", 241 | ), 242 | "datetime": .string( 243 | format: .dateTime 244 | ), 245 | "sound": .string( 246 | description: "Sound name to play when alarm triggers", 247 | enum: Sound.allCases.map { .string($0.rawValue) } 248 | ), 249 | "emailAddress": .string( 250 | description: "Email address to send notification to" 251 | ), 252 | ], 253 | required: ["datetime"], 254 | additionalProperties: false 255 | ), 256 | // Proximity alarm (location-based) 257 | .object( 258 | properties: [ 259 | "type": .string( 260 | const: "proximity", 261 | ), 262 | "proximity": .string( 263 | description: "Proximity trigger type", 264 | default: "enter", 265 | enum: ["enter", "leave"] 266 | ), 267 | "locationTitle": .string(), 268 | "latitude": .number(), 269 | "longitude": .number(), 270 | "radius": .number( 271 | description: "Radius in meters", 272 | default: .int(200) 273 | ), 274 | "sound": .string( 275 | description: "Sound name to play when alarm triggers", 276 | enum: Sound.allCases.map { .string($0.rawValue) } 277 | ), 278 | "emailAddress": .string( 279 | description: "Email address to send notification to" 280 | ), 281 | ], 282 | required: ["locationTitle", "latitude", "longitude"], 283 | additionalProperties: false 284 | ), 285 | ] 286 | ) 287 | ), 288 | "hasAlarms": .boolean(), 289 | "isRecurring": .boolean(), 290 | ], 291 | required: ["title", "start", "end"], 292 | additionalProperties: false 293 | ), 294 | annotations: .init( 295 | title: "Create Event", 296 | destructiveHint: true, 297 | openWorldHint: false 298 | ) 299 | ) { arguments in 300 | try await self.activate() 301 | 302 | guard EKEventStore.authorizationStatus(for: .event) == .fullAccess else { 303 | log.error("Calendar access not authorized") 304 | throw NSError( 305 | domain: "CalendarError", code: 1, 306 | userInfo: [NSLocalizedDescriptionKey: "Calendar access not authorized"] 307 | ) 308 | } 309 | 310 | // Create new event 311 | let event = EKEvent(eventStore: self.eventStore) 312 | 313 | // Set required properties 314 | guard case let .string(title) = arguments["title"] else { 315 | throw NSError( 316 | domain: "CalendarError", code: 2, 317 | userInfo: [NSLocalizedDescriptionKey: "Event title is required"] 318 | ) 319 | } 320 | event.title = title 321 | 322 | // Parse dates 323 | guard case let .string(startDateStr) = arguments["start"], 324 | let startDate = ISO8601DateFormatter.parseFlexibleISODate(startDateStr), 325 | case let .string(endDateStr) = arguments["end"], 326 | let endDate = ISO8601DateFormatter.parseFlexibleISODate(endDateStr) 327 | else { 328 | throw NSError( 329 | domain: "CalendarError", code: 2, 330 | userInfo: [ 331 | NSLocalizedDescriptionKey: 332 | "Invalid start or end date format. Expected ISO 8601 format." 333 | ] 334 | ) 335 | } 336 | 337 | // For all-day events, ensure we use local midnight 338 | if case .bool(true) = arguments["isAllDay"] { 339 | let calendar = Calendar.current 340 | var startComponents = calendar.dateComponents( 341 | [.year, .month, .day], from: startDate) 342 | startComponents.hour = 0 343 | startComponents.minute = 0 344 | startComponents.second = 0 345 | 346 | var endComponents = calendar.dateComponents([.year, .month, .day], from: endDate) 347 | endComponents.hour = 23 348 | endComponents.minute = 59 349 | endComponents.second = 59 350 | 351 | event.startDate = calendar.date(from: startComponents)! 352 | event.endDate = calendar.date(from: endComponents)! 353 | event.isAllDay = true 354 | } else { 355 | event.startDate = startDate 356 | event.endDate = endDate 357 | } 358 | 359 | // Set calendar 360 | var calendar = self.eventStore.defaultCalendarForNewEvents 361 | if case let .string(calendarName) = arguments["calendar"] { 362 | if let matchingCalendar = self.eventStore.calendars(for: .event) 363 | .first(where: { $0.title.lowercased() == calendarName.lowercased() }) 364 | { 365 | calendar = matchingCalendar 366 | } 367 | } 368 | event.calendar = calendar 369 | 370 | // Set optional properties 371 | if case let .string(location) = arguments["location"] { 372 | event.location = location 373 | } 374 | 375 | if case let .string(notes) = arguments["notes"] { 376 | event.notes = notes 377 | } 378 | 379 | if case let .string(urlString) = arguments["url"], 380 | let url = URL(string: urlString) 381 | { 382 | event.url = url 383 | } 384 | 385 | if case let .string(availability) = arguments["availability"] { 386 | event.availability = EKEventAvailability(availability) 387 | } 388 | 389 | // Set alarms 390 | if case let .array(alarmConfigs) = arguments["alarms"] { 391 | var alarms: [EKAlarm] = [] 392 | 393 | for alarmConfig in alarmConfigs { 394 | guard case let .object(config) = alarmConfig else { continue } 395 | 396 | var alarm: EKAlarm? 397 | 398 | let alarmType = config["type"]?.stringValue ?? "relative" 399 | switch alarmType { 400 | case "relative": 401 | if case let .int(minutes) = config["minutes"] { 402 | alarm = EKAlarm(relativeOffset: TimeInterval(-minutes * 60)) 403 | } 404 | 405 | case "absolute": 406 | if case let .string(datetimeStr) = config["datetime"], 407 | let absoluteDate = ISO8601DateFormatter.parseFlexibleISODate( 408 | datetimeStr) 409 | { 410 | alarm = EKAlarm(absoluteDate: absoluteDate) 411 | } 412 | 413 | case "proximity": 414 | if case let .string(locationTitle) = config["locationTitle"], 415 | case let .double(latitude) = config["latitude"], 416 | case let .double(longitude) = config["longitude"] 417 | { 418 | alarm = EKAlarm() 419 | 420 | // Create structured location 421 | let structuredLocation = EKStructuredLocation(title: locationTitle) 422 | structuredLocation.geoLocation = CLLocation( 423 | latitude: latitude, longitude: longitude) 424 | 425 | if case let .double(radius) = config["radius"] { 426 | structuredLocation.radius = radius 427 | } else if case let .int(radiusInt) = config["radius"] { 428 | structuredLocation.radius = Double(radiusInt) 429 | } 430 | 431 | // Set proximity type 432 | let proximityType = config["proximity"]?.stringValue ?? "enter" 433 | let proximity: EKAlarmProximity = 434 | proximityType == "enter" ? .enter : .leave 435 | alarm?.proximity = proximity 436 | alarm?.structuredLocation = structuredLocation 437 | } 438 | 439 | default: 440 | log.error("Unexpected alarm type encountered: \(alarmType, privacy: .public)") 441 | continue 442 | } 443 | 444 | guard let alarm = alarm else { continue } 445 | 446 | if case let .string(soundName) = config["sound"], 447 | Sound(rawValue: soundName) != nil 448 | { 449 | alarm.soundName = soundName 450 | } 451 | 452 | if case let .string(email) = config["emailAddress"], !email.isEmpty { 453 | alarm.emailAddress = email 454 | } 455 | 456 | alarms.append(alarm) 457 | } 458 | 459 | event.alarms = alarms 460 | } 461 | 462 | // Save the event 463 | try self.eventStore.save(event, span: .thisEvent) 464 | 465 | return Event(event) 466 | } 467 | } 468 | } 469 | -------------------------------------------------------------------------------- /App/Services/Contacts.swift: -------------------------------------------------------------------------------- 1 | import Contacts 2 | import Foundation 3 | import JSONSchema 4 | import OSLog 5 | import Ontology 6 | import OrderedCollections 7 | 8 | private let log = Logger.service("contacts") 9 | 10 | private let contactKeys = 11 | [ 12 | CNContactTypeKey, 13 | CNContactGivenNameKey, 14 | CNContactFamilyNameKey, 15 | CNContactBirthdayKey, 16 | CNContactOrganizationNameKey, 17 | CNContactJobTitleKey, 18 | CNContactPhoneNumbersKey, 19 | CNContactEmailAddressesKey, 20 | CNContactInstantMessageAddressesKey, 21 | CNContactSocialProfilesKey, 22 | CNContactUrlAddressesKey, 23 | CNContactPostalAddressesKey, 24 | CNContactRelationsKey, 25 | ] as [CNKeyDescriptor] 26 | 27 | private let contactProperties: OrderedDictionary = [ 28 | "givenName": .string(), 29 | "familyName": .string(), 30 | "organizationName": .string(), 31 | "jobTitle": .string(), 32 | "phoneNumbers": .object( 33 | properties: [ 34 | "mobile": .string(), 35 | "work": .string(), 36 | "home": .string(), 37 | ], 38 | additionalProperties: true 39 | ), 40 | "emailAddresses": .object( 41 | properties: [ 42 | "work": .string(), 43 | "home": .string(), 44 | ], 45 | additionalProperties: true 46 | ), 47 | "postalAddresses": .object( 48 | properties: [ 49 | "work": .object( 50 | properties: [ 51 | "street": .string(), 52 | "city": .string(), 53 | "state": .string(), 54 | "postalCode": .string(), 55 | "country": .string(), 56 | ] 57 | ), 58 | "home": .object( 59 | properties: [ 60 | "street": .string(), 61 | "city": .string(), 62 | "state": .string(), 63 | "postalCode": .string(), 64 | "country": .string(), 65 | ] 66 | ), 67 | ], 68 | additionalProperties: true 69 | ), 70 | "birthday": .object( 71 | properties: [ 72 | "day": .integer(minimum: 1, maximum: 31), 73 | "month": .integer(minimum: 1, maximum: 12), 74 | "year": .integer(), 75 | ], 76 | required: ["day", "month"] 77 | ), 78 | ] 79 | 80 | final class ContactsService: Service { 81 | private let contactStore = CNContactStore() 82 | 83 | static let shared = ContactsService() 84 | 85 | var isActivated: Bool { 86 | get async { 87 | let status = CNContactStore.authorizationStatus(for: .contacts) 88 | return status == .authorized 89 | } 90 | } 91 | 92 | func activate() async throws { 93 | log.debug("Activating contacts service") 94 | let status = CNContactStore.authorizationStatus(for: .contacts) 95 | switch status { 96 | case .authorized: 97 | log.debug("Contacts access authorized") 98 | return 99 | case .denied: 100 | log.error("Contacts access denied") 101 | throw NSError( 102 | domain: "ContactsService", code: 1, 103 | userInfo: [NSLocalizedDescriptionKey: "Contacts access denied"] 104 | ) 105 | case .restricted: 106 | log.error("Contacts access restricted") 107 | throw NSError( 108 | domain: "ContactsService", code: 1, 109 | userInfo: [NSLocalizedDescriptionKey: "Contacts access restricted"] 110 | ) 111 | case .notDetermined: 112 | log.debug("Requesting contacts access") 113 | _ = try await contactStore.requestAccess(for: .contacts) 114 | @unknown default: 115 | log.error("Unknown contacts authorization status") 116 | throw NSError( 117 | domain: "ContactsService", code: 1, 118 | userInfo: [NSLocalizedDescriptionKey: "Unknown contacts authorization status"] 119 | ) 120 | } 121 | } 122 | 123 | var tools: [Tool] { 124 | Tool( 125 | name: "contacts_me", 126 | description: 127 | "Get contact information about the user, including name, phone number, email, birthday, relations, address, online presence, and occupation. Always run this tool when the user asks a question that requires personal information about themselves.", 128 | inputSchema: .object( 129 | properties: [:], 130 | additionalProperties: false 131 | ), 132 | annotations: .init( 133 | title: "Who Am I?", 134 | readOnlyHint: true, 135 | openWorldHint: false 136 | ) 137 | ) { _ in 138 | let contact = try self.contactStore.unifiedMeContactWithKeys(toFetch: contactKeys) 139 | return Person(contact) 140 | } 141 | 142 | Tool( 143 | name: "contacts_search", 144 | description: 145 | "Search contacts by name, phone number, and/or email", 146 | inputSchema: .object( 147 | properties: [ 148 | "name": .string( 149 | description: "Name to search for" 150 | ), 151 | "phone": .string( 152 | description: "Phone number to search for" 153 | ), 154 | "email": .string( 155 | description: "Email address to search for" 156 | ), 157 | ], 158 | additionalProperties: false 159 | ), 160 | annotations: .init( 161 | title: "Search Contacts", 162 | readOnlyHint: true, 163 | openWorldHint: false 164 | ) 165 | ) { arguments in 166 | var predicates: [NSPredicate] = [] 167 | 168 | if case let .string(name) = arguments["name"] { 169 | let normalizedName = name.trimmingCharacters(in: .whitespaces) 170 | if !normalizedName.isEmpty { 171 | predicates.append(CNContact.predicateForContacts(matchingName: normalizedName)) 172 | } 173 | } 174 | 175 | if case let .string(phone) = arguments["phone"] { 176 | let phoneNumber = CNPhoneNumber(stringValue: phone) 177 | predicates.append(CNContact.predicateForContacts(matching: phoneNumber)) 178 | } 179 | 180 | if case let .string(email) = arguments["email"] { 181 | // Normalize email to lowercase 182 | let normalizedEmail = email.trimmingCharacters(in: .whitespaces).lowercased() 183 | if !normalizedEmail.isEmpty { 184 | predicates.append( 185 | CNContact.predicateForContacts(matchingEmailAddress: normalizedEmail)) 186 | } 187 | } 188 | 189 | guard !predicates.isEmpty else { 190 | throw NSError( 191 | domain: "ContactsService", code: 1, 192 | userInfo: [ 193 | NSLocalizedDescriptionKey: "At least one valid search parameter is required" 194 | ] 195 | ) 196 | } 197 | 198 | // Combine predicates with AND if multiple criteria are provided 199 | let finalPredicate = 200 | predicates.count == 1 201 | ? predicates[0] 202 | : NSCompoundPredicate(andPredicateWithSubpredicates: predicates) 203 | 204 | let contacts = try self.contactStore.unifiedContacts( 205 | matching: finalPredicate, 206 | keysToFetch: contactKeys 207 | ) 208 | 209 | return contacts.compactMap { Person($0) } 210 | } 211 | 212 | Tool( 213 | name: "contacts_update", 214 | description: 215 | "Update an existing contact's information. Only provide values for properties that need to be changed; omit any properties that should remain unchanged.", 216 | inputSchema: .object( 217 | properties: ([ 218 | "identifier": .string( 219 | description: "Unique identifier of the contact to update" 220 | ) 221 | ] as OrderedDictionary).merging( 222 | contactProperties, uniquingKeysWith: { new, _ in new }), 223 | required: ["identifier"] 224 | ), 225 | annotations: .init( 226 | title: "Update Contact", 227 | readOnlyHint: false, 228 | destructiveHint: true, 229 | openWorldHint: false 230 | ) 231 | ) { arguments in 232 | guard case let .string(identifier) = arguments["identifier"], !identifier.isEmpty else { 233 | throw NSError( 234 | domain: "ContactsService", code: 1, 235 | userInfo: [NSLocalizedDescriptionKey: "Valid contact identifier required"] 236 | ) 237 | } 238 | 239 | // Fetch the mutable copy of the contact 240 | let predicate = CNContact.predicateForContacts(withIdentifiers: [identifier]) 241 | let contact = 242 | try self.contactStore.unifiedContacts(matching: predicate, keysToFetch: contactKeys) 243 | .first? 244 | .mutableCopy() as? CNMutableContact 245 | 246 | guard let updatedContact = contact else { 247 | throw NSError( 248 | domain: "ContactsService", code: 2, 249 | userInfo: [ 250 | NSLocalizedDescriptionKey: 251 | "Contact not found with identifier: \(identifier)" 252 | ] 253 | ) 254 | } 255 | 256 | // Update all properties 257 | updatedContact.populate(from: arguments) 258 | 259 | // Create a save request 260 | let saveRequest = CNSaveRequest() 261 | saveRequest.update(updatedContact) 262 | 263 | // Save the changes 264 | try self.contactStore.execute(saveRequest) 265 | 266 | return Person(updatedContact) 267 | } 268 | 269 | Tool( 270 | name: "contacts_create", 271 | description: 272 | "Create a new contact with the specified information.", 273 | inputSchema: .object( 274 | properties: contactProperties, 275 | required: ["givenName"] 276 | ), 277 | annotations: .init( 278 | title: "Create Contact", 279 | readOnlyHint: false, 280 | openWorldHint: false 281 | ) 282 | ) { arguments in 283 | // Create and populate a new contact 284 | let newContact = CNMutableContact() 285 | newContact.populate(from: arguments) 286 | 287 | // Validate that given name is provided and not empty 288 | if newContact.givenName.isEmpty { 289 | throw NSError( 290 | domain: "ContactsService", code: 1, 291 | userInfo: [NSLocalizedDescriptionKey: "Given name is required"] 292 | ) 293 | } 294 | 295 | // Create a save request 296 | let saveRequest = CNSaveRequest() 297 | saveRequest.add(newContact, toContainerWithIdentifier: nil) 298 | 299 | // Execute the save request 300 | try self.contactStore.execute(saveRequest) 301 | 302 | return Person(newContact) 303 | } 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /App/Services/Location.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | import Foundation 3 | import OSLog 4 | import Ontology 5 | 6 | private let log = Logger.service("location") 7 | 8 | final class LocationService: NSObject, Service, CLLocationManagerDelegate { 9 | private let locationManager = { 10 | let manager = CLLocationManager() 11 | manager.activityType = .other 12 | manager.desiredAccuracy = kCLLocationAccuracyKilometer 13 | manager.distanceFilter = kCLDistanceFilterNone 14 | manager.pausesLocationUpdatesAutomatically = true 15 | return manager 16 | }() 17 | private var latestLocation: CLLocation? 18 | private var authorizationContinuation: CheckedContinuation? 19 | 20 | static let shared = LocationService() 21 | 22 | override init() { 23 | log.debug("Initializing location service") 24 | 25 | super.init() 26 | locationManager.delegate = self 27 | 28 | // Check authorization status first to avoid any permission prompts 29 | let status = locationManager.authorizationStatus 30 | if (status == .authorizedAlways) && CLLocationManager.locationServicesEnabled() { 31 | log.debug("Starting location updates with existing authorization...") 32 | locationManager.startUpdatingLocation() 33 | } 34 | } 35 | 36 | deinit { 37 | log.info("Deinitializing location service, stopping updates...") 38 | locationManager.stopUpdatingLocation() 39 | } 40 | 41 | var isActivated: Bool { 42 | get async { 43 | return locationManager.authorizationStatus == .authorizedAlways 44 | } 45 | } 46 | 47 | func activate() async throws { 48 | try await withCheckedThrowingContinuation { 49 | (continuation: CheckedContinuation) in 50 | self.authorizationContinuation = continuation 51 | locationManager.delegate = self 52 | 53 | // Check current authorization status first 54 | let status = locationManager.authorizationStatus 55 | switch status { 56 | case .authorizedWhenInUse, .authorizedAlways: 57 | // Already authorized, resume immediately 58 | log.debug("Location access authorized") 59 | continuation.resume() 60 | self.authorizationContinuation = nil 61 | case .denied, .restricted: 62 | // Already denied, throw error immediately 63 | log.error("Location access denied") 64 | continuation.resume( 65 | throwing: NSError( 66 | domain: "LocationServiceError", 67 | code: 7, 68 | userInfo: [NSLocalizedDescriptionKey: "Location access denied"] 69 | )) 70 | self.authorizationContinuation = nil 71 | case .notDetermined: 72 | // Need to request authorization 73 | log.debug("Requesting location access") 74 | locationManager.requestWhenInUseAuthorization() 75 | @unknown default: 76 | // Handle unknown future cases 77 | log.error("Unknown location authorization status") 78 | continuation.resume( 79 | throwing: NSError( 80 | domain: "LocationServiceError", 81 | code: 8, 82 | userInfo: [NSLocalizedDescriptionKey: "Unknown authorization status"] 83 | )) 84 | self.authorizationContinuation = nil 85 | } 86 | } 87 | } 88 | 89 | var tools: [Tool] { 90 | Tool( 91 | name: "location_current", 92 | description: "Get the user's current location", 93 | inputSchema: .object( 94 | properties: [:], 95 | additionalProperties: false 96 | ), 97 | annotations: .init( 98 | title: "Get Current Location", 99 | readOnlyHint: true, 100 | openWorldHint: false 101 | ) 102 | ) { _ in 103 | return try await withCheckedThrowingContinuation { 104 | (continuation: CheckedContinuation) in 105 | Task { 106 | let status = self.locationManager.authorizationStatus 107 | 108 | guard status == .authorizedAlways else { 109 | log.error("Location access not authorized") 110 | continuation.resume( 111 | throwing: NSError( 112 | domain: "LocationServiceError", code: 1, 113 | userInfo: [ 114 | NSLocalizedDescriptionKey: "Location access not authorized" 115 | ] 116 | )) 117 | return 118 | } 119 | 120 | // If we already have a recent location, use it 121 | if let location = self.latestLocation { 122 | continuation.resume( 123 | returning: GeoCoordinates(location)) 124 | return 125 | } 126 | 127 | // Otherwise, request a new location update 128 | self.locationManager.desiredAccuracy = kCLLocationAccuracyHundredMeters 129 | self.locationManager.startUpdatingLocation() 130 | 131 | // Modern timeout pattern using task group 132 | let location = await withTaskGroup(of: CLLocation?.self) { group in 133 | // Start location monitoring task 134 | group.addTask { 135 | while self.latestLocation == nil { 136 | try? await Task.sleep(nanoseconds: 100_000_000) 137 | if Task.isCancelled { return nil } 138 | } 139 | return self.latestLocation 140 | } 141 | 142 | // Start timeout task 143 | group.addTask { 144 | try? await Task.sleep(nanoseconds: 10_000_000_000) // 10 seconds 145 | return nil 146 | } 147 | 148 | // Return first non-nil result or nil if timeout 149 | for await result in group { 150 | group.cancelAll() 151 | return result 152 | } 153 | 154 | return nil 155 | } 156 | 157 | self.locationManager.stopUpdatingLocation() 158 | 159 | if let location = location { 160 | continuation.resume( 161 | returning: GeoCoordinates(location)) 162 | } else { 163 | continuation.resume( 164 | throwing: NSError( 165 | domain: "LocationServiceError", code: 2, 166 | userInfo: [NSLocalizedDescriptionKey: "Failed to get location"] 167 | )) 168 | } 169 | } 170 | } 171 | } 172 | 173 | Tool( 174 | name: "location_geocode", 175 | description: "Convert an address to geographic coordinates", 176 | inputSchema: .object( 177 | properties: [ 178 | "address": .string( 179 | description: "Address to geocode" 180 | ) 181 | ], 182 | required: ["address"], 183 | additionalProperties: false 184 | ), 185 | annotations: .init( 186 | title: "Geocode Address", 187 | readOnlyHint: true, 188 | openWorldHint: true 189 | ) 190 | ) { arguments in 191 | guard let address = arguments["address"]?.stringValue else { 192 | throw NSError( 193 | domain: "LocationServiceError", code: 3, 194 | userInfo: [NSLocalizedDescriptionKey: "Invalid address"] 195 | ) 196 | } 197 | 198 | return try await withCheckedThrowingContinuation { 199 | (continuation: CheckedContinuation) in 200 | let geocoder = CLGeocoder() 201 | 202 | geocoder.geocodeAddressString(address) { placemarks, error in 203 | if let error = error { 204 | continuation.resume(throwing: error) 205 | return 206 | } 207 | 208 | guard let placemark = placemarks?.first, let location = placemark.location 209 | else { 210 | continuation.resume( 211 | throwing: NSError( 212 | domain: "LocationServiceError", code: 4, 213 | userInfo: [ 214 | NSLocalizedDescriptionKey: "No location found for address" 215 | ] 216 | )) 217 | return 218 | } 219 | 220 | var result: [String: Value] = [ 221 | "@context": .string("https://schema.org"), 222 | "@type": .string("Place"), 223 | "geo": .object([ 224 | "@type": .string("GeoCoordinates"), 225 | "latitude": .double(location.coordinate.latitude), 226 | "longitude": .double(location.coordinate.longitude), 227 | ]), 228 | ] 229 | 230 | // Add address components if available 231 | if let name = placemark.name { 232 | result["name"] = .string(name) 233 | } 234 | 235 | var addressComponents: [String: Value] = [ 236 | "@type": .string("PostalAddress") 237 | ] 238 | 239 | if let thoroughfare = placemark.thoroughfare { 240 | addressComponents["streetAddress"] = .string(thoroughfare) 241 | } 242 | 243 | if let locality = placemark.locality { 244 | addressComponents["addressLocality"] = .string(locality) 245 | } 246 | 247 | if let administrativeArea = placemark.administrativeArea { 248 | addressComponents["addressRegion"] = .string(administrativeArea) 249 | } 250 | 251 | if let postalCode = placemark.postalCode { 252 | addressComponents["postalCode"] = .string(postalCode) 253 | } 254 | 255 | if let country = placemark.country { 256 | addressComponents["addressCountry"] = .string(country) 257 | } 258 | 259 | if addressComponents.count > 1 { // More than just the @type 260 | result["address"] = .object(addressComponents) 261 | } 262 | 263 | continuation.resume(returning: .object(result)) 264 | } 265 | } 266 | } 267 | 268 | Tool( 269 | name: "location_reverse-geocode", 270 | description: "Convert geographic coordinates to an address", 271 | inputSchema: .object( 272 | properties: [ 273 | "latitude": .number(), 274 | "longitude": .number(), 275 | ], 276 | required: ["latitude", "longitude"] 277 | ), 278 | annotations: .init( 279 | title: "Reverse Geocode Location", 280 | readOnlyHint: true, 281 | openWorldHint: true 282 | ) 283 | ) { arguments in 284 | guard let latitude = arguments["latitude"]?.doubleValue, 285 | let longitude = arguments["longitude"]?.doubleValue 286 | else { 287 | log.error("Invalid coordinates") 288 | throw NSError( 289 | domain: "LocationServiceError", code: 5, 290 | userInfo: [NSLocalizedDescriptionKey: "Invalid coordinates"] 291 | ) 292 | } 293 | 294 | return try await withCheckedThrowingContinuation { 295 | (continuation: CheckedContinuation) in 296 | let location = CLLocation(latitude: latitude, longitude: longitude) 297 | let geocoder = CLGeocoder() 298 | 299 | geocoder.reverseGeocodeLocation(location) { placemarks, error in 300 | if let error = error { 301 | continuation.resume(throwing: error) 302 | return 303 | } 304 | 305 | guard let placemark = placemarks?.first else { 306 | continuation.resume( 307 | throwing: NSError( 308 | domain: "LocationServiceError", code: 6, 309 | userInfo: [ 310 | NSLocalizedDescriptionKey: "No address found for location" 311 | ] 312 | )) 313 | return 314 | } 315 | 316 | var result: [String: Value] = [ 317 | "@context": .string("https://schema.org"), 318 | "@type": .string("Place"), 319 | "geo": .object([ 320 | "@type": .string("GeoCoordinates"), 321 | "latitude": .double(latitude), 322 | "longitude": .double(longitude), 323 | ]), 324 | ] 325 | 326 | // Add address components if available 327 | if let name = placemark.name { 328 | result["name"] = .string(name) 329 | } 330 | 331 | var addressComponents: [String: Value] = [ 332 | "@type": .string("PostalAddress") 333 | ] 334 | 335 | if let thoroughfare = placemark.thoroughfare { 336 | addressComponents["streetAddress"] = .string(thoroughfare) 337 | } 338 | 339 | if let locality = placemark.locality { 340 | addressComponents["addressLocality"] = .string(locality) 341 | } 342 | 343 | if let administrativeArea = placemark.administrativeArea { 344 | addressComponents["addressRegion"] = .string(administrativeArea) 345 | } 346 | 347 | if let postalCode = placemark.postalCode { 348 | addressComponents["postalCode"] = .string(postalCode) 349 | } 350 | 351 | if let country = placemark.country { 352 | addressComponents["addressCountry"] = .string(country) 353 | } 354 | 355 | if addressComponents.count > 1 { // More than just the @type 356 | result["address"] = .object(addressComponents) 357 | } 358 | 359 | continuation.resume(returning: .object(result)) 360 | } 361 | } 362 | } 363 | } 364 | 365 | // MARK: - CLLocationManagerDelegate 366 | 367 | func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { 368 | log.debug("Location manager did update locations") 369 | if let location = locations.last { 370 | self.latestLocation = location 371 | } 372 | } 373 | 374 | func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { 375 | log.error("Location manager failed with error: \(error.localizedDescription)") 376 | } 377 | 378 | func locationManager( 379 | _ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus 380 | ) { 381 | switch status { 382 | case .authorizedWhenInUse, .authorizedAlways: 383 | log.debug("Location access authorized") 384 | authorizationContinuation?.resume() 385 | authorizationContinuation = nil 386 | case .denied, .restricted: 387 | log.error("Location access denied") 388 | authorizationContinuation?.resume( 389 | throwing: NSError( 390 | domain: "LocationServiceError", 391 | code: 7, 392 | userInfo: [NSLocalizedDescriptionKey: "Location access denied"] 393 | )) 394 | authorizationContinuation = nil 395 | case .notDetermined: 396 | log.debug("Location access not determined") 397 | // Wait for the user to make a choice 398 | break 399 | @unknown default: 400 | log.error("Unknown location authorization status") 401 | break 402 | } 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /App/Services/Messages.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import OSLog 3 | import SQLite3 4 | import UniformTypeIdentifiers 5 | import iMessage 6 | 7 | private let log = Logger.service("messages") 8 | private let messagesDatabasePath = "/Users/\(NSUserName())/Library/Messages/chat.db" 9 | private let messagesDatabaseBookmarkKey: String = "com.loopwork.iMCP.messagesDatabaseBookmark" 10 | private let defaultLimit = 30 11 | 12 | final class MessageService: NSObject, Service, NSOpenSavePanelDelegate { 13 | static let shared = MessageService() 14 | 15 | func activate() async throws { 16 | log.debug("Starting message service activation") 17 | 18 | if canAccessDatabaseAtDefaultPath { 19 | log.debug("Successfully activated using default database path") 20 | return 21 | } 22 | 23 | if canAccessDatabaseUsingBookmark { 24 | log.debug("Successfully activated using stored bookmark") 25 | return 26 | } 27 | 28 | log.debug("Opening file picker for manual database selection") 29 | guard try await showDatabaseAccessAlert() else { 30 | throw DatabaseAccessError.userDeclinedAccess 31 | } 32 | 33 | let selectedURL = try await showFilePicker() 34 | 35 | guard FileManager.default.isReadableFile(atPath: selectedURL.path) else { 36 | throw DatabaseAccessError.fileNotReadable 37 | } 38 | 39 | storeBookmark(for: selectedURL) 40 | log.debug("Successfully activated message service") 41 | } 42 | 43 | var isActivated: Bool { 44 | get async { 45 | let isActivated = canAccessDatabaseAtDefaultPath || canAccessDatabaseUsingBookmark 46 | log.debug("Message service activation status: \(isActivated)") 47 | return isActivated 48 | } 49 | } 50 | 51 | var tools: [Tool] { 52 | Tool( 53 | name: "messages_fetch", 54 | description: "Fetch messages from the Messages app", 55 | inputSchema: .object( 56 | properties: [ 57 | "participants": .array( 58 | description: 59 | "Participant handles (phone or email). Phone numbers should use E.164 format", 60 | items: .string() 61 | ), 62 | "start": .string( 63 | description: "Start of the date range (inclusive)", 64 | format: .dateTime 65 | ), 66 | "end": .string( 67 | description: "End of the date range (exclusive)", 68 | format: .dateTime 69 | ), 70 | "query": .string( 71 | description: "Search term to filter messages by content" 72 | ), 73 | "limit": .integer( 74 | description: "Maximum messages to return", 75 | default: .int(defaultLimit) 76 | ), 77 | ], 78 | additionalProperties: false 79 | ), 80 | annotations: .init( 81 | title: "Fetch Messages", 82 | readOnlyHint: true, 83 | openWorldHint: false 84 | ) 85 | ) { arguments in 86 | log.debug("Starting message fetch with arguments: \(arguments)") 87 | try await self.activate() 88 | 89 | let participants = 90 | arguments["participants"]?.arrayValue?.compactMap({ 91 | $0.stringValue 92 | }) ?? [] 93 | 94 | var dateRange: Range? 95 | if let startDateStr = arguments["start"]?.stringValue, 96 | let endDateStr = arguments["end"]?.stringValue, 97 | let startDate = ISO8601DateFormatter.parseFlexibleISODate(startDateStr), 98 | let endDate = ISO8601DateFormatter.parseFlexibleISODate(endDateStr) 99 | { 100 | dateRange = startDate..(_ url: URL, _ operation: (URL) throws -> T) throws -> T 188 | { 189 | guard url.startAccessingSecurityScopedResource() else { 190 | log.error("Failed to start accessing security-scoped resource") 191 | throw DatabaseAccessError.securityScopeAccessFailed 192 | } 193 | defer { url.stopAccessingSecurityScopedResource() } 194 | return try operation(url) 195 | } 196 | 197 | private func resolveBookmarkURL() throws -> URL { 198 | guard let bookmarkData = UserDefaults.standard.data(forKey: messagesDatabaseBookmarkKey) 199 | else { 200 | throw DatabaseAccessError.noBookmarkFound 201 | } 202 | 203 | var isStale = false 204 | return try URL( 205 | resolvingBookmarkData: bookmarkData, 206 | options: .withSecurityScope, 207 | relativeTo: nil, 208 | bookmarkDataIsStale: &isStale 209 | ) 210 | } 211 | 212 | private func createDatabaseConnection() throws -> iMessage.Database { 213 | if canAccessDatabaseAtDefaultPath { 214 | return try iMessage.Database() 215 | } 216 | 217 | let databaseURL = try resolveBookmarkURL() 218 | return try withSecurityScopedAccess(databaseURL) { url in 219 | try iMessage.Database(path: url.path) 220 | } 221 | } 222 | 223 | private var canAccessDatabaseUsingBookmark: Bool { 224 | do { 225 | let url = try resolveBookmarkURL() 226 | return try withSecurityScopedAccess(url) { url in 227 | FileManager.default.isReadableFile(atPath: url.path) 228 | } 229 | } catch { 230 | log.error("Error accessing database with bookmark: \(error.localizedDescription)") 231 | return false 232 | } 233 | } 234 | 235 | @MainActor 236 | private func showDatabaseAccessAlert() async throws -> Bool { 237 | let alert = NSAlert() 238 | alert.messageText = "Messages Database Access Required" 239 | alert.informativeText = """ 240 | To read your Messages history, we need to open your database file. 241 | 242 | In the next screen, please select the file `chat.db` and click "Grant Access". 243 | """ 244 | alert.alertStyle = .informational 245 | alert.addButton(withTitle: "Continue") 246 | alert.addButton(withTitle: "Cancel") 247 | 248 | return alert.runModal() == .alertFirstButtonReturn 249 | } 250 | 251 | @MainActor 252 | private func showFilePicker() async throws -> URL { 253 | let openPanel = NSOpenPanel() 254 | openPanel.delegate = self 255 | openPanel.message = "Please select the Messages database file (chat.db)" 256 | openPanel.prompt = "Grant Access" 257 | openPanel.allowedContentTypes = [UTType.item] 258 | openPanel.directoryURL = URL(fileURLWithPath: messagesDatabasePath) 259 | .deletingLastPathComponent() 260 | openPanel.allowsMultipleSelection = false 261 | openPanel.canChooseDirectories = false 262 | openPanel.canChooseFiles = true 263 | openPanel.showsHiddenFiles = true 264 | 265 | guard openPanel.runModal() == .OK, 266 | let url = openPanel.url, 267 | url.lastPathComponent == "chat.db" 268 | else { 269 | throw DatabaseAccessError.invalidFileSelected 270 | } 271 | 272 | return url 273 | } 274 | 275 | private func storeBookmark(for url: URL) { 276 | do { 277 | let bookmarkData = try url.bookmarkData( 278 | options: .securityScopeAllowOnlyReadAccess, 279 | includingResourceValuesForKeys: nil, 280 | relativeTo: nil 281 | ) 282 | UserDefaults.standard.set(bookmarkData, forKey: messagesDatabaseBookmarkKey) 283 | log.debug("Successfully created and stored bookmark") 284 | } catch { 285 | log.error("Failed to create bookmark: \(error.localizedDescription)") 286 | } 287 | } 288 | 289 | // NSOpenSavePanelDelegate method to constrain file selection 290 | func panel(_ sender: Any, shouldEnable url: URL) -> Bool { 291 | let shouldEnable = url.lastPathComponent == "chat.db" 292 | log.debug( 293 | "File selection panel: \(shouldEnable ? "enabling" : "disabling") URL: \(url.path)") 294 | return shouldEnable 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /App/Services/Reminders.swift: -------------------------------------------------------------------------------- 1 | import EventKit 2 | import Foundation 3 | import OSLog 4 | import Ontology 5 | 6 | private let log = Logger.service("reminders") 7 | 8 | final class RemindersService: Service { 9 | private let eventStore = EKEventStore() 10 | 11 | static let shared = RemindersService() 12 | 13 | var isActivated: Bool { 14 | get async { 15 | return EKEventStore.authorizationStatus(for: .reminder) == .fullAccess 16 | } 17 | } 18 | 19 | func activate() async throws { 20 | try await eventStore.requestFullAccessToReminders() 21 | } 22 | 23 | var tools: [Tool] { 24 | Tool( 25 | name: "reminders_lists", 26 | description: "List available reminder lists", 27 | inputSchema: .object( 28 | properties: [:], 29 | additionalProperties: false 30 | ), 31 | annotations: .init( 32 | title: "List Reminder Lists", 33 | readOnlyHint: true, 34 | openWorldHint: false 35 | ) 36 | ) { arguments in 37 | guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { 38 | log.error("Reminders access not authorized") 39 | throw NSError( 40 | domain: "RemindersError", code: 1, 41 | userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"] 42 | ) 43 | } 44 | 45 | let reminderLists = self.eventStore.calendars(for: .reminder) 46 | 47 | return reminderLists.map { reminderList in 48 | Value.object([ 49 | "title": .string(reminderList.title), 50 | "source": .string(reminderList.source.title), 51 | "color": .string(reminderList.color.accessibilityName), 52 | "isEditable": .bool(reminderList.allowsContentModifications), 53 | "isSubscribed": .bool(reminderList.isSubscribed), 54 | ]) 55 | } 56 | } 57 | 58 | Tool( 59 | name: "reminders_fetch", 60 | description: "Get reminders from the reminders app with flexible filtering options", 61 | inputSchema: .object( 62 | properties: [ 63 | "completed": .boolean( 64 | description: 65 | "If true, fetch completed reminders; if false, fetch incomplete; if omitted, fetch all" 66 | ), 67 | "start": .string( 68 | description: "Start date range for fetching reminders", 69 | format: .dateTime 70 | ), 71 | "end": .string( 72 | description: "End date range for fetching reminders", 73 | format: .dateTime 74 | ), 75 | "lists": .array( 76 | description: 77 | "Names of reminder lists to fetch from; if empty, fetches from all lists", 78 | items: .string() 79 | ), 80 | "query": .string( 81 | description: "Text to search for in reminder titles" 82 | ), 83 | ], 84 | additionalProperties: false 85 | ), 86 | annotations: .init( 87 | title: "Fetch Reminders", 88 | readOnlyHint: true, 89 | openWorldHint: false 90 | ) 91 | ) { arguments in 92 | try await self.activate() 93 | 94 | guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { 95 | log.error("Reminders access not authorized") 96 | throw NSError( 97 | domain: "RemindersError", code: 1, 98 | userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"] 99 | ) 100 | } 101 | 102 | // Filter reminder lists based on provided names 103 | var reminderLists = self.eventStore.calendars(for: .reminder) 104 | if case let .array(listNames) = arguments["lists"], 105 | !listNames.isEmpty 106 | { 107 | let requestedNames = Set( 108 | listNames.compactMap { $0.stringValue?.lowercased() }) 109 | reminderLists = reminderLists.filter { 110 | requestedNames.contains($0.title.lowercased()) 111 | } 112 | } 113 | 114 | // Parse dates if provided 115 | var startDate: Date? = nil 116 | var endDate: Date? = nil 117 | 118 | if case let .string(start) = arguments["start"] { 119 | startDate = ISO8601DateFormatter.parseFlexibleISODate(start) 120 | } 121 | if case let .string(end) = arguments["end"] { 122 | endDate = ISO8601DateFormatter.parseFlexibleISODate(end) 123 | } 124 | 125 | // Create predicate based on completion status 126 | let predicate: NSPredicate 127 | if case let .bool(completed) = arguments["completed"] { 128 | if completed { 129 | predicate = self.eventStore.predicateForCompletedReminders( 130 | withCompletionDateStarting: startDate, 131 | ending: endDate, 132 | calendars: reminderLists 133 | ) 134 | } else { 135 | predicate = self.eventStore.predicateForIncompleteReminders( 136 | withDueDateStarting: startDate, 137 | ending: endDate, 138 | calendars: reminderLists 139 | ) 140 | } 141 | } else { 142 | // If completion status not specified, use incomplete predicate as default 143 | predicate = self.eventStore.predicateForReminders(in: reminderLists) 144 | } 145 | 146 | // Fetch reminders 147 | let reminders = try await withCheckedThrowingContinuation { continuation in 148 | self.eventStore.fetchReminders(matching: predicate) { fetchedReminders in 149 | continuation.resume(returning: fetchedReminders ?? []) 150 | } 151 | } 152 | 153 | // Apply additional filters 154 | var filteredReminders = reminders 155 | 156 | // Filter by search text if provided 157 | if case let .string(searchText) = arguments["query"], 158 | !searchText.isEmpty 159 | { 160 | filteredReminders = filteredReminders.filter { 161 | $0.title?.localizedCaseInsensitiveContains(searchText) == true 162 | } 163 | } 164 | 165 | return filteredReminders.map { PlanAction($0) } 166 | } 167 | 168 | Tool( 169 | name: "reminders_create", 170 | description: "Create a new reminder with specified properties", 171 | inputSchema: .object( 172 | properties: [ 173 | "title": .string(), 174 | "due": .string( 175 | format: .dateTime 176 | ), 177 | "list": .string( 178 | description: "Reminder list name (uses default if not specified)" 179 | ), 180 | "notes": .string(), 181 | "priority": .string( 182 | default: .string(EKReminderPriority.none.stringValue), 183 | enum: EKReminderPriority.allCases.map { .string($0.stringValue) } 184 | ), 185 | "alarms": .array( 186 | description: "Minutes before due date to set alarms", 187 | items: .integer() 188 | ), 189 | ], 190 | required: ["title"], 191 | additionalProperties: false 192 | ), 193 | annotations: .init( 194 | title: "Create Reminder", 195 | destructiveHint: true, 196 | openWorldHint: false 197 | ) 198 | ) { arguments in 199 | try await self.activate() 200 | 201 | guard EKEventStore.authorizationStatus(for: .reminder) == .fullAccess else { 202 | log.error("Reminders access not authorized") 203 | throw NSError( 204 | domain: "RemindersError", code: 1, 205 | userInfo: [NSLocalizedDescriptionKey: "Reminders access not authorized"] 206 | ) 207 | } 208 | 209 | let reminder = EKReminder(eventStore: self.eventStore) 210 | 211 | // Set required properties 212 | guard case let .string(title) = arguments["title"] else { 213 | throw NSError( 214 | domain: "RemindersError", code: 2, 215 | userInfo: [NSLocalizedDescriptionKey: "Reminder title is required"] 216 | ) 217 | } 218 | reminder.title = title 219 | 220 | // Set calendar (list) 221 | var calendar = self.eventStore.defaultCalendarForNewReminders() 222 | if case let .string(listName) = arguments["list"] { 223 | if let matchingCalendar = self.eventStore.calendars(for: .reminder) 224 | .first(where: { $0.title.lowercased() == listName.lowercased() }) 225 | { 226 | calendar = matchingCalendar 227 | } 228 | } 229 | reminder.calendar = calendar 230 | 231 | // Set optional properties 232 | if case let .string(dueDateStr) = arguments["due"], 233 | let dueDate = ISO8601DateFormatter.parseFlexibleISODate(dueDateStr) 234 | { 235 | reminder.dueDateComponents = Calendar.current.dateComponents( 236 | [.year, .month, .day, .hour, .minute, .second], from: dueDate) 237 | } 238 | 239 | if case let .string(notes) = arguments["notes"] { 240 | reminder.notes = notes 241 | } 242 | 243 | if case let .string(priorityStr) = arguments["priority"] { 244 | reminder.priority = Int(EKReminderPriority.from(string: priorityStr).rawValue) 245 | } 246 | 247 | // Set alarms 248 | if case let .array(alarmMinutes) = arguments["alarms"] { 249 | reminder.alarms = alarmMinutes.compactMap { 250 | guard case let .int(minutes) = $0 else { return nil } 251 | return EKAlarm(relativeOffset: TimeInterval(-minutes * 60)) 252 | } 253 | } 254 | 255 | // Save the reminder 256 | try self.eventStore.save(reminder, commit: true) 257 | 258 | return PlanAction(reminder) 259 | } 260 | } 261 | } 262 | -------------------------------------------------------------------------------- /App/Services/Utilities.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import JSONSchema 3 | import OSLog 4 | 5 | private let log = Logger.service("utilities") 6 | 7 | final class UtilitiesService: Service { 8 | static let shared = UtilitiesService() 9 | 10 | var tools: [Tool] { 11 | Tool( 12 | name: "utilities_beep", 13 | description: "Play a system sound", 14 | inputSchema: .object( 15 | properties: [ 16 | "sound": .string( 17 | default: .string(Sound.default.rawValue), 18 | enum: Sound.allCases.map { .string($0.rawValue) }) 19 | ], 20 | required: ["sound"], 21 | additionalProperties: false 22 | ), 23 | annotations: .init( 24 | title: "Play System Sound", 25 | readOnlyHint: true, 26 | openWorldHint: false 27 | ) 28 | ) { input in 29 | let rawValue = input["sound"]?.stringValue ?? Sound.default.rawValue 30 | guard let sound = Sound(rawValue: rawValue) else { 31 | log.error("Invalid sound: \(rawValue)") 32 | throw NSError( 33 | domain: "SoundError", code: 1, 34 | userInfo: [ 35 | NSLocalizedDescriptionKey: "Invalid sound" 36 | ]) 37 | } 38 | 39 | return NSSound.play(sound) 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /App/Services/Weather.swift: -------------------------------------------------------------------------------- 1 | import CoreLocation 2 | import Foundation 3 | import OSLog 4 | import Ontology 5 | import WeatherKit 6 | 7 | private let log = Logger.service("weather") 8 | 9 | final class WeatherService: Service { 10 | static let shared = WeatherService() 11 | 12 | private let weatherService = WeatherKit.WeatherService.shared 13 | 14 | var tools: [Tool] { 15 | Tool( 16 | name: "weather_current", 17 | description: 18 | "Get current weather for a location", 19 | inputSchema: .object( 20 | properties: [ 21 | "latitude": .number(), 22 | "longitude": .number(), 23 | ], 24 | required: ["latitude", "longitude"], 25 | additionalProperties: false 26 | ), 27 | annotations: .init( 28 | title: "Get Current Weather", 29 | readOnlyHint: true, 30 | openWorldHint: true 31 | ) 32 | ) { arguments in 33 | guard case let .double(latitude) = arguments["latitude"], 34 | case let .double(longitude) = arguments["longitude"] 35 | else { 36 | log.error("Invalid coordinates") 37 | throw NSError( 38 | domain: "WeatherServiceError", code: 1, 39 | userInfo: [NSLocalizedDescriptionKey: "Invalid coordinates"] 40 | ) 41 | } 42 | 43 | let location = CLLocation(latitude: latitude, longitude: longitude) 44 | let currentWeather = try await self.weatherService.weather( 45 | for: location, including: .current) 46 | 47 | return WeatherConditions(currentWeather) 48 | } 49 | 50 | Tool( 51 | name: "weather_daily", 52 | description: "Get daily weather forecast for a location", 53 | inputSchema: .object( 54 | properties: [ 55 | "latitude": .number(), 56 | "longitude": .number(), 57 | "days": .integer( 58 | description: "Number of forecast days (max 10)", 59 | default: 7, 60 | minimum: 1, 61 | maximum: 10 62 | ), 63 | ], 64 | required: ["latitude", "longitude"], 65 | additionalProperties: false 66 | ), 67 | annotations: .init( 68 | title: "Get Daily Forecast", 69 | readOnlyHint: true, 70 | openWorldHint: true 71 | ) 72 | ) { arguments in 73 | guard case let .double(latitude) = arguments["latitude"], 74 | case let .double(longitude) = arguments["longitude"] 75 | else { 76 | log.error("Invalid coordinates") 77 | throw NSError( 78 | domain: "WeatherServiceError", code: 1, 79 | userInfo: [NSLocalizedDescriptionKey: "Invalid coordinates"] 80 | ) 81 | } 82 | 83 | var days: Int = 7 84 | if case let .int(daysRequested) = arguments["days"] { 85 | days = daysRequested 86 | } else if case let .double(daysRequested) = arguments["days"] { 87 | days = Int(daysRequested) 88 | } 89 | days = days.clamped(to: 1...10) 90 | 91 | let location = CLLocation(latitude: latitude, longitude: longitude) 92 | let dailyForecast = try await self.weatherService.weather( 93 | for: location, including: .daily) 94 | 95 | return dailyForecast.prefix(days).map { WeatherForecast($0) } 96 | } 97 | 98 | Tool( 99 | name: "weather_hourly", 100 | description: "Get hourly weather forecast for a location", 101 | inputSchema: .object( 102 | properties: [ 103 | "latitude": .number(), 104 | "longitude": .number(), 105 | "hours": .integer( 106 | description: "Number of hours to forecast", 107 | default: 24, 108 | minimum: 1, 109 | maximum: 240 110 | ), 111 | ], 112 | required: ["latitude", "longitude"], 113 | additionalProperties: false 114 | ), 115 | annotations: .init( 116 | title: "Get Hourly Forecast", 117 | readOnlyHint: true, 118 | openWorldHint: true 119 | ) 120 | ) { arguments in 121 | guard case let .double(latitude) = arguments["latitude"], 122 | case let .double(longitude) = arguments["longitude"] 123 | else { 124 | log.error("Invalid coordinates") 125 | throw NSError( 126 | domain: "WeatherServiceError", code: 1, 127 | userInfo: [NSLocalizedDescriptionKey: "Invalid coordinates"] 128 | ) 129 | } 130 | 131 | let hours: Int 132 | switch arguments["hours"] { 133 | case let .int(hoursRequested): 134 | hours = min(240, max(1, hoursRequested)) 135 | case let .double(hoursRequested): 136 | hours = Int(min(240, max(1, hoursRequested))) 137 | default: 138 | hours = 24 139 | } 140 | 141 | let location = CLLocation(latitude: latitude, longitude: longitude) 142 | let hourlyForecasts = try await self.weatherService.weather( 143 | for: location, including: .hourly) 144 | 145 | return hourlyForecasts.prefix(hours).map { WeatherForecast($0) } 146 | } 147 | 148 | Tool( 149 | name: "weather_minute", 150 | description: "Get minute-by-minute weather forecast for a location", 151 | inputSchema: .object( 152 | properties: [ 153 | "latitude": .number(), 154 | "longitude": .number(), 155 | "minutes": .integer( 156 | description: "Number of minutes to forecast", 157 | default: 60, 158 | minimum: 1, 159 | maximum: 120 160 | ), 161 | ], 162 | required: ["latitude", "longitude"], 163 | additionalProperties: false 164 | ), 165 | annotations: .init( 166 | title: "Get Minute-by-Minute Forecast", 167 | readOnlyHint: true, 168 | openWorldHint: true 169 | ) 170 | ) { arguments in 171 | guard case let .double(latitude) = arguments["latitude"], 172 | case let .double(longitude) = arguments["longitude"] 173 | else { 174 | log.error("Invalid coordinates") 175 | throw NSError( 176 | domain: "WeatherServiceError", code: 1, 177 | userInfo: [NSLocalizedDescriptionKey: "Invalid coordinates"] 178 | ) 179 | } 180 | 181 | var minutes: Int = 60 182 | if case let .int(minutesRequested) = arguments["minutes"] { 183 | minutes = minutesRequested 184 | } else if case let .double(minutesRequested) = arguments["minutes"] { 185 | minutes = Int(minutesRequested) 186 | } 187 | minutes = minutes.clamped(to: 1...120) 188 | 189 | let location = CLLocation(latitude: latitude, longitude: longitude) 190 | guard 191 | let minuteByMinuteForecast = try await self.weatherService.weather( 192 | for: location, including: .minute) 193 | else { 194 | throw NSError( 195 | domain: "WeatherServiceError", code: 2, 196 | userInfo: [NSLocalizedDescriptionKey: "No minute-by-minute forecast available"] 197 | ) 198 | } 199 | 200 | return minuteByMinuteForecast.prefix(minutes).map { WeatherForecast($0) } 201 | } 202 | } 203 | } 204 | 205 | extension Int { 206 | fileprivate func clamped(to range: ClosedRange) -> Int { 207 | return Swift.max(range.lowerBound, Swift.min(self, range.upperBound)) 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /App/Views/AboutWindow.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | 4 | class AboutWindowController: NSWindowController { 5 | convenience init() { 6 | let window = NSWindow( 7 | contentRect: NSRect(x: 0, y: 0, width: 400, height: 500), 8 | styleMask: [.titled, .closable], 9 | backing: .buffered, 10 | defer: false 11 | ) 12 | window.center() 13 | window.title = "About iMCP" 14 | window.contentView = NSHostingView(rootView: AboutView()) 15 | window.isReleasedWhenClosed = false 16 | self.init(window: window) 17 | } 18 | } 19 | 20 | private struct AboutView: View { 21 | var body: some View { 22 | VStack { 23 | HStack(alignment: .top, spacing: 32) { 24 | // Left column - Icon 25 | Image(.menuIconOn) 26 | .resizable() 27 | .frame(width: 160, height: 160) 28 | .padding() 29 | 30 | // Right column - App info and links 31 | VStack(alignment: .leading, spacing: 24) { 32 | VStack(alignment: .leading, spacing: 8) { 33 | Text("iMCP") 34 | .font(.system(size: 24, weight: .medium)) 35 | 36 | if let shortVersionString = Bundle.main.shortVersionString { 37 | Text("Version \(shortVersionString)") 38 | .foregroundStyle(.secondary) 39 | } 40 | } 41 | .padding(.top, 20) 42 | 43 | Button("Report an Issue...") { 44 | NSWorkspace.shared.open( 45 | URL(string: "https://github.com/loopwork-ai/iMCP/issues/new")!) 46 | } 47 | } 48 | } 49 | 50 | if let copyright = Bundle.main.copyright { 51 | Text(copyright) 52 | .font(.caption) 53 | .foregroundStyle(.secondary) 54 | .padding() 55 | } 56 | } 57 | .frame(width: 400) 58 | .padding() 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /App/Views/ConnectionApprovalView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import SwiftUI 3 | 4 | struct ConnectionApprovalView: View { 5 | let clientName: String 6 | let onApprove: (Bool) -> Void // Bool parameter is for "always trust" 7 | let onDeny: () -> Void 8 | 9 | @State private var alwaysTrust = false 10 | 11 | var body: some View { 12 | VStack(alignment: .leading, spacing: 20) { 13 | // Icon 14 | Image(.menuIconOn) 15 | .resizable() 16 | .foregroundColor(.accentColor) 17 | .aspectRatio(contentMode: .fit) 18 | .frame(width: 64, height: 64) 19 | 20 | // Title 21 | Text("Client Connection Request") 22 | .font(.title2) 23 | .fontWeight(.semibold) 24 | 25 | // Message 26 | VStack(alignment: .leading, spacing: 8) { 27 | Text("Allow \"\(clientName)\" to connect to iMCP?") 28 | 29 | Text("This will give the client access to enabled services.") 30 | .font(.caption) 31 | .foregroundColor(.secondary) 32 | .multilineTextAlignment(.leading) 33 | } 34 | 35 | // Always trust checkbox 36 | HStack(alignment: .firstTextBaseline) { 37 | Toggle("Always trust this client", isOn: $alwaysTrust) 38 | .toggleStyle(CheckboxToggleStyle()) 39 | Spacer() 40 | } 41 | .padding(.bottom, 20) 42 | 43 | // Buttons 44 | HStack(spacing: 12) { 45 | Button("Deny") { 46 | onDeny() 47 | } 48 | .buttonStyle(.bordered) 49 | .keyboardShortcut(.cancelAction) 50 | 51 | Button("Allow") { 52 | onApprove(alwaysTrust) 53 | } 54 | .buttonStyle(.borderedProminent) 55 | .keyboardShortcut(.defaultAction) 56 | } 57 | } 58 | .padding(24) 59 | .frame(width: 400, height: 300) 60 | .fixedSize() 61 | .background(Color(NSColor.windowBackgroundColor)) 62 | .cornerRadius(12) 63 | .shadow(radius: 10) 64 | } 65 | } 66 | 67 | struct CheckboxToggleStyle: ToggleStyle { 68 | func makeBody(configuration: Configuration) -> some View { 69 | HStack { 70 | Image(systemName: configuration.isOn ? "checkmark.square.fill" : "square") 71 | .foregroundColor(configuration.isOn ? .accentColor : .secondary) 72 | .accessibilityLabel(configuration.isOn ? "Always trust this client, checked" : "Always trust this client, unchecked") 73 | .onTapGesture { 74 | configuration.isOn.toggle() 75 | } 76 | 77 | configuration.label 78 | .onTapGesture { 79 | configuration.isOn.toggle() 80 | } 81 | } 82 | } 83 | } 84 | 85 | @MainActor 86 | class ConnectionApprovalWindowController: NSObject { 87 | private var window: NSWindow? 88 | private var approvalView: ConnectionApprovalView? 89 | 90 | func showApprovalWindow( 91 | clientName: String, 92 | onApprove: @escaping (Bool) -> Void, 93 | onDeny: @escaping () -> Void 94 | ) { 95 | // Create the SwiftUI view 96 | let approvalView = ConnectionApprovalView( 97 | clientName: clientName, 98 | onApprove: { alwaysTrust in 99 | onApprove(alwaysTrust) 100 | self.closeWindow() 101 | }, 102 | onDeny: { 103 | onDeny() 104 | self.closeWindow() 105 | } 106 | ) 107 | 108 | // Create the hosting controller 109 | let hostingController = NSHostingController(rootView: approvalView) 110 | 111 | // Create the window with fixed size matching the SwiftUI view 112 | let window = NSWindow( 113 | contentRect: NSRect(x: 0, y: 0, width: 400, height: 300), 114 | styleMask: [.titled, .closable], 115 | backing: .buffered, 116 | defer: false 117 | ) 118 | 119 | window.title = "Connection Request" 120 | window.contentViewController = hostingController 121 | window.isReleasedWhenClosed = false 122 | window.level = .floating 123 | window.isMovableByWindowBackground = false 124 | window.titlebarAppearsTransparent = false 125 | 126 | // Initial centering 127 | window.center() 128 | 129 | // Store references 130 | self.window = window 131 | self.approvalView = approvalView 132 | 133 | // Activate the app first 134 | NSApp.activate(ignoringOtherApps: true) 135 | 136 | // Show the window 137 | window.makeKeyAndOrderFront(nil) 138 | 139 | // Center again after showing to ensure proper positioning 140 | Task { @MainActor in 141 | if let screen = NSScreen.main { 142 | let screenRect = screen.visibleFrame 143 | let windowRect = window.frame 144 | let x = (screenRect.width - windowRect.width) / 2 + screenRect.origin.x 145 | let y = (screenRect.height - windowRect.height) / 2 + screenRect.origin.y 146 | window.setFrameOrigin(NSPoint(x: x, y: y)) 147 | } 148 | } 149 | } 150 | 151 | private func closeWindow() { 152 | window?.close() 153 | window = nil 154 | approvalView = nil 155 | } 156 | } 157 | 158 | #Preview { 159 | ConnectionApprovalView( 160 | clientName: "Claude Desktop", 161 | onApprove: { alwaysTrust in 162 | print("Approved with always trust: \(alwaysTrust)") 163 | }, 164 | onDeny: { 165 | print("Denied") 166 | } 167 | ) 168 | .frame(width: 500, height: 400) 169 | } 170 | -------------------------------------------------------------------------------- /App/Views/ContentView.swift: -------------------------------------------------------------------------------- 1 | import AppKit 2 | import MenuBarExtraAccess 3 | import SwiftUI 4 | 5 | struct ContentView: View { 6 | @ObservedObject var serverController: ServerController 7 | @Binding var isEnabled: Bool 8 | @Binding var isMenuPresented: Bool 9 | @Environment(\.openSettings) private var openSettings 10 | 11 | private let aboutWindowController: AboutWindowController 12 | 13 | private var serviceConfigs: [ServiceConfig] { 14 | serverController.computedServiceConfigs 15 | } 16 | 17 | private var serviceBindings: [String: Binding] { 18 | Dictionary( 19 | uniqueKeysWithValues: serviceConfigs.map { 20 | ($0.id, $0.binding) 21 | }) 22 | } 23 | 24 | init( 25 | serverManager: ServerController, 26 | isEnabled: Binding, 27 | isMenuPresented: Binding 28 | ) { 29 | self.serverController = serverManager 30 | self._isEnabled = isEnabled 31 | self._isMenuPresented = isMenuPresented 32 | self.aboutWindowController = AboutWindowController() 33 | } 34 | 35 | var body: some View { 36 | VStack(alignment: .leading, spacing: 0) { 37 | HStack { 38 | Text("Enable MCP Server") 39 | .frame(maxWidth: .infinity, alignment: .leading) 40 | Toggle("", isOn: $isEnabled) 41 | .toggleStyle(.switch) 42 | .labelsHidden() 43 | } 44 | .padding(.top, 2) 45 | .padding(.horizontal, 14) 46 | .onChange(of: isEnabled, initial: true) { 47 | Task { 48 | await serverController.setEnabled(isEnabled) 49 | } 50 | } 51 | 52 | if isEnabled { 53 | VStack(alignment: .leading, spacing: 8) { 54 | Divider() 55 | 56 | Text("Services") 57 | .font(.system(size: 13, weight: .semibold)) 58 | .foregroundColor(.secondary) 59 | .opacity(isEnabled ? 1.0 : 0.4) 60 | .padding(.horizontal, 14) 61 | 62 | ForEach(serviceConfigs) { config in 63 | ServiceToggleView(config: config) 64 | } 65 | } 66 | .padding(.top, 8) 67 | .padding(.bottom, 4) 68 | .padding(.horizontal, 2) 69 | .onChange(of: serviceConfigs.map { $0.binding.wrappedValue }, initial: true) { 70 | Task { 71 | await serverController.updateServiceBindings(serviceBindings) 72 | } 73 | } 74 | .transition(.opacity.combined(with: .move(edge: .top))) 75 | .animation(.easeInOut(duration: 0.3), value: isEnabled) 76 | } 77 | 78 | VStack(alignment: .leading, spacing: 2) { 79 | Divider() 80 | 81 | MenuButton("Configure Claude Desktop", isMenuPresented: $isMenuPresented) { 82 | ClaudeDesktop.showConfigurationPanel() 83 | } 84 | 85 | MenuButton("Copy server command to clipboard", isMenuPresented: $isMenuPresented) { 86 | let command = Bundle.main.bundleURL 87 | .appendingPathComponent("Contents/MacOS/imcp-server") 88 | .path 89 | 90 | let pasteboard = NSPasteboard.general 91 | pasteboard.clearContents() 92 | pasteboard.setString(command, forType: .string) 93 | } 94 | } 95 | .padding(.top, 8) 96 | .padding(.bottom, 2) 97 | .padding(.horizontal, 2) 98 | 99 | VStack(alignment: .leading, spacing: 2) { 100 | Divider() 101 | 102 | MenuButton("Settings...", isMenuPresented: $isMenuPresented) { 103 | openSettings() 104 | } 105 | 106 | MenuButton("About iMCP", isMenuPresented: $isMenuPresented) { 107 | aboutWindowController.showWindow(nil) 108 | NSApp.activate(ignoringOtherApps: true) 109 | } 110 | 111 | MenuButton("Quit", isMenuPresented: $isMenuPresented) { 112 | NSApplication.shared.terminate(nil) 113 | } 114 | } 115 | .padding(.bottom, 2) 116 | .padding(.horizontal, 2) 117 | } 118 | .padding(.vertical, 6) 119 | .background(Material.thick) 120 | } 121 | } 122 | 123 | private struct MenuButton: View { 124 | @Environment(\.isEnabled) private var isEnabled 125 | 126 | private let title: String 127 | private let action: () -> Void 128 | @Binding private var isMenuPresented: Bool 129 | @State private var isHighlighted: Bool = false 130 | @State private var isPressed: Bool = false 131 | 132 | init( 133 | _ title: S, 134 | isMenuPresented: Binding, 135 | action: @escaping () -> Void 136 | ) where S: StringProtocol { 137 | self.title = String(title) 138 | self._isMenuPresented = isMenuPresented 139 | self.action = action 140 | } 141 | 142 | var body: some View { 143 | HStack { 144 | Text(title) 145 | .foregroundColor(.primary.opacity(isEnabled ? 1.0 : 0.4)) 146 | .multilineTextAlignment(.leading) 147 | .padding(.vertical, 8) 148 | .padding(.horizontal, 14) 149 | 150 | Spacer() 151 | } 152 | .contentShape(Rectangle()) 153 | .allowsHitTesting(isEnabled) 154 | .onTapGesture { 155 | guard isEnabled else { return } 156 | 157 | Task { @MainActor in 158 | withAnimation(.easeInOut(duration: 0.1)) { 159 | isPressed = true 160 | } 161 | 162 | try? await Task.sleep(for: .milliseconds(100)) 163 | 164 | withAnimation(.easeInOut(duration: 0.1)) { 165 | isPressed = false 166 | } 167 | 168 | action() 169 | isMenuPresented = false 170 | } 171 | } 172 | .frame(height: 18) 173 | .padding(.vertical, 4) 174 | .background( 175 | RoundedRectangle(cornerRadius: 6) 176 | .fill( 177 | isPressed 178 | ? Color.accentColor 179 | : isHighlighted ? Color.accentColor.opacity(0.7) : Color.clear) 180 | ) 181 | .onHover { state in 182 | guard isEnabled else { return } 183 | isHighlighted = state 184 | } 185 | .onChange(of: isEnabled) { _, newValue in 186 | if !newValue { 187 | isHighlighted = false 188 | isPressed = false 189 | } 190 | } 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /App/Views/ServiceToggleView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import AppKit 3 | 4 | struct ServiceToggleView: View { 5 | let config: ServiceConfig 6 | @State private var isServiceActivated = false 7 | 8 | // MARK: Environment 9 | @Environment(\.colorScheme) private var colorScheme 10 | @Environment(\.isEnabled) private var isEnabled 11 | 12 | // MARK: Private State 13 | private let buttonSize: CGFloat = 26 14 | private let imagePadding: CGFloat = 5 15 | 16 | var body: some View { 17 | HStack { 18 | Button(action: { 19 | config.binding.wrappedValue.toggle() 20 | if config.binding.wrappedValue && !isServiceActivated { 21 | Task { 22 | do { 23 | try await config.service.activate() 24 | } catch { 25 | config.binding.wrappedValue = false 26 | } 27 | } 28 | } 29 | }) { 30 | Circle() 31 | .fill(buttonBackgroundColor) 32 | .overlay( 33 | Image(systemName: config.iconName) 34 | .resizable() 35 | .scaledToFit() 36 | .foregroundColor(buttonForegroundColor) 37 | .padding(imagePadding) 38 | ) 39 | .animation(.snappy, value: config.binding.wrappedValue || isEnabled) 40 | } 41 | .buttonStyle(PlainButtonStyle()) 42 | .disabled(!isEnabled) 43 | .frame(width: buttonSize, height: buttonSize) 44 | 45 | Text(config.name) 46 | .frame(maxWidth: .infinity, alignment: .leading) 47 | .foregroundColor(isEnabled ? Color.primary : .primary.opacity(0.5)) 48 | } 49 | .frame(height: buttonSize) 50 | .padding(.horizontal, 14) 51 | .task { 52 | isServiceActivated = await config.isActivated 53 | } 54 | } 55 | 56 | private var buttonBackgroundColor: Color { 57 | if config.binding.wrappedValue { 58 | return config.color.opacity(isEnabled ? 1.0 : 0.4) 59 | } else { 60 | return Color(NSColor.controlColor) 61 | .opacity(isEnabled ? (colorScheme == .dark ? 0.8 : 0.2) : 0.1) 62 | } 63 | } 64 | 65 | private var buttonForegroundColor: Color { 66 | if config.binding.wrappedValue { 67 | return .white.opacity(isEnabled ? 1.0 : 0.6) 68 | } else { 69 | return .primary.opacity(isEnabled ? 0.7 : 0.4) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /App/Views/SettingsView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct SettingsView: View { 4 | @ObservedObject var serverController: ServerController 5 | @State private var selectedSection: SettingsSection? = .general 6 | 7 | enum SettingsSection: String, CaseIterable, Identifiable { 8 | case general = "General" 9 | 10 | var id: String { self.rawValue } 11 | 12 | var icon: String { 13 | switch self { 14 | case .general: return "gear" 15 | } 16 | } 17 | } 18 | 19 | var body: some View { 20 | NavigationView { 21 | List( 22 | selection: .init( 23 | get: { selectedSection }, 24 | set: { section in 25 | selectedSection = section 26 | } 27 | ) 28 | ) { 29 | Section { 30 | ForEach(SettingsSection.allCases) { section in 31 | Label(section.rawValue, systemImage: section.icon) 32 | .tag(section) 33 | } 34 | } 35 | } 36 | 37 | if let selectedSection { 38 | switch selectedSection { 39 | case .general: 40 | GeneralSettingsView(serverController: serverController) 41 | .navigationTitle("General") 42 | .formStyle(.grouped) 43 | } 44 | } else { 45 | Text("Select a category") 46 | .foregroundColor(.secondary) 47 | .frame(maxWidth: .infinity, maxHeight: .infinity) 48 | } 49 | } 50 | .toolbar { 51 | Text("") 52 | } 53 | .task { 54 | let window = NSApplication.shared.keyWindow 55 | window?.toolbarStyle = .unified 56 | window?.toolbar?.displayMode = .iconOnly 57 | } 58 | .onAppear { 59 | if selectedSection == nil, let firstSection = SettingsSection.allCases.first { 60 | selectedSection = firstSection 61 | } 62 | } 63 | } 64 | 65 | } 66 | 67 | struct GeneralSettingsView: View { 68 | @ObservedObject var serverController: ServerController 69 | @State private var showingResetAlert = false 70 | @State private var selectedClients = Set() 71 | 72 | private var trustedClients: [String] { 73 | serverController.getTrustedClients() 74 | } 75 | 76 | var body: some View { 77 | Form { 78 | Section { 79 | VStack(alignment: .leading, spacing: 12) { 80 | HStack { 81 | Text("Trusted Clients") 82 | .font(.headline) 83 | Spacer() 84 | if !trustedClients.isEmpty { 85 | Button("Remove All") { 86 | showingResetAlert = true 87 | } 88 | .buttonStyle(.borderless) 89 | .foregroundStyle(.red) 90 | } 91 | } 92 | 93 | Text("Clients that automatically connect without approval.") 94 | .font(.caption) 95 | .foregroundStyle(.secondary) 96 | } 97 | .padding(.bottom, 4) 98 | 99 | if trustedClients.isEmpty { 100 | HStack { 101 | Text("No trusted clients") 102 | .foregroundStyle(.secondary) 103 | .italic() 104 | Spacer() 105 | } 106 | .padding(.vertical, 8) 107 | } else { 108 | List(trustedClients, id: \.self, selection: $selectedClients) { client in 109 | HStack { 110 | Text(client) 111 | .font(.system(.body, design: .monospaced)) 112 | Spacer() 113 | } 114 | .contextMenu { 115 | Button("Remove Client", role: .destructive) { 116 | serverController.removeTrustedClient(client) 117 | } 118 | } 119 | } 120 | .frame(minHeight: 100, maxHeight: 200) 121 | .onDeleteCommand { 122 | for clientID in selectedClients { 123 | serverController.removeTrustedClient(clientID) 124 | } 125 | selectedClients.removeAll() 126 | } 127 | } 128 | } 129 | } 130 | .formStyle(.grouped) 131 | .alert("Remove All Trusted Clients", isPresented: $showingResetAlert) { 132 | Button("Cancel", role: .cancel) {} 133 | Button("Remove All", role: .destructive) { 134 | serverController.resetTrustedClients() 135 | selectedClients.removeAll() 136 | } 137 | } message: { 138 | Text( 139 | "This will remove all trusted clients. They will need to be approved again when connecting." 140 | ) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Assets/calendar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /Assets/claude-desktop-screenshot-message.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/claude-desktop-screenshot-message.png -------------------------------------------------------------------------------- /Assets/claude-desktop-screenshot-tool-use.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/claude-desktop-screenshot-tool-use.png -------------------------------------------------------------------------------- /Assets/claude-desktop-screenshot-tools-enabled.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/claude-desktop-screenshot-tools-enabled.png -------------------------------------------------------------------------------- /Assets/companion-screenshot-add-server.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/companion-screenshot-add-server.png -------------------------------------------------------------------------------- /Assets/contacts.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /Assets/hero-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 14 | 16 | 18 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 49 | 50 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 74 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /Assets/hero-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 14 | 16 | 18 | 20 | 21 | 22 | 23 | 24 | 26 | 27 | 28 | 29 | 30 | 31 | 33 | 34 | 35 | 36 | 37 | 38 | 40 | 41 | 42 | 43 | 44 | 45 | 47 | 49 | 50 | 51 | 52 | 53 | 54 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 74 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Assets/icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /Assets/imcp-screenshot-all-services-active.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/imcp-screenshot-all-services-active.png -------------------------------------------------------------------------------- /Assets/imcp-screenshot-approve-connection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/imcp-screenshot-approve-connection.png -------------------------------------------------------------------------------- /Assets/imcp-screenshot-configure-claude-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/imcp-screenshot-configure-claude-desktop.png -------------------------------------------------------------------------------- /Assets/imcp-screenshot-first-launch.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/imcp-screenshot-first-launch.png -------------------------------------------------------------------------------- /Assets/imcp-screenshot-grant-permission.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/loopwork/iMCP/5f42a1c9bc5cda40df49acd0c9fb1dc382ea0d29/Assets/imcp-screenshot-grant-permission.png -------------------------------------------------------------------------------- /Assets/location.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /Assets/maps.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /Assets/messages.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | -------------------------------------------------------------------------------- /Assets/reminders.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 10 | 11 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Assets/weather.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 9 | 10 | -------------------------------------------------------------------------------- /CLI/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2025 Loopwork Limited 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | iMCP 5 | 6 | 7 | iMCP is a macOS app for connecting your digital life with AI. 8 | It works with [Claude Desktop][claude-app] 9 | and a [growing list of clients][mcp-clients] that support the 10 | [Model Context Protocol (MCP)][mcp]. 11 | 12 | ## Capabilities 13 | 14 | 15 | 16 | 19 | 20 | 21 | 22 | 23 | 26 | 27 | 28 | 29 | 30 | 33 | 34 | 35 | 36 | 37 | 40 | 41 | 42 | 43 | 44 | 47 | 48 | 49 | 50 | 51 | 54 | 55 | 56 | 57 | 58 | 61 | 62 | 63 | 64 |
17 | 18 | CalendarView and manage calendar events, including creating new events with customizable settings like recurrence, alarms, and availability status.
24 | 25 | ContactsAccess contact information about yourself and search your contacts by name, phone number, or email address.
31 | 32 | LocationAccess current location data and convert between addresses and geographic coordinates.
38 | 39 | MapsProvides location services including place search, directions, points of interest lookup, travel time estimation, and static map image generation.
45 | 46 | MessagesAccess message history with specific participants within customizable date ranges.
52 | 53 | RemindersView and create reminders with customizable due dates, priorities, and alerts across different reminder lists.
59 | 60 | WeatherAccess current weather conditions including temperature, wind speed, and weather conditions for any location.
65 | 66 | > [!TIP] 67 | > Have a suggestion for a new capability? 68 | > Reach out to us at 69 | 70 | ## Getting Started 71 | 72 | ### Download and open the app 73 | 74 | First, [download the iMCP app](https://iMCP.app/download) 75 | (requires macOS 15.3 or later). 76 | 77 | Or, if you have [Homebrew](https://brew.sh) installed, 78 | you can run the following command: 79 | 80 | ```console 81 | brew install --cask loopwork/tap/iMCP 82 | ``` 83 | 84 | Screenshot of iMCP on first launch 85 | 86 | When you open the app, 87 | you'll see a 88 | 89 | icon in your menu bar. 90 | 91 | Clicking on this icon reveals the iMCP menu, 92 | which displays all available services. 93 | Initially, all services will appear in gray, 94 | indicating they're inactive. 95 | 96 | The blue toggle switch at the top indicates that the MCP server is running 97 | and ready to connect with MCP-compatible clients. 98 | 99 |
100 | 101 | Screenshot of macOS permission dialog 102 | 103 | ### Activate services 104 | 105 | To activate a service, click on its icon. 106 | The system will prompt you with a permission dialog. 107 | For example, when activating Calendar access, you'll see a dialog asking `"iMCP" Would Like Full Access to Your Calendar`. 108 | Click Allow Full Access to continue. 109 | 110 | > [!IMPORTANT] 111 | > iMCP **does not** collect or store any of your data. 112 | > Clients like Claude Desktop _do_ send 113 | > your data off device as part of tool calls. 114 | 115 |
116 | 117 | Screenshot of iMCP with all services enabled 118 | 119 | Once activated, 120 | each service icons goes from gray to their distinctive colors — 121 | red for Calendar, green for Messages, blue for Location, and so on. 122 | 123 | Repeat this process for all of the capabilities you'd like to enable. 124 | These permissions follow Apple's standard security model, 125 | giving you complete control over what information iMCP can access. 126 | 127 | 128 | 129 | 130 | 131 |
132 | 133 | ### Connect to Claude Desktop 134 | 135 | If you don't have Claude Desktop installed, 136 | you can [download it here](https://claude.ai/download). 137 | 138 | Open Claude Desktop and go to "Settings... (,)". 139 | Click on "Developer" in the sidebar of the Settings pane, 140 | and then click on "Edit Config". 141 | This will create a configuration file at 142 | `~/Library/Application Support/Claude/claude_desktop_config.json`. 143 | 144 |
145 | 146 | To connect iMCP to Claude Desktop, 147 | click 148 | \> "Configure Claude Desktop". 149 | 150 | This will add or update the MCP server configuration to use the 151 | `imcp-server` executable bundled in the application. 152 | Other MCP server configurations in the file will be preserved. 153 | 154 |
155 | You can also configure Claude Desktop manually 156 | 157 | Click 158 | \> "Copy server command to clipboard". 159 | Then open `claude_desktop_config.json` in your editor 160 | and enter the following: 161 | 162 | ```json 163 | { 164 | "mcpServers": { 165 | "iMCP": { 166 | "command": "{paste iMCP server command}" 167 | } 168 | } 169 | } 170 | ``` 171 | 172 |
173 | 174 | 175 | 176 | ### Call iMCP tools from Claude Desktop 177 | 178 | Quit and reopen the Claude Desktop app. 179 | You'll be prompted to approve the connection. 180 | 181 |
182 | 183 | After approving the connection, 184 | you should now see 🔨12 in the bottom right corner of your chat box. 185 | Click on that to see a list of all the tools made available to Claude 186 | by iMCP. 187 | 188 |

189 | Screenshot of Claude Desktop with tools enabled 190 |

191 | 192 | Now you can ask Claude questions that require access to your personal data, 193 | such as: 194 | 195 | > "How's the weather where I am?" 196 | 197 | Claude will use the appropriate tools to retrieve this information, 198 | providing you with accurate, personalized responses 199 | without requiring you to manually share this data during your conversation. 200 | 201 |

202 | Screenshot of Claude response to user message 'How's the weather where I am?' 203 |

204 | 205 | ## Technical Details 206 | 207 | ### App & CLI 208 | 209 | iMCP is a macOS app that bundles a command-line executable, `imcp-server`. 210 | 211 | - [`iMCP.app`](/App/) provides UI for configuring services and — most importantly — 212 | a means of interacting with macOS system permissions, 213 | so that it can access Contacts, Calendar, and other information. 214 | - [`imcp-server`](/CLI/) provides an MCP server that 215 | uses standard input/output for communication 216 | ([stdio transport][mcp-transports]). 217 | 218 | The app and CLI communicate with each other on the local network 219 | using [Bonjour][bonjour] for automatic discovery. 220 | Both advertise a service with type "\_mcp.\_tcp" and domain "local". 221 | Requests from MCP clients are read by the CLI from `stdin` 222 | and relayed to the app; 223 | responses from the app are received by the CLI and written to `stdout`. 224 | See [`StdioProxy`](https://github.com/loopwork-ai/iMCP/blob/8cf9d250286288b06bf5d3dda78f5905ad0d7729/CLI/main.swift#L47) 225 | for implementation details. 226 | 227 | For this project, we created what became 228 | [the official Swift SDK][swift-sdk] 229 | for Model Context Protocol servers and clients. 230 | The app uses this package to handle proxied requests from MCP clients. 231 | 232 | ### iMessage Database Access 233 | 234 | Apple doesn't provide public APIs for accessing your messages. 235 | However, the Messages app on macOS stores data in a SQLite database located at 236 | `~/Library/Messages/chat.db`. 237 | 238 | iMCP runs in [App Sandbox][app-sandbox], 239 | which limits its access to user data and system resources. 240 | When you go to enable the Messages service, 241 | you'll be prompted to open the `chat.db` file through the standard file picker. 242 | When you do, macOS adds that file to the app's sandbox. 243 | [`NSOpenPanel`][nsopenpanel] is magic like that. 244 | 245 | But opening the iMessage database is just half the battle. 246 | Over the past few years, 247 | Apple has moved away from storing messages in plain text 248 | and instead toward a proprietary `typedstream` format. 249 | 250 | For this project, we created [Madrid][madrid]: 251 | a Swift package for reading your iMessage database. 252 | It includes a Swift implementation for decoding Apple's `typedstream` format, 253 | adapted from Christopher Sardegna's [imessage-exporter] project 254 | and [blog post about reverse-engineering `typedstream`][typedstream-blog-post]. 255 | 256 | ### JSON-LD for Tool Results 257 | 258 | The tools provided by iMCP return results as 259 | [JSON-LD][json-ld] documents. 260 | For example, 261 | the `fetchContacts` tool uses the [Contacts framework][contacts-framework], 262 | which represents people and organizations with the [`CNContact`][cncontact] type. 263 | Here's how an object of that type is encoded as JSON-LD: 264 | 265 | ```json 266 | { 267 | "@context": "https://schema.org", 268 | "@type": "Organization", 269 | "name": "Loopwork Limited", 270 | "url": "https://loop.work" 271 | } 272 | ``` 273 | 274 | [Schema.org][schema.org] provides standard vocabularies for 275 | people, postal addresses, events, and many other objects we want to represent. 276 | And JSON-LD is a convenient encoding format for 277 | humans, AI, and conventional software alike. 278 | 279 | For this project, we created [Ontology][ontology]: 280 | a Swift package for working with structured data. 281 | It includes convenience initializers for types from Apple frameworks, 282 | such as those returned by iMCP tools. 283 | 284 | ## Debugging 285 | 286 | ### Using the MCP Inspector 287 | 288 | To debug interactions between iMCP and clients, 289 | you can use the [inspector tool](https://github.com/modelcontextprotocol/inspector) 290 | (requires Node.js): 291 | 292 | 1. Click > "Copy server command to clipboard" 293 | 2. Open a terminal and run the following commands: 294 | 295 | ```console 296 | # Download and run inspector package on imcp-server 297 | npx @modelcontextprotocol/inspector [paste-copied-command] 298 | 299 | # Open inspector web app running locally 300 | open http://127.0.0.1:6274 301 | ``` 302 | 303 | Inspector lets you see all requests and responses between the client and the iMCP server, 304 | which is helpful for understanding how the protocol works. 305 | 306 | ### Using Companion 307 | 308 | 309 | 310 | [Companion][companion] is a utility for testing and debugging your MCP servers 311 | (requires macOS 15 or later). 312 | It gives you an easy way to browse and interact with 313 | a server's prompts, resources, and tools. 314 | Here's how to connect it to iMCP: 315 | 316 | 1. Click > "Copy server command to clipboard" 317 | 2. [Download][companion-download] and open the Companion app 318 | 3. Click the + button in the toolbar to add an MCP server 319 | 4. Fill out the form: 320 | - Enter "iMCP" as the name 321 | - Select "STDIO" as the transport 322 | - Paste the copied iMCP server command 323 | - Click "Add Server" 324 | 325 |
326 | 327 | ## Acknowledgments 328 | 329 | - [Justin Spahr-Summers](https://jspahrsummers.com/) 330 | ([@jspahrsummers](https://github.com/jspahrsummers)), 331 | David Soria Parra 332 | ([@dsp-ant](https://github.com/dsp-ant)), and 333 | Ashwin Bhat 334 | ([@ashwin-ant](https://github.com/ashwin-ant)) 335 | for their work on MCP. 336 | - [Christopher Sardegna](https://chrissardegna.com) 337 | ([@ReagentX](https://github.com/ReagentX)) 338 | for reverse-engineering the `typedstream` format 339 | used by the Messages app. 340 | 341 | ## License 342 | 343 | This project is licensed under the Apache License, Version 2.0. 344 | 345 | ## Legal 346 | 347 | iMessage® is a registered trademark of Apple Inc. 348 | This project is not affiliated with, endorsed, or sponsored by Apple Inc. 349 | 350 | [app-sandbox]: https://developer.apple.com/documentation/security/app-sandbox 351 | [bonjour]: https://developer.apple.com/bonjour/ 352 | [claude-app]: https://claude.ai/download 353 | [companion]: https://github.com/loopwork-ai/Companion 354 | [companion-download]: https://github.com/loopwork-ai/Companion/releases/latest/download/Companion.zip 355 | [contacts-framework]: https://developer.apple.com/documentation/contacts 356 | [cncontact]: https://developer.apple.com/documentation/contacts/cncontact 357 | [imessage-exporter]: https://github.com/ReagentX/imessage-exporter 358 | [json-ld]: https://json-ld.org 359 | [madrid]: https://github.com/loopwork-ai/Madrid 360 | [mcp]: https://modelcontextprotocol.io/introduction 361 | [mcp-clients]: https://modelcontextprotocol.io/clients 362 | [mcp-transports]: https://modelcontextprotocol.io/docs/concepts/architecture#transport-layer 363 | [nsopenpanel]: https://developer.apple.com/documentation/appkit/nsopenpanel 364 | [ontology]: https://github.com/loopwork-ai/Ontology 365 | [schema.org]: https://schema.org 366 | [swift-sdk]: https://github.com/modelcontextprotocol/swift-sdk 367 | [typedstream-blog-post]: https://chrissardegna.com/blog/reverse-engineering-apples-typedstream-format/ 368 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "0604c8a8c049fb6e696fcd8af68cdc36f2f23f938eec962e31f24cc19d550d45", 3 | "pins" : [ 4 | { 5 | "identity" : "eventsource", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/loopwork-ai/eventsource.git", 8 | "state" : { 9 | "revision" : "07957602bb99a5355c810187e66e6ce378a1057d", 10 | "version" : "1.1.1" 11 | } 12 | }, 13 | { 14 | "identity" : "jsonschema", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/loopwork-ai/JSONSchema.git", 17 | "state" : { 18 | "revision" : "e17c9e1fb6afbad656824d03f996cf8621f9db83", 19 | "version" : "1.3.0" 20 | } 21 | }, 22 | { 23 | "identity" : "madrid", 24 | "kind" : "remoteSourceControl", 25 | "location" : "https://github.com/loopwork-ai/madrid", 26 | "state" : { 27 | "revision" : "9d9fdb20424483fb592e5a026d9d0816cd5c43eb", 28 | "version" : "0.1.0" 29 | } 30 | }, 31 | { 32 | "identity" : "menubarextraaccess", 33 | "kind" : "remoteSourceControl", 34 | "location" : "https://github.com/orchetect/MenuBarExtraAccess", 35 | "state" : { 36 | "revision" : "e911e6454f8cbfe34a52136fc48e1ceb989a60e7", 37 | "version" : "1.2.1" 38 | } 39 | }, 40 | { 41 | "identity" : "ontology", 42 | "kind" : "remoteSourceControl", 43 | "location" : "https://github.com/loopwork-ai/Ontology", 44 | "state" : { 45 | "revision" : "2cbb952ac1a0b1dc465b05c1a480a922ab767849", 46 | "version" : "0.6.0" 47 | } 48 | }, 49 | { 50 | "identity" : "swift-async-algorithms", 51 | "kind" : "remoteSourceControl", 52 | "location" : "https://github.com/apple/swift-async-algorithms.git", 53 | "state" : { 54 | "revision" : "4c3ea81f81f0a25d0470188459c6d4bf20cf2f97", 55 | "version" : "1.0.3" 56 | } 57 | }, 58 | { 59 | "identity" : "swift-collections", 60 | "kind" : "remoteSourceControl", 61 | "location" : "https://github.com/apple/swift-collections.git", 62 | "state" : { 63 | "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", 64 | "version" : "1.1.4" 65 | } 66 | }, 67 | { 68 | "identity" : "swift-log", 69 | "kind" : "remoteSourceControl", 70 | "location" : "https://github.com/apple/swift-log.git", 71 | "state" : { 72 | "revision" : "96a2f8a0fa41e9e09af4585e2724c4e825410b91", 73 | "version" : "1.6.2" 74 | } 75 | }, 76 | { 77 | "identity" : "swift-sdk", 78 | "kind" : "remoteSourceControl", 79 | "location" : "https://github.com/modelcontextprotocol/swift-sdk", 80 | "state" : { 81 | "branch" : "main", 82 | "revision" : "87f33d022a870cc8635d52a1a77fcca14b2cab7d" 83 | } 84 | }, 85 | { 86 | "identity" : "swift-service-lifecycle", 87 | "kind" : "remoteSourceControl", 88 | "location" : "https://github.com/swift-server/swift-service-lifecycle", 89 | "state" : { 90 | "revision" : "7ee57f99fbe0073c3700997186721e74d925b59b", 91 | "version" : "2.7.0" 92 | } 93 | }, 94 | { 95 | "identity" : "swift-system", 96 | "kind" : "remoteSourceControl", 97 | "location" : "https://github.com/apple/swift-system.git", 98 | "state" : { 99 | "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", 100 | "version" : "1.4.2" 101 | } 102 | } 103 | ], 104 | "version" : 3 105 | } 106 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/xcshareddata/xcschemes/iMCP.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 30 | 36 | 37 | 38 | 39 | 40 | 46 | 47 | 57 | 59 | 65 | 66 | 67 | 68 | 74 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/xcuserdata/carlpeaslee.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | iMCP.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | imcp-server.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 4 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/xcuserdata/mattt.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /iMCP.xcodeproj/xcuserdata/mattt.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Associations (Playground).xcscheme 8 | 9 | orderHint 10 | 0 11 | 12 | MyPlayground (Playground).xcscheme 13 | 14 | orderHint 15 | 3 16 | 17 | Tour (Playground).xcscheme 18 | 19 | orderHint 20 | 4 21 | 22 | TransactionObserver (Playground).xcscheme 23 | 24 | orderHint 25 | 5 26 | 27 | iMCP.xcscheme_^#shared#^_ 28 | 29 | orderHint 30 | 5 31 | 32 | 33 | SuppressBuildableAutocreation 34 | 35 | F8F44E6C2D59038D0075D79C 36 | 37 | primary 38 | 39 | 40 | F8F44E7D2D59038E0075D79C 41 | 42 | primary 43 | 44 | 45 | F8F44E872D59038E0075D79C 46 | 47 | primary 48 | 49 | 50 | F8F44EB52D5908D00075D79C 51 | 52 | primary 53 | 54 | 55 | 56 | 57 | 58 | --------------------------------------------------------------------------------