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 pub query: String,
9 pub limit: Option<u32>,
11}
12
13#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
14pub struct ReverseInput {
15 pub lat: f64,
17 pub lon: f64,
19}
20
21#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
22pub struct RouteInput {
23 pub origin_lat: f64,
25 pub origin_lon: f64,
27 pub dest_lat: f64,
29 pub dest_lon: f64,
31 pub mode: Option<String>,
33}
34
35#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
36pub struct ElevationInput {
37 pub lat: f64,
39 pub lon: f64,
41}
42
43#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
44pub struct PoiInput {
45 pub lat: f64,
47 pub lon: f64,
49 pub poi_type: String,
51 pub radius: Option<u32>,
53 pub limit: Option<u32>,
55}
56
57#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
58pub struct DistanceInput {
59 pub points: Vec<[f64; 2]>,
61 pub mode: Option<String>,
63}
64
65#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
66pub struct TimezoneInput {
67 pub lat: f64,
69 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}