// // EventKitService.swift // oAI // // Calendar and Reminders integration via EventKit // // 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 EventKit import Foundation import os /// Shared tri-state authorization status for the Settings UI across all Personal Data services. /// `.denied` also covers `.restricted` and (for Calendar) `.writeOnly` — states where the OS /// will not show a request dialog again; the user must go to System Settings manually. enum PersonalDataAccessState { case notDetermined case denied case granted } @Observable class EventKitService { static let shared = EventKitService() private let store = EKEventStore() private init() {} // MARK: - Authorization var calendarAuthStatus: EKAuthorizationStatus { let status = EKEventStore.authorizationStatus(for: .event) Log.mcp.debug("EventKitService.calendarAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))") return status } var reminderAuthStatus: EKAuthorizationStatus { let status = EKEventStore.authorizationStatus(for: .reminder) Log.mcp.debug("EventKitService.reminderAuthStatus -> \(Self.describe(status)) (raw=\(status.rawValue))") return status } var calendarAuthorized: Bool { calendarAuthStatus == .fullAccess } var reminderAuthorized: Bool { reminderAuthStatus == .fullAccess } var calendarAccessState: PersonalDataAccessState { switch calendarAuthStatus { case .fullAccess: return .granted case .notDetermined: return .notDetermined default: return .denied // .denied, .restricted, .writeOnly (no read access for our tools) } } var reminderAccessState: PersonalDataAccessState { switch reminderAuthStatus { case .fullAccess: return .granted case .notDetermined: return .notDetermined default: return .denied } } @discardableResult func requestCalendarAccess() async -> Bool { let before = EKEventStore.authorizationStatus(for: .event) Log.mcp.info("requestCalendarAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))") do { let granted = try await store.requestFullAccessToEvents() let after = EKEventStore.authorizationStatus(for: .event) Log.mcp.info("requestCalendarAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))") return granted } catch { let after = EKEventStore.authorizationStatus(for: .event) Log.mcp.error("requestCalendarAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))") return false } } @discardableResult func requestReminderAccess() async -> Bool { let before = EKEventStore.authorizationStatus(for: .reminder) Log.mcp.info("requestReminderAccess: status before request = \(Self.describe(before)) (raw=\(before.rawValue))") do { let granted = try await store.requestFullAccessToReminders() let after = EKEventStore.authorizationStatus(for: .reminder) Log.mcp.info("requestReminderAccess: API returned granted=\(granted); status after request = \(Self.describe(after)) (raw=\(after.rawValue))") return granted } catch { let after = EKEventStore.authorizationStatus(for: .reminder) Log.mcp.error("requestReminderAccess: threw error: \(error.localizedDescription); status after = \(Self.describe(after)) (raw=\(after.rawValue))") return false } } nonisolated static func describe(_ status: EKAuthorizationStatus) -> String { switch status { case .notDetermined: return "notDetermined" case .restricted: return "restricted" case .denied: return "denied" case .fullAccess: return "fullAccess" case .writeOnly: return "writeOnly" @unknown default: return "unknown" } } // MARK: - Tool Schemas func getToolSchemas(calendarEnabled: Bool, remindersEnabled: Bool) -> [Tool] { var tools: [Tool] = [] if calendarEnabled { tools.append(makeTool( name: "calendar_list_calendars", description: "List all calendars available on this Mac (e.g. iCloud, Work, Home).", properties: [:], required: [] )) tools.append(makeTool( name: "calendar_list_events", description: "List calendar events within a date range. Dates are ISO8601 (e.g. '2026-06-20T00:00:00' or '2026-06-20'). Range is limited to 1 year. For open-ended queries like 'next appointment' or 'upcoming events', do NOT limit the range to just today — use a generous forward-looking window (e.g. today through +90 days) so you don't miss events further out.", properties: [ "start_date": prop("string", "Start of the date range (ISO8601)"), "end_date": prop("string", "End of the date range (ISO8601)"), "calendar_name": prop("string", "Optional: only list events from this calendar") ], required: ["start_date", "end_date"] )) tools.append(makeTool( name: "calendar_create_event", description: "Create a new calendar event. Requires user approval before it is actually created.", properties: [ "title": prop("string", "Event title"), "start_date": prop("string", "Start date/time (ISO8601, e.g. '2026-06-20T14:00:00')"), "end_date": prop("string", "End date/time (ISO8601)"), "calendar_name": prop("string", "Optional: calendar to add the event to (defaults to the system default calendar)"), "location": prop("string", "Optional: event location text"), "notes": prop("string", "Optional: event notes"), "all_day": prop("boolean", "Optional: whether this is an all-day event (default: false)"), "alarm_minutes_before": prop("number", "Optional: minutes before the start time to show an alert") ], required: ["title", "start_date", "end_date"] )) } if remindersEnabled { tools.append(makeTool( name: "reminders_list_lists", description: "List all reminder lists available on this Mac.", properties: [:], required: [] )) tools.append(makeTool( name: "reminders_list", description: "List reminders. Omit list_name to search across ALL reminder lists in a single call — prefer this over calling once per list. Incomplete reminders only unless include_completed is true.", properties: [ "list_name": prop("string", "Optional: only list reminders from this one list (omit to search all lists at once)"), "include_completed": prop("boolean", "Optional: include completed reminders (default: false)") ], required: [] )) tools.append(makeTool( name: "reminders_create", description: "Create a new reminder. Requires user approval before it is actually created.", properties: [ "title": prop("string", "Reminder title"), "list_name": prop("string", "Optional: reminder list to add to (defaults to the system default list)"), "due_date": prop("string", "Optional: due date/time (ISO8601)"), "priority": prop("string", "Optional: priority level", enumValues: ["none", "low", "medium", "high"]), "notes": prop("string", "Optional: reminder notes") ], required: ["title"] )) tools.append(makeTool( name: "reminders_complete", description: "Mark a reminder as completed. Requires user approval. Use reminders_list to find the reminder_id first.", properties: [ "reminder_id": prop("string", "The reminder's identifier, from reminders_list") ], required: ["reminder_id"] )) } return tools } // MARK: - Read Tool Execution func executeTool(name: String, arguments: String) async -> [String: Any] { Log.mcp.info("Executing EventKit tool: \(name)") let args = Self.parseArgs(arguments) switch name { case "calendar_list_calendars": guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") } return listCalendars() case "calendar_list_events": guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") } guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else { return ["error": "Missing or invalid parameter: start_date"] } guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else { return ["error": "Missing or invalid parameter: end_date"] } let calendarName = args["calendar_name"] as? String return listEvents(start: start, end: end, calendarName: calendarName) case "reminders_list_lists": guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") } return listReminderLists() case "reminders_list": guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") } let listName = args["list_name"] as? String let includeCompleted = args["include_completed"] as? Bool ?? false return await listReminders(listName: listName, includeCompleted: includeCompleted) default: return ["error": "Unknown EventKit tool: \(name)"] } } // MARK: - Write Tool Execution (called only after approval) func executeWriteTool(name: String, arguments: String) async -> [String: Any] { Log.mcp.info("Executing EventKit write tool: \(name)") let args = Self.parseArgs(arguments) switch name { case "calendar_create_event": guard calendarAuthorized else { return Self.permissionError(domain: "Calendar") } return createEvent(args: args) case "reminders_create": guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") } return createReminder(args: args) case "reminders_complete": guard reminderAuthorized else { return Self.permissionError(domain: "Reminders") } guard let reminderId = args["reminder_id"] as? String else { return ["error": "Missing required parameter: reminder_id"] } return completeReminder(reminderId: reminderId) default: return ["error": "Unknown EventKit write tool: \(name)"] } } // MARK: - Approval Summary /// Human-readable description shown in the approval sheet before a write tool runs. func approvalSummary(forTool name: String, arguments: String) -> String { let args = Self.parseArgs(arguments) switch name { case "calendar_create_event": let title = args["title"] as? String ?? "Untitled event" let start = (args["start_date"] as? String).flatMap(Self.parseDate) ?? Date() let end = (args["end_date"] as? String).flatMap(Self.parseDate) ?? start return "Create calendar event \"\(title)\" from \(Self.displayFormatter.string(from: start)) to \(Self.displayFormatter.string(from: end))" case "reminders_create": let title = args["title"] as? String ?? "Untitled reminder" if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) { return "Create reminder \"\(title)\" due \(Self.displayFormatter.string(from: due))" } return "Create reminder \"\(title)\"" case "reminders_complete": return "Mark reminder as completed" default: return "Perform action: \(name)" } } // MARK: - Calendar Read Implementations private func listCalendars() -> [String: Any] { let calendars = store.calendars(for: .event).map { cal -> [String: Any] in [ "name": cal.title, "type": calendarTypeDescription(cal), "allows_modifications": cal.allowsContentModifications ] } return ["calendars": calendars] } private func listEvents(start: Date, end: Date, calendarName: String?) -> [String: Any] { guard end > start else { return ["error": "end_date must be after start_date"] } guard end.timeIntervalSince(start) <= 366 * 24 * 60 * 60 else { return ["error": "Date range too large — limit to 1 year or less"] } var calendars = store.calendars(for: .event) if let calendarName { calendars = calendars.filter { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame } if calendars.isEmpty { return ["error": "No calendar found named '\(calendarName)'"] } } let predicate = store.predicateForEvents(withStart: start, end: end, calendars: calendars) let events = store.events(matching: predicate) .sorted { $0.startDate < $1.startDate } .prefix(200) .map { event -> [String: Any] in var item: [String: Any] = [ "id": event.eventIdentifier ?? "", "title": event.title ?? "Untitled", "start": Self.isoFormatter.string(from: event.startDate), "end": Self.isoFormatter.string(from: event.endDate), "all_day": event.isAllDay, "calendar": event.calendar?.title ?? "" ] if let location = event.location, !location.isEmpty { item["location"] = location } if let notes = event.notes, !notes.isEmpty { item["notes"] = String(notes.prefix(500)) } return item } return ["count": events.count, "events": Array(events)] } private func createEvent(args: [String: Any]) -> [String: Any] { guard let title = args["title"] as? String, !title.isEmpty else { return ["error": "Missing required parameter: title"] } guard let startStr = args["start_date"] as? String, let start = Self.parseDate(startStr) else { return ["error": "Missing or invalid parameter: start_date"] } guard let endStr = args["end_date"] as? String, let end = Self.parseDate(endStr) else { return ["error": "Missing or invalid parameter: end_date"] } guard end >= start else { return ["error": "end_date must not be before start_date"] } let event = EKEvent(eventStore: store) event.title = title event.startDate = start event.endDate = end event.isAllDay = args["all_day"] as? Bool ?? false if let calendarName = args["calendar_name"] as? String, let calendar = store.calendars(for: .event).first(where: { $0.title.caseInsensitiveCompare(calendarName) == .orderedSame }) { event.calendar = calendar } else if let defaultCalendar = store.defaultCalendarForNewEvents { event.calendar = defaultCalendar } else { guard let fallback = store.calendars(for: .event).first(where: { $0.allowsContentModifications }) else { return ["error": "No writable calendar available"] } event.calendar = fallback } if let location = args["location"] as? String { event.location = location } if let notes = args["notes"] as? String { event.notes = notes } if let minutesBefore = (args["alarm_minutes_before"] as? Double) ?? (args["alarm_minutes_before"] as? Int).map(Double.init) { event.addAlarm(EKAlarm(relativeOffset: -(minutesBefore * 60))) } do { try store.save(event, span: .thisEvent, commit: true) return ["success": true, "event_id": event.eventIdentifier ?? "", "calendar": event.calendar?.title ?? ""] } catch { Log.mcp.error("calendar_create_event failed: \(error.localizedDescription)") return ["error": "Failed to create event: \(error.localizedDescription)"] } } // MARK: - Reminders Read Implementations private func listReminderLists() -> [String: Any] { let lists = store.calendars(for: .reminder).map { cal -> [String: Any] in ["name": cal.title, "allows_modifications": cal.allowsContentModifications] } return ["lists": lists] } private func listReminders(listName: String?, includeCompleted: Bool) async -> [String: Any] { var lists = store.calendars(for: .reminder) if let listName { lists = lists.filter { $0.title.caseInsensitiveCompare(listName) == .orderedSame } if lists.isEmpty { return ["error": "No reminder list found named '\(listName)'"] } } let predicate = store.predicateForReminders(in: lists) let reminders: [EKReminder] = await withCheckedContinuation { continuation in store.fetchReminders(matching: predicate) { results in continuation.resume(returning: results ?? []) } } let filtered = reminders .filter { includeCompleted || !$0.isCompleted } .sorted { lhs, rhs in let l = lhs.dueDateComponents?.date ?? .distantFuture let r = rhs.dueDateComponents?.date ?? .distantFuture return l < r } .prefix(200) .map { reminder -> [String: Any] in var item: [String: Any] = [ "id": reminder.calendarItemIdentifier, "title": reminder.title ?? "Untitled", "completed": reminder.isCompleted, "list": reminder.calendar?.title ?? "" ] if let due = reminder.dueDateComponents?.date { item["due"] = Self.isoFormatter.string(from: due) } if reminder.priority > 0 { item["priority"] = priorityDescription(reminder.priority) } if let notes = reminder.notes, !notes.isEmpty { item["notes"] = String(notes.prefix(500)) } return item } return ["count": filtered.count, "reminders": Array(filtered)] } private func createReminder(args: [String: Any]) -> [String: Any] { guard let title = args["title"] as? String, !title.isEmpty else { return ["error": "Missing required parameter: title"] } let reminder = EKReminder(eventStore: store) reminder.title = title if let listName = args["list_name"] as? String, let list = store.calendars(for: .reminder).first(where: { $0.title.caseInsensitiveCompare(listName) == .orderedSame }) { reminder.calendar = list } else if let defaultList = store.defaultCalendarForNewReminders() { reminder.calendar = defaultList } else { guard let fallback = store.calendars(for: .reminder).first(where: { $0.allowsContentModifications }) else { return ["error": "No writable reminder list available"] } reminder.calendar = fallback } if let dueStr = args["due_date"] as? String, let due = Self.parseDate(dueStr) { reminder.dueDateComponents = Calendar.current.dateComponents([.year, .month, .day, .hour, .minute, .second], from: due) } if let notes = args["notes"] as? String { reminder.notes = notes } if let priority = args["priority"] as? String { reminder.priority = priorityValue(priority) } do { try store.save(reminder, commit: true) return ["success": true, "reminder_id": reminder.calendarItemIdentifier, "list": reminder.calendar?.title ?? ""] } catch { Log.mcp.error("reminders_create failed: \(error.localizedDescription)") return ["error": "Failed to create reminder: \(error.localizedDescription)"] } } private func completeReminder(reminderId: String) -> [String: Any] { guard let item = store.calendarItem(withIdentifier: reminderId) as? EKReminder else { return ["error": "No reminder found with id '\(reminderId)'"] } item.isCompleted = true item.completionDate = Date() do { try store.save(item, commit: true) return ["success": true, "reminder_id": reminderId, "title": item.title ?? ""] } catch { Log.mcp.error("reminders_complete failed: \(error.localizedDescription)") return ["error": "Failed to complete reminder: \(error.localizedDescription)"] } } // MARK: - Helpers private func calendarTypeDescription(_ cal: EKCalendar) -> String { switch cal.type { case .local: return "local" case .calDAV: return "caldav" case .exchange: return "exchange" case .subscription: return "subscription" case .birthday: return "birthday" @unknown default: return "unknown" } } private func priorityDescription(_ value: Int) -> String { switch value { case 1...4: return "high" case 5: return "medium" case 6...9: return "low" default: return "none" } } private func priorityValue(_ description: String) -> Int { switch description.lowercased() { case "high": return 1 case "medium": return 5 case "low": return 9 default: return 0 } } 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, enumValues: [String]? = nil) -> Tool.Function.Parameters.Property { Tool.Function.Parameters.Property(type: type, description: description, enum: enumValues) } nonisolated static func parseArgs(_ arguments: String) -> [String: Any] { guard let data = arguments.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return [:] } return dict } nonisolated static func permissionError(domain: String) -> [String: Any] { ["error": "\(domain) permission not granted. Grant access in Settings > MCP."] } nonisolated(unsafe) static let isoFormatter: ISO8601DateFormatter = { let f = ISO8601DateFormatter() f.formatOptions = [.withInternetDateTime] return f }() nonisolated static let displayFormatter: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .short return f }() nonisolated static func parseDate(_ string: String) -> Date? { if let date = isoFormatter.date(from: string) { return date } let isoNoTimezone = ISO8601DateFormatter() isoNoTimezone.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = isoNoTimezone.date(from: string) { return date } let localDateTime = DateFormatter() localDateTime.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" if let date = localDateTime.date(from: string) { return date } let dateOnly = DateFormatter() dateOnly.dateFormat = "yyyy-MM-dd" if let date = dateOnly.date(from: string) { return date } return nil } }