Skip to main content
Glama
Maps.swift27.4 kB
import Foundation import MapKit import OSLog import Ontology private let log = Logger.service("maps") private let defaultSearchRadius: CLLocationDistance = 5000 // Default 5km private let defaultSearchLimit: Int = 10 private let defaultMapImageSize: CGSize = CGSize(width: 1024, height: 1024) final class MapsService: NSObject, Service { private let searchCompleter = MKLocalSearchCompleter() private var searchResults: [MKLocalSearchCompletion] = [] private var searchContinuation: CheckedContinuation<[MKLocalSearchCompletion], Error>? static let shared = MapsService() override init() { log.debug("Initializing maps service") super.init() self.searchCompleter.delegate = self } var isActivated: Bool { get async { // MapKit doesn't require explicit permission, but we rely on location return await LocationService.shared.isActivated } } func activate() async throws { log.debug("Activating maps service") // Maps service depends on location service being active try await LocationService.shared.activate() } var tools: [Tool] { Tool( name: "maps_search", description: "Search for places, addresses, points of interest by text query", inputSchema: .object( properties: [ "query": .string( description: "Search text (place name, address, etc.)" ), "region": .object( description: "Region to bias search results", properties: [ "latitude": .number(), "longitude": .number(), "radius": .number( description: "Search radius in meters", default: .double(defaultSearchRadius) ), ], required: ["latitude", "longitude"], additionalProperties: false ), ], required: ["query"], additionalProperties: false ), annotations: .init( title: "Search Places", readOnlyHint: true, openWorldHint: true ) ) { arguments in guard let query = arguments["query"]?.stringValue else { throw NSError( domain: "MapsServiceError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Search query is required"] ) } // Set up search request let searchRequest = MKLocalSearch.Request() searchRequest.naturalLanguageQuery = query // Configure region if provided if let regionArg = arguments["region"]?.objectValue, let lat = regionArg["latitude"]?.doubleValue, let lon = regionArg["longitude"]?.doubleValue { let radius = regionArg["radius"]?.doubleValue ?? defaultSearchRadius let center = CLLocationCoordinate2D(latitude: lat, longitude: lon) let region = MKCoordinateRegion( center: center, latitudinalMeters: radius, longitudinalMeters: radius ) searchRequest.region = region } return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[Place], Error>) in let search = MKLocalSearch(request: searchRequest) search.start { response, error in if let error = error { continuation.resume(throwing: error) return } guard let response = response else { continuation.resume( throwing: NSError( domain: "MapsServiceError", code: 2, userInfo: [NSLocalizedDescriptionKey: "No search results"] ) ) return } // Convert MKMapItems to Ontology Place objects let places = response.mapItems.map { item -> Place in return Place(item) } continuation.resume(returning: places) } } } Tool( name: "maps_directions", description: "Get directions between two locations with optional transport type", inputSchema: .object( properties: [ "originAddress": .string( description: "Origin address" ), "originCoordinates": .object( description: "Origin coordinates", properties: [ "latitude": .number(), "longitude": .number(), ], required: ["latitude", "longitude"], additionalProperties: false ), "destinationAddress": .string( description: "Destination address" ), "destinationCoordinates": .object( description: "Destination coordinates", properties: [ "latitude": .number(), "longitude": .number(), ], required: ["latitude", "longitude"], additionalProperties: false ), "transportType": .string( description: "Transport type", default: "automobile", enum: ["automobile", "walking", "transit", "any"] ), ], additionalProperties: false ), annotations: .init( title: "Get Directions", readOnlyHint: true, openWorldHint: true ) ) { arguments in // Need either origin address or coordinates guard arguments["originAddress"]?.stringValue != nil || (arguments["originCoordinates"]?.objectValue?["latitude"]?.doubleValue != nil && arguments["originCoordinates"]?.objectValue?["longitude"]?.doubleValue != nil) else { throw NSError( domain: "MapsServiceError", code: 3, userInfo: [NSLocalizedDescriptionKey: "Origin address or coordinates required"] ) } // Need either destination address or coordinates guard arguments["destinationAddress"]?.stringValue != nil || (arguments["destinationCoordinates"]?.objectValue?["latitude"]?.doubleValue != nil && arguments["destinationCoordinates"]?.objectValue?["longitude"]? .doubleValue != nil) else { throw NSError( domain: "MapsServiceError", code: 4, userInfo: [ NSLocalizedDescriptionKey: "Destination address or coordinates required" ] ) } // Get origin and destination let originItem = try await self.getMapItem( address: arguments["originAddress"]?.stringValue, coordinates: arguments["originCoordinates"]?.objectValue ) let destinationItem = try await self.getMapItem( address: arguments["destinationAddress"]?.stringValue, coordinates: arguments["destinationCoordinates"]?.objectValue ) // Set up directions request let directionsRequest = MKDirections.Request() directionsRequest.source = originItem directionsRequest.destination = destinationItem // Set transport type switch arguments["transportType"]?.stringValue { case "automobile": directionsRequest.transportType = .automobile case "walking": directionsRequest.transportType = .walking case "transit": directionsRequest.transportType = .transit default: directionsRequest.transportType = .any } return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Trip, Error>) in let directions = MKDirections(request: directionsRequest) directions.calculate { response, error in if let error = error { continuation.resume(throwing: error) return } guard let response = response, !response.routes.isEmpty else { continuation.resume( throwing: NSError( domain: "MapsServiceError", code: 5, userInfo: [NSLocalizedDescriptionKey: "No routes found"] ) ) return } let trip = Trip(response) continuation.resume(returning: trip) } } } Tool( name: "maps_explore", description: "Find points of interest near a location", inputSchema: .object( properties: [ "category": .string( description: "POI category", enum: MKPointOfInterestCategory.allCases.map { .string($0.stringValue) } ), "latitude": .number(), "longitude": .number(), "radius": .number( description: "Search radius in meters", default: .double(defaultSearchRadius) ), "limit": .integer( description: "Maximum results to return", default: .int(defaultSearchLimit) ), ], required: ["category", "latitude", "longitude"], additionalProperties: false ), annotations: .init( title: "Find Nearby Points of Interest", readOnlyHint: true, openWorldHint: true ) ) { arguments in guard let categoryString = arguments["category"]?.stringValue, let latitude = arguments["latitude"]?.doubleValue, let longitude = arguments["longitude"]?.doubleValue else { throw NSError( domain: "MapsServiceError", code: 6, userInfo: [NSLocalizedDescriptionKey: "Category and coordinates are required"] ) } let radius = arguments["radius"]?.doubleValue ?? defaultSearchRadius let limit = arguments["limit"]?.intValue ?? defaultSearchLimit guard let category = MKPointOfInterestCategory.from(string: categoryString) else { throw NSError( domain: "MapsServiceError", code: 7, userInfo: [NSLocalizedDescriptionKey: "Invalid POI category"] ) } // Create search request let request = MKLocalPointsOfInterestRequest( center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), radius: radius ) request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[Place], Error>) in let search = MKLocalSearch(request: request) search.start { response, error in if let error = error { continuation.resume(throwing: error) return } guard let response = response else { continuation.resume( throwing: NSError( domain: "MapsServiceError", code: 8, userInfo: [NSLocalizedDescriptionKey: "No POI results found"] ) ) return } // Convert MKMapItems to Value objects let places = response.mapItems.prefix(limit).map { item -> Place in return Place(item) } continuation.resume(returning: places) } } } Tool( name: "maps_eta", description: "Calculate estimated travel time between two locations", inputSchema: .object( properties: [ "originLatitude": .number(), "originLongitude": .number(), "destinationLatitude": .number(), "destinationLongitude": .number(), "transportType": .string( description: "Transport type", default: "automobile", enum: ["automobile", "walking", "transit"] ), ], required: [ "originLatitude", "originLongitude", "destinationLatitude", "destinationLongitude", ], additionalProperties: false ), annotations: .init( title: "Get Estimated Travel Time", readOnlyHint: true, openWorldHint: true ) ) { arguments in guard let originLat = arguments["originLatitude"]?.doubleValue, let originLng = arguments["originLongitude"]?.doubleValue, let destLat = arguments["destinationLatitude"]?.doubleValue, let destLng = arguments["destinationLongitude"]?.doubleValue else { throw NSError( domain: "MapsServiceError", code: 9, userInfo: [ NSLocalizedDescriptionKey: "Origin and destination coordinates required" ] ) } // Create origin and destination placemarks let originPlacemark = MKPlacemark( coordinate: CLLocationCoordinate2D(latitude: originLat, longitude: originLng) ) let destPlacemark = MKPlacemark( coordinate: CLLocationCoordinate2D(latitude: destLat, longitude: destLng) ) // Create map items from placemarks let originItem = MKMapItem(placemark: originPlacemark) let destinationItem = MKMapItem(placemark: destPlacemark) // Set up directions request let directionsRequest = MKDirections.Request() directionsRequest.source = originItem directionsRequest.destination = destinationItem // Set transport type if let transportTypeStr = arguments["transportType"]?.stringValue { switch transportTypeStr { case "automobile": directionsRequest.transportType = .automobile case "walking": directionsRequest.transportType = .walking case "transit": directionsRequest.transportType = .transit default: directionsRequest.transportType = .automobile } } else { directionsRequest.transportType = .automobile } return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Trip, Error>) in let directions = MKDirections(request: directionsRequest) directions.calculateETA { response, error in if let error = error { continuation.resume(throwing: error) return } guard let response = response else { continuation.resume( throwing: NSError( domain: "MapsServiceError", code: 10, userInfo: [NSLocalizedDescriptionKey: "Could not calculate ETA"] ) ) return } let trip = Trip(response) continuation.resume(returning: trip) } } } Tool( name: "maps_generate", description: "Generate a static map image for given coordinates and parameters", inputSchema: .object( properties: [ "latitude": .number(), "longitude": .number(), "latitudeDelta": .number( description: "Latitude degrees visible on map" ), "longitudeDelta": .number( description: "Longitude degrees visible on map" ), "width": .integer( description: "Image width in pixels", default: .int(Int(defaultMapImageSize.width)) ), "height": .integer( description: "Image height in pixels", default: .int(Int(defaultMapImageSize.height)) ), "mapType": .string( description: "Map type", default: "standard", enum: ["standard", "satellite", "hybrid", "mutedStandard"] ), "showPointsOfInterest": .oneOf( [ .boolean( description: "Show all (true) or no (false) POIs", default: false ), .array( description: "Specific POI types to show", items: .anyOf( MKPointOfInterestCategory.allCases.map { .string(const: .string($0.stringValue)) } ), minItems: 1 ), ] ), "showBuildings": .boolean( description: "Whether to show buildings", default: false ), ], required: ["latitude", "longitude", "latitudeDelta", "longitudeDelta"], additionalProperties: false ), annotations: .init( title: "Generate Map Image", readOnlyHint: true, openWorldHint: true ) ) { arguments in guard let latitude = arguments["latitude"]?.doubleValue, let longitude = arguments["longitude"]?.doubleValue, let latitudeDelta = arguments["latitudeDelta"]?.doubleValue, let longitudeDelta = arguments["longitudeDelta"]?.doubleValue else { throw NSError( domain: "MapsServiceError", code: 13, userInfo: [ NSLocalizedDescriptionKey: "Latitude, longitude, latitudeDelta, and longitudeDelta are required" ] ) } let width = arguments["width"]?.intValue ?? Int(defaultMapImageSize.width) let height = arguments["height"]?.intValue ?? Int(defaultMapImageSize.height) let mapTypeString = arguments["mapType"]?.stringValue ?? "standard" let options = MKMapSnapshotter.Options() let center = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) let span = MKCoordinateSpan( latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta) options.region = MKCoordinateRegion(center: center, span: span) switch mapTypeString { case "satellite": options.mapType = .satellite case "hybrid": options.mapType = .hybrid case "mutedStandard": options.mapType = .mutedStandard default: options.mapType = .standard } options.size = CGSize(width: width, height: height) let filter: MKPointOfInterestFilter switch arguments["showPointsOfInterest"] { case .bool(true), .string("true"): filter = .includingAll case .bool(false), .string("false"): filter = .excludingAll case let .string(string): do { let jsonData = string.data(using: .utf8)! let poiStrings = try JSONDecoder().decode([String].self, from: jsonData) let categories = poiStrings.compactMap { MKPointOfInterestCategory.from(string: $0) } filter = categories.isEmpty ? .excludingAll : .init(including: categories) } catch { filter = .excludingAll } case let .array(poiTypes): let categories = poiTypes.compactMap { $0.stringValue }.compactMap { MKPointOfInterestCategory.from(string: $0) } filter = categories.isEmpty ? .excludingAll : .init(including: categories) default: filter = .excludingAll } options.pointOfInterestFilter = filter options.showsBuildings = arguments["showBuildings"]?.boolValue == true return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Value, Error>) in let snapshotter = MKMapSnapshotter(options: options) snapshotter.start { snapshot, error in if let error = error { log.error("Map snapshot failed: \(error.localizedDescription)") continuation.resume(throwing: error) return } guard let snapshot = snapshot else { log.error("Map snapshot failed: No snapshot data") continuation.resume( throwing: NSError( domain: "MapsServiceError", code: 14, userInfo: [ NSLocalizedDescriptionKey: "Failed to generate map snapshot" ] ) ) return } // Get image representation (e.g., PNG) guard let imageData = snapshot.image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: imageData), let pngData = bitmap.representation(using: .png, properties: [:]) else { log.error("Map snapshot failed: Could not convert image to PNG") continuation.resume( throwing: NSError( domain: "MapsServiceError", code: 15, userInfo: [ NSLocalizedDescriptionKey: "Failed to convert snapshot to PNG format" ] ) ) return } continuation.resume( returning: .data(mimeType: "image/png", pngData) ) } } } } // MARK: - Helper methods private func getMapItem(address: String?, coordinates: [String: Value]?) async throws -> MKMapItem { if let address = address { // Use geocoding to get location from address let searchRequest = MKLocalSearch.Request() searchRequest.naturalLanguageQuery = address let search = MKLocalSearch(request: searchRequest) let response = try await search.start() guard let mapItem = response.mapItems.first else { throw NSError( domain: "MapsServiceError", code: 11, userInfo: [ NSLocalizedDescriptionKey: "Could not find location for address: \(address)" ] ) } return mapItem } else if let coordinates = coordinates, let lat = coordinates["latitude"]?.doubleValue, let lng = coordinates["longitude"]?.doubleValue { // Create placemark and map item from coordinates let placemark = MKPlacemark( coordinate: CLLocationCoordinate2D(latitude: lat, longitude: lng) ) return MKMapItem(placemark: placemark) } else { throw NSError( domain: "MapsServiceError", code: 12, userInfo: [ NSLocalizedDescriptionKey: "Either address or coordinates must be provided" ] ) } } } // MARK: - MKLocalSearchCompleterDelegate extension MapsService: MKLocalSearchCompleterDelegate { func completerDidUpdateResults(_ completer: MKLocalSearchCompleter) { self.searchResults = completer.results self.searchContinuation?.resume(returning: completer.results) self.searchContinuation = nil } func completer(_ completer: MKLocalSearchCompleter, didFailWithError error: Error) { self.searchContinuation?.resume(throwing: error) self.searchContinuation = nil } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/mattt/iMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server