// // LocationMapsService.swift // oAI // // Read-only Location and Maps integration via CoreLocation and MapKit // // 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 CoreLocation import Foundation import MapKit import os @Observable class LocationMapsService: NSObject, CLLocationManagerDelegate { static let shared = LocationMapsService() private let locationManager = CLLocationManager() private var authContinuation: CheckedContinuation? private var locationContinuation: CheckedContinuation? private override init() { super.init() locationManager.delegate = self } // MARK: - Authorization var authStatus: CLAuthorizationStatus { locationManager.authorizationStatus } var authorized: Bool { authStatus == .authorizedAlways || authStatus == .authorized } var accessState: PersonalDataAccessState { let status = authStatus Log.mcp.debug("LocationMapsService.accessState -> status=\(Self.describe(status)) (raw=\(status.rawValue))") switch status { case .authorizedAlways, .authorized: return .granted case .notDetermined: return .notDetermined default: return .denied } } @discardableResult func requestAccess() async -> Bool { let before = locationManager.authorizationStatus Log.mcp.info("LocationMapsService.requestAccess: status before = \(Self.describe(before)) (raw=\(before.rawValue))") if before != .notDetermined { Log.mcp.info("LocationMapsService.requestAccess: skipping OS prompt (not notDetermined)") return authorized } return await withCheckedContinuation { continuation in self.authContinuation = continuation locationManager.requestWhenInUseAuthorization() } } func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { let status = manager.authorizationStatus Log.mcp.info("LocationMapsService: authorization changed -> \(Self.describe(status)) (raw=\(status.rawValue))") authContinuation?.resume(returning: authorized) authContinuation = nil } nonisolated static func describe(_ status: CLAuthorizationStatus) -> String { switch status { case .notDetermined: return "notDetermined" case .restricted: return "restricted" case .denied: return "denied" case .authorizedAlways: return "authorizedAlways" case .authorized: return "authorized" @unknown default: return "unknown" } } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { locationContinuation?.resume(returning: locations.last) locationContinuation = nil } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { Log.mcp.error("Location request failed: \(error.localizedDescription)") locationContinuation?.resume(returning: nil) locationContinuation = nil } private func currentLocation() async -> CLLocation? { await withCheckedContinuation { continuation in self.locationContinuation = continuation locationManager.requestLocation() } } // MARK: - Tool Schemas func getToolSchemas() -> [Tool] { [ makeTool( name: "location_get_current", description: "Get the device's current location, including a human-readable address.", properties: [:], required: [] ), makeTool( name: "maps_search_places", description: "Search for places (businesses, landmarks, addresses) by name or category.", properties: [ "query": prop("string", "What to search for, e.g. 'coffee shops' or 'Eiffel Tower'"), "near": prop("string", "Optional: an address or 'latitude,longitude' to search near") ], required: ["query"] ), makeTool( name: "maps_geocode", description: "Convert an address into geographic coordinates and a formatted address.", properties: [ "address": prop("string", "The address to geocode") ], required: ["address"] ), makeTool( name: "maps_get_directions", description: "Get distance and estimated travel time between two locations.", properties: [ "origin": prop("string", "Starting address or 'latitude,longitude'"), "destination": prop("string", "Destination address or 'latitude,longitude'"), "transport_type": prop("string", "Mode of transport", enumValues: ["driving", "walking", "transit"]) ], required: ["origin", "destination"] ) ] } // MARK: - Tool Execution func executeTool(name: String, arguments: String) async -> [String: Any] { Log.mcp.info("Executing LocationMaps tool: \(name)") let args = parseArgs(arguments) switch name { case "location_get_current": guard authorized else { return ["error": "Location permission not granted. Grant access in Settings > MCP."] } return await getCurrentLocation() case "maps_search_places": guard let query = args["query"] as? String, !query.isEmpty else { return ["error": "Missing required parameter: query"] } let near = args["near"] as? String return await searchPlaces(query: query, near: near) case "maps_geocode": guard let address = args["address"] as? String, !address.isEmpty else { return ["error": "Missing required parameter: address"] } return await geocode(address: address) case "maps_get_directions": guard let origin = args["origin"] as? String, let destination = args["destination"] as? String else { return ["error": "Missing required parameter: origin and/or destination"] } let transportType = args["transport_type"] as? String ?? "driving" return await getDirections(origin: origin, destination: destination, transportType: transportType) default: return ["error": "Unknown LocationMaps tool: \(name)"] } } // MARK: - Implementations private func getCurrentLocation() async -> [String: Any] { guard let location = await currentLocation() else { return ["error": "Could not determine current location"] } var result: [String: Any] = [ "latitude": location.coordinate.latitude, "longitude": location.coordinate.longitude ] if let mapItem = await reverseGeocode(location), let address = addressString(for: mapItem) { result["address"] = address } return result } private func searchPlaces(query: String, near: String?) async -> [String: Any] { let request = MKLocalSearch.Request() request.naturalLanguageQuery = query if let near, let coordinate = await coordinate(for: near) { request.region = MKCoordinateRegion(center: coordinate, latitudinalMeters: 20_000, longitudinalMeters: 20_000) } do { let response = try await MKLocalSearch(request: request).start() let places = response.mapItems.prefix(15).map { item -> [String: Any] in var place: [String: Any] = ["name": item.name ?? "Unknown"] place["latitude"] = item.location.coordinate.latitude place["longitude"] = item.location.coordinate.longitude if let address = addressString(for: item) { place["address"] = address } if let phone = item.phoneNumber { place["phone"] = phone } return place } return ["count": places.count, "places": Array(places)] } catch { return ["error": "Search failed: \(error.localizedDescription)"] } } private func geocode(address: String) async -> [String: Any] { guard let request = MKGeocodingRequest(addressString: address) else { return ["error": "Invalid address: \(address)"] } do { guard let mapItem = try await request.mapItems.first else { return ["error": "No results found for address: \(address)"] } var result: [String: Any] = [ "latitude": mapItem.location.coordinate.latitude, "longitude": mapItem.location.coordinate.longitude ] if let formatted = addressString(for: mapItem) { result["formatted_address"] = formatted } return result } catch { return ["error": "Geocoding failed: \(error.localizedDescription)"] } } private func getDirections(origin: String, destination: String, transportType: String) async -> [String: Any] { guard let originCoordinate = await coordinate(for: origin) else { return ["error": "Could not resolve origin: \(origin)"] } guard let destinationCoordinate = await coordinate(for: destination) else { return ["error": "Could not resolve destination: \(destination)"] } let request = MKDirections.Request() request.source = MKMapItem(location: CLLocation(latitude: originCoordinate.latitude, longitude: originCoordinate.longitude), address: nil) request.destination = MKMapItem(location: CLLocation(latitude: destinationCoordinate.latitude, longitude: destinationCoordinate.longitude), address: nil) switch transportType { case "walking": request.transportType = .walking case "transit": request.transportType = .transit default: request.transportType = .automobile } do { let response = try await MKDirections(request: request).calculate() guard let route = response.routes.first else { return ["error": "No route found"] } let distanceFormatter = MKDistanceFormatter() let durationFormatter = DateComponentsFormatter() durationFormatter.allowedUnits = [.hour, .minute] durationFormatter.unitsStyle = .short return [ "distance_meters": route.distance, "distance_text": distanceFormatter.string(fromDistance: route.distance), "duration_seconds": route.expectedTravelTime, "duration_text": durationFormatter.string(from: route.expectedTravelTime) ?? "", "transport_type": transportType ] } catch { return ["error": "Directions failed: \(error.localizedDescription)"] } } // MARK: - Helpers private func coordinate(for text: String) async -> CLLocationCoordinate2D? { let parts = text.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } if parts.count == 2, let lat = Double(parts[0]), let lon = Double(parts[1]) { return CLLocationCoordinate2D(latitude: lat, longitude: lon) } guard let request = MKGeocodingRequest(addressString: text) else { return nil } if let mapItems = try? await request.mapItems, let mapItem = mapItems.first { return mapItem.location.coordinate } return nil } private func reverseGeocode(_ location: CLLocation) async -> MKMapItem? { guard let request = MKReverseGeocodingRequest(location: location) else { return nil } let mapItems = try? await request.mapItems return mapItems?.first } private func addressString(for mapItem: MKMapItem) -> String? { mapItem.address?.fullAddress ?? mapItem.addressRepresentations?.fullAddress(includingRegion: true, singleLine: true) } private 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 } 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) } }