Skip to main content

mcp_maps/
server.rs

1use rmcp::{handler::server::wrapper::Parameters, schemars, tool, tool_router};
2use reqwest::Client;
3use serde_json::{json, Value};
4
5#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
6pub struct GeocodeInput {
7    /// Address or place name to geocode
8    pub query: String,
9    /// Max results (default 5)
10    pub limit: Option<u32>,
11}
12
13#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
14pub struct ReverseInput {
15    /// Latitude
16    pub lat: f64,
17    /// Longitude
18    pub lon: f64,
19}
20
21#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
22pub struct RouteInput {
23    /// Origin latitude
24    pub origin_lat: f64,
25    /// Origin longitude
26    pub origin_lon: f64,
27    /// Destination latitude
28    pub dest_lat: f64,
29    /// Destination longitude
30    pub dest_lon: f64,
31    /// Travel mode: driving, walking, cycling (default: driving)
32    pub mode: Option<String>,
33}
34
35#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
36pub struct ElevationInput {
37    /// Latitude
38    pub lat: f64,
39    /// Longitude
40    pub lon: f64,
41}
42
43#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
44pub struct PoiInput {
45    /// Latitude of center point
46    pub lat: f64,
47    /// Longitude of center point
48    pub lon: f64,
49    /// POI type (hospital, restaurant, school, bank, pharmacy, fuel, hotel, supermarket, park, police)
50    pub poi_type: String,
51    /// Search radius in meters (default 2000)
52    pub radius: Option<u32>,
53    /// Max results (default 10)
54    pub limit: Option<u32>,
55}
56
57#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
58pub struct DistanceInput {
59    /// List of coordinate pairs [[lat,lon], [lat,lon], ...] (2-25 points)
60    pub points: Vec<[f64; 2]>,
61    /// Travel mode: driving, walking, cycling (default: driving)
62    pub mode: Option<String>,
63}
64
65#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
66pub struct TimezoneInput {
67    /// Latitude
68    pub lat: f64,
69    /// Longitude
70    pub lon: f64,
71}
72
73#[derive(Clone)]
74pub struct MapsServer {
75    pub client: Client,
76}
77
78impl MapsServer {
79    pub fn new() -> Self {
80        let client = Client::builder()
81            .danger_accept_invalid_certs(true)
82            .http1_only()
83            .user_agent("mcp-maps/1.0 (https://github.com/zavora-ai/mcp-maps)")
84            .build()
85            .unwrap_or_default();
86        Self { client }
87    }
88    fn ua(&self) -> &'static str { "mcp-maps/1.0 (https://github.com/zavora-ai/mcp-maps)" }
89}
90
91#[tool_router(server_handler)]
92impl MapsServer {
93    #[tool(description = "Geocode an address or place name to coordinates (lat/lon). Supports worldwide locations.")]
94    async fn geocode(&self, Parameters(input): Parameters<GeocodeInput>) -> String {
95        let limit = input.limit.unwrap_or(5);
96        let url = format!(
97            "https://nominatim.openstreetmap.org/search?q={}&format=json&limit={}&addressdetails=1",
98            urlencoding::encode(&input.query), limit
99        );
100        match self.client.get(&url).header("User-Agent", self.ua()).send().await {
101            Ok(resp) => match resp.json::<Vec<Value>>().await {
102                Ok(results) => {
103                    let places: Vec<Value> = results.iter().map(|r| json!({
104                        "lat": r["lat"].as_str().unwrap_or("0").parse::<f64>().unwrap_or(0.0),
105                        "lon": r["lon"].as_str().unwrap_or("0").parse::<f64>().unwrap_or(0.0),
106                        "display_name": r["display_name"],
107                        "type": r["type"],
108                        "importance": r["importance"],
109                        "address": r["address"]
110                    })).collect();
111                    json!({"query": input.query, "results": places.len(), "places": places}).to_string()
112                }
113                Err(e) => format!("Error: {e}"),
114            },
115            Err(e) => format!("Error: {e}"),
116        }
117    }
118
119    #[tool(description = "Reverse geocode coordinates to an address/place name")]
120    async fn reverse_geocode(&self, Parameters(input): Parameters<ReverseInput>) -> String {
121        let url = format!(
122            "https://nominatim.openstreetmap.org/reverse?lat={}&lon={}&format=json&addressdetails=1",
123            input.lat, input.lon
124        );
125        match self.client.get(&url).header("User-Agent", self.ua()).send().await {
126            Ok(resp) => match resp.json::<Value>().await {
127                Ok(data) => json!({
128                    "lat": input.lat, "lon": input.lon,
129                    "display_name": data["display_name"],
130                    "address": data["address"],
131                    "osm_type": data["osm_type"],
132                    "osm_id": data["osm_id"]
133                }).to_string(),
134                Err(e) => format!("Error: {e}"),
135            },
136            Err(e) => format!("Error: {e}"),
137        }
138    }
139
140    #[tool(description = "Calculate route between two points with distance, duration, and turn-by-turn steps")]
141    async fn get_route(&self, Parameters(input): Parameters<RouteInput>) -> String {
142        let mode = input.mode.as_deref().unwrap_or("driving");
143        let profile = match mode {
144            "walking" | "foot" => "foot",
145            "cycling" | "bike" => "bike",
146            _ => "car",
147        };
148        let url_str = format!(
149            "https://router.project-osrm.org/route/v1/{}/{},{};{},{}?overview=simplified&steps=true",
150            profile, input.origin_lon, input.origin_lat, input.dest_lon, input.dest_lat
151        );
152        let url = reqwest::Url::parse(&url_str).unwrap();
153        let request = self.client.get(url).header("User-Agent", self.ua());
154        match request.send().await {
155            Ok(resp) => match resp.json::<Value>().await {
156                Ok(data) => {
157                    if let Some(route) = data["routes"].as_array().and_then(|r| r.first()) {
158                        let steps: Vec<Value> = route["legs"][0]["steps"].as_array().unwrap_or(&vec![]).iter().map(|s| json!({
159                            "instruction": s["maneuver"]["type"],
160                            "modifier": s["maneuver"]["modifier"],
161                            "name": s["name"],
162                            "distance_m": s["distance"],
163                            "duration_s": s["duration"]
164                        })).collect();
165                        json!({
166                            "distance_km": route["distance"].as_f64().unwrap_or(0.0) / 1000.0,
167                            "duration_min": route["duration"].as_f64().unwrap_or(0.0) / 60.0,
168                            "mode": mode,
169                            "steps": steps.len(),
170                            "directions": steps
171                        }).to_string()
172                    } else {
173                        json!({"error": "No route found", "code": data["code"]}).to_string()
174                    }
175                }
176                Err(e) => format!("Error: {e}"),
177            },
178            Err(e) => format!("Error: {e}"),
179        }
180    }
181
182    #[tool(description = "Get elevation (altitude) for a coordinate point")]
183    async fn get_elevation(&self, Parameters(input): Parameters<ElevationInput>) -> String {
184        let url = format!("https://api.opentopodata.org/v1/srtm90m?locations={},{}", input.lat, input.lon);
185        match self.client.get(&url).send().await {
186            Ok(resp) => match resp.json::<Value>().await {
187                Ok(data) => {
188                    let elev = data["results"][0]["elevation"].as_f64().unwrap_or(0.0);
189                    json!({"lat": input.lat, "lon": input.lon, "elevation_m": elev, "dataset": "SRTM 90m"}).to_string()
190                }
191                Err(e) => format!("Error: {e}"),
192            },
193            Err(e) => format!("Error: {e}"),
194        }
195    }
196
197    #[tool(description = "Search for points of interest (POI) near a location. Types: hospital, restaurant, school, bank, pharmacy, fuel, hotel, supermarket, park, police, cafe, atm")]
198    async fn search_poi(&self, Parameters(input): Parameters<PoiInput>) -> String {
199        let radius = input.radius.unwrap_or(2000);
200        let limit = input.limit.unwrap_or(10);
201        let query = format!(
202            "[out:json][timeout:15];node[\"amenity\"=\"{}\"](around:{},{},{});out {};",
203            input.poi_type, radius, input.lat, input.lon, limit
204        );
205        let url = format!("https://overpass-api.de/api/interpreter?data={}", urlencoding::encode(&query));
206        match self.client.get(&url).header("User-Agent", self.ua()).send().await {
207            Ok(resp) => match resp.json::<Value>().await {
208                Ok(data) => {
209                    let pois: Vec<Value> = data["elements"].as_array().unwrap_or(&vec![]).iter().map(|e| {
210                        let tags = e.get("tags").cloned().unwrap_or(json!({}));
211                        json!({
212                            "name": tags["name"],
213                            "lat": e["lat"], "lon": e["lon"],
214                            "phone": tags["phone"],
215                            "website": tags["website"],
216                            "address": tags.get("addr:street").map(|s| format!("{} {}", s.as_str().unwrap_or(""), tags.get("addr:housenumber").and_then(|h| h.as_str()).unwrap_or("")))
217                        })
218                    }).collect();
219                    json!({"poi_type": input.poi_type, "radius_m": radius, "results": pois.len(), "pois": pois}).to_string()
220                }
221                Err(_) => json!({"poi_type": input.poi_type, "results": 0, "pois": [], "note": "Overpass API may be rate-limited. Retry in a few seconds."}).to_string(),
222            },
223            Err(e) => format!("Error: {e}"),
224        }
225    }
226
227    #[tool(description = "Calculate distance matrix between multiple points (driving/walking/cycling)")]
228    async fn distance_matrix(&self, Parameters(input): Parameters<DistanceInput>) -> String {
229        let mode = input.mode.as_deref().unwrap_or("driving");
230        let profile = match mode { "walking" | "foot" => "foot", "cycling" | "bike" => "bike", _ => "car" };
231        let coords: Vec<String> = input.points.iter().map(|p| format!("{},{}", p[1], p[0])).collect();
232        let url = format!(
233            "https://router.project-osrm.org/table/v1/{}/{}?annotations=distance,duration",
234            profile, coords.join(";")
235        );
236        match self.client.get(&url).send().await {
237            Ok(resp) => match resp.json::<Value>().await {
238                Ok(data) => json!({
239                    "mode": mode,
240                    "points": input.points.len(),
241                    "durations_seconds": data["durations"],
242                    "distances_meters": data["distances"]
243                }).to_string(),
244                Err(e) => format!("Error: {e}"),
245            },
246            Err(e) => format!("Error: {e}"),
247        }
248    }
249
250    #[tool(description = "Get timezone for a coordinate (uses Nominatim address data)")]
251    async fn get_timezone(&self, Parameters(input): Parameters<TimezoneInput>) -> String {
252        let url = format!(
253            "https://nominatim.openstreetmap.org/reverse?lat={}&lon={}&format=json&zoom=3",
254            input.lat, input.lon
255        );
256        match self.client.get(&url).header("User-Agent", self.ua()).send().await {
257            Ok(resp) => match resp.json::<Value>().await {
258                Ok(data) => json!({
259                    "lat": input.lat, "lon": input.lon,
260                    "country": data["address"]["country"],
261                    "country_code": data["address"]["country_code"],
262                    "display_name": data["display_name"]
263                }).to_string(),
264                Err(e) => format!("Error: {e}"),
265            },
266            Err(e) => format!("Error: {e}"),
267        }
268    }
269}