40c5f25517
Personal Data Tools: native Calendar, Reminders, Contacts (hidden pending Apple TCC fix in beta), and Location & Maps access via EventKit, Contacts framework, and MapKit. Write actions (create event/reminder, complete reminder) gate through an approval sheet. Four hardened-runtime entitlements added to oAI.entitlements; Info.plist usage strings added for all services. Personal Data section shows a β badge while Contacts is hidden. 2nd Brain always-trust: inline toggle on the Agent Skills row for the skill named "2nd Brain" skips the bash approval dialog when the command contains .brain_helper.py, gated by three runtime checks in MCPService. Research agents: spawn_research_agents tool runs up to 5 concurrent read-only sub-agents (read_file, list_directory, search_files, web_search — no write, no bash, no nesting). Bounded by maxConcurrentAgents setting (default 3) and a hard ceiling of 8 tasks. Added items field to Tool.Function.Parameters.Property for JSON Schema array support; wired into AnthropicProvider.convertParametersToDict. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
594 lines
25 KiB
Swift
594 lines
25 KiB
Swift
//
|
|
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
|
|
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
|
|
}
|
|
}
|