// // ContactsService.swift // oAI // // Read-only Contacts integration: search and "my card" lookup // // SPDX-License-Identifier: AGPL-3.0-or-later // Copyright (C) 2026 Rune Olsen // // This file is part of oAI. // // oAI is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // oAI is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General // Public License for more details. // // You should have received a copy of the GNU Affero General Public // License along with oAI. If not, see . import Contacts import Foundation import os @Observable class ContactsService { static let shared = ContactsService() private let store = CNContactStore() private let maxResults = 20 private let maxScan = 5000 private init() {} // MARK: - Authorization var authStatus: CNAuthorizationStatus { CNContactStore.authorizationStatus(for: .contacts) } var authorized: Bool { authStatus == .authorized } var accessState: PersonalDataAccessState { let status = authStatus Log.mcp.debug("ContactsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))") switch status { case .authorized: return .granted case .notDetermined: return .notDetermined default: return .denied } } @discardableResult func requestAccess() async -> Bool { let before = CNContactStore.authorizationStatus(for: .contacts) Log.mcp.info("ContactsService.requestAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))") return await withCheckedContinuation { continuation in store.requestAccess(for: .contacts) { granted, error in let after = CNContactStore.authorizationStatus(for: .contacts) if let error { Log.mcp.error("ContactsService.requestAccess: error=\(error.localizedDescription); granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))") } else { Log.mcp.info("ContactsService.requestAccess: granted=\(granted); status after = \(Self.describe(after)) (raw=\(after.rawValue))") } continuation.resume(returning: granted) } } } nonisolated static func describe(_ status: CNAuthorizationStatus) -> String { switch status { case .notDetermined: return "notDetermined" case .restricted: return "restricted" case .denied: return "denied" case .authorized: return "authorized" case .limited: return "limited" @unknown default: return "unknown" } } // MARK: - Tool Schemas func getToolSchemas() -> [Tool] { [ makeTool( name: "contacts_search", description: "Search Contacts by name, phone number, or email address. Returns matching contacts with their phone numbers and emails. This does NOT match relationship labels like 'mother' or 'spouse' — for those, call contacts_get_me first to find the related person's name, then search for that name.", properties: [ "query": prop("string", "Name, phone number, or email fragment to search for") ], required: ["query"] ), makeTool( name: "contacts_get_me", description: "Get the user's own contact card (\"My Card\" in Contacts.app), if configured. Includes any defined relationships (e.g. mother, spouse, child) with the related person's name — use contacts_search with that name to find their phone/email.", properties: [:], required: [] ) ] } // MARK: - Tool Execution func executeTool(name: String, arguments: String) async -> [String: Any] { Log.mcp.info("Executing Contacts tool: \(name)") guard authorized else { return ["error": "Contacts permission not granted. Grant access in Settings > MCP."] } switch name { case "contacts_search": guard let data = arguments.data(using: .utf8), let args = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let query = args["query"] as? String, !query.isEmpty else { return ["error": "Missing required parameter: query"] } return search(query: query) case "contacts_get_me": return getMe() default: return ["error": "Unknown Contacts tool: \(name)"] } } // MARK: - Implementation private let keysToFetch: [CNKeyDescriptor] = [ CNContactGivenNameKey as CNKeyDescriptor, CNContactFamilyNameKey as CNKeyDescriptor, CNContactOrganizationNameKey as CNKeyDescriptor, CNContactPhoneNumbersKey as CNKeyDescriptor, CNContactEmailAddressesKey as CNKeyDescriptor, CNContactRelationsKey as CNKeyDescriptor ] private func search(query: String) -> [String: Any] { var matches: [CNContact] = [] // Fast path: name predicate let namePredicate = CNContact.predicateForContacts(matchingName: query) if let nameMatches = try? store.unifiedContacts(matching: namePredicate, keysToFetch: keysToFetch) { matches.append(contentsOf: nameMatches) } // Fallback: scan for phone/email substring matches if matches.isEmpty { let lowerQuery = query.lowercased() let digitsQuery = query.filter(\.isNumber) let request = CNContactFetchRequest(keysToFetch: keysToFetch) var scanned = 0 try? store.enumerateContacts(with: request) { contact, stop in scanned += 1 if scanned > self.maxScan || matches.count >= self.maxResults { stop.pointee = true return } let emailMatch = contact.emailAddresses.contains { ($0.value as String).lowercased().contains(lowerQuery) } let phoneMatch = !digitsQuery.isEmpty && contact.phoneNumbers.contains { $0.value.stringValue.filter(\.isNumber).contains(digitsQuery) } if emailMatch || phoneMatch { matches.append(contact) } } } let deduped = dedupContacts(matches) let formatted = deduped.prefix(maxResults).map(formatContact) return ["count": formatted.count, "contacts": Array(formatted)] } /// Collapses contacts that share a phone number or email — Contacts.app's "linked contacts" /// merge doesn't catch every real-world duplicate card, so do a best-effort merge here too. private func dedupContacts(_ contacts: [CNContact]) -> [CNContact] { var result: [CNContact] = [] outer: for contact in contacts { let phones = Set(contact.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) }) let emails = Set(contact.emailAddresses.map { ($0.value as String).lowercased() }) for existing in result { let existingPhones = Set(existing.phoneNumbers.map { $0.value.stringValue.filter(\.isNumber) }) let existingEmails = Set(existing.emailAddresses.map { ($0.value as String).lowercased() }) if !phones.isDisjoint(with: existingPhones) || !emails.isDisjoint(with: existingEmails) { continue outer } } result.append(contact) } return result } private func getMe() -> [String: Any] { guard let me = try? store.unifiedMeContactWithKeys(toFetch: keysToFetch) else { return ["error": "No 'My Card' is configured in Contacts.app"] } return formatContact(me) } private func formatContact(_ contact: CNContact) -> [String: Any] { var item: [String: Any] = [ "given_name": contact.givenName, "family_name": contact.familyName ] if !contact.organizationName.isEmpty { item["organization"] = contact.organizationName } if !contact.phoneNumbers.isEmpty { item["phones"] = contact.phoneNumbers.map { $0.value.stringValue } } if !contact.emailAddresses.isEmpty { item["emails"] = contact.emailAddresses.map { $0.value as String } } if !contact.contactRelations.isEmpty { item["relations"] = contact.contactRelations.map { labeled -> [String: String] in let label = labeled.label.map { CNLabeledValue.localizedString(forLabel: $0) } ?? "relation" return ["label": label, "name": labeled.value.name] } } return item } private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool { Tool( type: "function", function: Tool.Function( name: name, description: description, parameters: Tool.Function.Parameters( type: "object", properties: properties, required: required ) ) ) } private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property { Tool.Function.Parameters.Property(type: type, description: description, enum: nil) } }