Files
2026-07-10 15:02:09 +01:00

820 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Google Maps MCP server (JSON-RPC 2.0 over stdio).
Capabilities (callable as `mcp__gmaps__<tool>`):
directions — transit/driving/walking directions from A to B
geocode — convert an address or place name to coordinates
reverse_geocode — convert coordinates to an address
search_places — find nearby places (stations, stops, POIs)
distance_matrix — travel time & distance between multiple origins/destinations
Auth:
API key is read from env var GOOGLE_MAPS_API_KEY, or from the file at
GOOGLE_MAPS_API_KEY_FILE (default: ./secrets/gmaps_api_key.txt).
Required Google Cloud APIs to enable:
- Directions API
- Geocoding API
- Places API (New) or Places API
- Distance Matrix API
Run with:
python3 scripts/gmaps_mcp_server.py
"""
from __future__ import annotations
import json
import os
import sys
from datetime import datetime, timezone
from typing import Any
# Log to stderr so stdout stays clean for JSON-RPC.
def log(msg: str) -> None:
print(f"[gmaps_mcp] {msg}", file=sys.stderr, flush=True)
# ── API key / client init ──────────────────────────────────────────────────────
_client = None
_init_error: str | None = None
def _get_api_key() -> str | None:
# 1. Environment variable
key = os.environ.get("GOOGLE_MAPS_API_KEY", "").strip()
if key:
return key
# 2. File
key_file = os.environ.get(
"GOOGLE_MAPS_API_KEY_FILE",
os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"secrets",
"gmaps_api_key.txt",
),
)
if os.path.exists(key_file):
with open(key_file) as f:
key = f.read().strip()
if key:
return key
return None
def _get_client():
global _client, _init_error
if _client is not None:
return _client
try:
import googlemaps # type: ignore
except ImportError as e:
_init_error = f"Missing dependency: {e}. Run: pip install googlemaps"
log(_init_error)
return None
api_key = _get_api_key()
if not api_key:
_init_error = (
"Google Maps API key not found. "
"Set GOOGLE_MAPS_API_KEY env var or create secrets/gmaps_api_key.txt "
"with just the key on the first line."
)
log(_init_error)
return None
try:
_client = googlemaps.Client(key=api_key)
log("Google Maps client initialised successfully.")
return _client
except Exception as e:
_init_error = f"Failed to build Maps client: {e}"
log(_init_error)
return None
def _format_gmaps_error(e: Exception, api_label: str) -> str:
"""Map a googlemaps exception into an actionable Error: string.
`api_label` is a human name for the failing API (e.g. "Directions", "Geocoding"),
used to point the user at the right Google Cloud Console switch.
"""
try:
from googlemaps import exceptions as gm_exc # type: ignore
except ImportError:
gm_exc = None # type: ignore
if gm_exc is not None and isinstance(e, gm_exc.ApiError):
status = getattr(e, "status", "") or ""
message = (getattr(e, "message", "") or "").strip()
if status == "OVER_QUERY_LIMIT":
return (
f"Error: {api_label} API quota exceeded (OVER_QUERY_LIMIT). "
"Check usage and billing in the Google Cloud Console."
)
if status == "REQUEST_DENIED":
return (
f"Error: {api_label} API request denied (REQUEST_DENIED). "
"Verify that the API key in secrets/gmaps_api_key.txt is valid and that "
f"the {api_label} API is enabled in the Google Cloud Console."
)
if status == "INVALID_REQUEST":
return (
f"Error: {api_label} API rejected the request as invalid (INVALID_REQUEST). "
"Check that the addresses, coordinates, and parameters are well-formed."
)
if status == "MAX_ELEMENTS_EXCEEDED":
return (
f"Error: {api_label} API returned MAX_ELEMENTS_EXCEEDED — too many "
"origins×destinations at once. Reduce the input size and retry."
)
if status == "NOT_FOUND":
return (
f"Error: {api_label} API could not geocode one of the supplied places. "
"Use more specific place names or coordinates."
)
return f"Error: {api_label} API error ({status}): {message}"
if gm_exc is not None and isinstance(e, gm_exc.HTTPError):
status = getattr(e, "status", "") or ""
return f"Error: {api_label} API returned HTTP error {status}."
if gm_exc is not None and isinstance(e, gm_exc.Timeout):
return f"Error: {api_label} API request timed out. Retry in a moment."
return f"Error: {api_label} API call failed: {e}"
# ── Tool implementations ───────────────────────────────────────────────────────
def _maps_status(args: dict) -> str:
"""Self-check: confirm the API key is present, valid, and the network works.
Performs one cheap geocode ("Rome, IT") so we exercise key validation, the
Geocoding API, and the network in a single round-trip. Returns a plain-text
report the LLM can use to decide what to tell the user.
"""
# Step 1: API key present?
api_key = _get_api_key()
if not api_key:
return (
"Error: Google Maps API key not found. "
"Set GOOGLE_MAPS_API_KEY env var or create secrets/gmaps_api_key.txt "
"with the key on the first line. No Google Maps tool will work until this is fixed."
)
# Step 2: dependency present + client built?
gmaps = _get_client()
if gmaps is None:
return f"Error: {_init_error}"
# Step 3: live call. One geocode is the cheapest "is the key valid?" probe.
try:
result = gmaps.geocode("Rome, IT")
except Exception as e:
return _format_gmaps_error(e, "Geocoding")
if not result:
return (
"Error: Geocoding API returned no result for the probe query. "
"The API key may be restricted or the Geocoding API may be disabled."
)
return (
"OK: Google Maps client is ready. API key is present and the Geocoding API responds.\n"
"All tools (directions, geocode, reverse_geocode, search_places, distance_matrix) are operational."
)
def _maps_directions(args: dict) -> str:
"""Get directions from origin to destination."""
gmaps = _get_client()
if gmaps is None:
return f"Error: {_init_error}"
origin = args.get("origin")
destination = args.get("destination")
if not origin or not destination:
return "Error: Missing required parameters 'origin' and/or 'destination'."
mode = args.get("mode", "transit").lower()
valid_modes = {"driving", "walking", "bicycling", "transit"}
if mode not in valid_modes:
return f"Error: 'mode' must be one of: {', '.join(sorted(valid_modes))}."
# Optional departure time: literal "now" or an ISO 8601 datetime string.
# Integers (Unix timestamps) are rejected explicitly — the schema documents
# strings only and silently coercing ints would teach the LLM the wrong call.
departure_raw = args.get("departure_time", "now")
if isinstance(departure_raw, bool):
return "Error: 'departure_time' must be 'now' or an ISO 8601 string (e.g. '2025-06-15T08:30:00+02:00'). Never pass a boolean."
if isinstance(departure_raw, (int, float)):
return "Error: 'departure_time' must be 'now' or an ISO 8601 string (e.g. '2025-06-15T08:30:00+02:00'). Never pass a Unix timestamp integer."
if departure_raw == "now":
departure_time = datetime.now(timezone.utc)
else:
try:
departure_time = datetime.fromisoformat(str(departure_raw).replace("Z", "+00:00"))
except ValueError:
return (
"Error: 'departure_time' must be the literal 'now' or an ISO 8601 datetime "
f"string with timezone offset (e.g. '2025-06-15T08:30:00+02:00'). Got: {departure_raw!r}."
)
# Transit preferences
transit_mode = args.get("transit_mode") # e.g. "bus", "rail", "subway", "train", "tram"
transit_routing_preference = args.get("transit_routing_preference") # "less_walking", "fewer_transfers"
language = args.get("language", "it")
alternatives = args.get("alternatives", False)
kwargs: dict[str, Any] = {
"origin": origin,
"destination": destination,
"mode": mode,
"language": language,
"alternatives": alternatives,
}
if mode == "transit":
kwargs["departure_time"] = departure_time
if transit_mode:
kwargs["transit_mode"] = transit_mode if isinstance(transit_mode, list) else [transit_mode]
if transit_routing_preference:
kwargs["transit_routing_preference"] = transit_routing_preference
try:
result = gmaps.directions(**kwargs)
except Exception as e:
return _format_gmaps_error(e, "Directions")
if not result:
return f"No routes found from '{origin}' to '{destination}'."
lines = []
for route_idx, route in enumerate(result):
if alternatives and len(result) > 1:
lines.append(f"\n── Route {route_idx + 1} of {len(result)} ──")
summary = route.get("summary", "")
if summary:
lines.append(f"Via: {summary}")
legs = route.get("legs", [])
for leg in legs:
duration = leg.get("duration", {}).get("text", "?")
distance = leg.get("distance", {}).get("text", "?")
dep_addr = leg.get("start_address", origin)
arr_addr = leg.get("end_address", destination)
dep_time = leg.get("departure_time", {}).get("text", "")
arr_time = leg.get("arrival_time", {}).get("text", "")
lines.append(f"From: {dep_addr}")
lines.append(f"To: {arr_addr}")
lines.append(f"Duration: {duration} | Distance: {distance}")
if dep_time:
lines.append(f"Departure: {dep_time} → Arrival: {arr_time}")
lines.append("\nSteps:")
for step in leg.get("steps", []):
instr = step.get("html_instructions", "")
# Strip basic HTML tags for clean text output
import re
instr = re.sub(r"<[^>]+>", " ", instr).strip()
instr = re.sub(r"\s+", " ", instr)
step_dur = step.get("duration", {}).get("text", "")
step_dist = step.get("distance", {}).get("text", "")
travel_mode = step.get("travel_mode", "")
prefix = ""
if travel_mode == "TRANSIT":
td = step.get("transit_details", {})
line_info = td.get("line", {})
vehicle = line_info.get("vehicle", {}).get("name", "")
line_name = line_info.get("short_name") or line_info.get("name", "")
dep_stop = td.get("departure_stop", {}).get("name", "")
arr_stop = td.get("arrival_stop", {}).get("name", "")
dep_t = td.get("departure_time", {}).get("text", "")
arr_t = td.get("arrival_time", {}).get("text", "")
num_stops = td.get("num_stops", "")
headsign = td.get("headsign", "")
prefix = (
f" 🚌 {vehicle} {line_name}"
+ (f" → {headsign}" if headsign else "")
+ f"\n From: {dep_stop} ({dep_t})"
+ f"\n To: {arr_stop} ({arr_t})"
+ (f" [{num_stops} stops]" if num_stops else "")
)
else:
emoji = {"WALKING": "🚶", "DRIVING": "🚗", "BICYCLING": "🚲"}.get(travel_mode, "•")
prefix = f" {emoji} {instr}"
if step_dur or step_dist:
prefix += f" ({step_dur}, {step_dist})"
lines.append(prefix)
return "\n".join(lines)
def _maps_geocode(args: dict) -> str:
"""Convert an address or place name to coordinates."""
gmaps = _get_client()
if gmaps is None:
return f"Error: {_init_error}"
address = args.get("address")
if not address:
return "Error: Missing required parameter 'address'."
language = args.get("language", "it")
region = args.get("region", "it") # country bias
try:
result = gmaps.geocode(address, language=language, region=region)
except Exception as e:
return _format_gmaps_error(e, "Geocoding")
if not result:
return f"No results found for '{address}'."
lines = []
for i, place in enumerate(result[:5]):
formatted = place.get("formatted_address", "?")
loc = place.get("geometry", {}).get("location", {})
lat = loc.get("lat", "?")
lng = loc.get("lng", "?")
place_id = place.get("place_id", "")
types = ", ".join(place.get("types", []))
lines.append(f"{i+1}. {formatted}")
lines.append(f" Coordinates: {lat}, {lng}")
if place_id:
lines.append(f" Place ID: {place_id}")
if types:
lines.append(f" Types: {types}")
return "\n".join(lines)
def _maps_reverse_geocode(args: dict) -> str:
"""Convert coordinates to an address."""
gmaps = _get_client()
if gmaps is None:
return f"Error: {_init_error}"
lat = args.get("lat")
lng = args.get("lng")
if lat is None or lng is None:
return "Error: Missing required parameters 'lat' and/or 'lng'."
language = args.get("language", "it")
try:
result = gmaps.reverse_geocode((float(lat), float(lng)), language=language)
except Exception as e:
return _format_gmaps_error(e, "Geocoding")
if not result:
return f"No address found for coordinates ({lat}, {lng})."
place = result[0]
return place.get("formatted_address", "?")
def _maps_search_places(args: dict) -> str:
"""Search for places near a location."""
gmaps = _get_client()
if gmaps is None:
return f"Error: {_init_error}"
query = args.get("query")
location = args.get("location") # "lat,lng" string or address
radius = args.get("radius", 1000)
language = args.get("language", "it")
place_type = args.get("type") # e.g. "train_station", "bus_station", "subway_station"
if not query and not location:
return "Error: Provide at least 'query' or 'location'."
# Resolve location string to lat/lng if needed
loc_tuple = None
if location:
if "," in str(location):
parts = str(location).split(",")
try:
loc_tuple = (float(parts[0].strip()), float(parts[1].strip()))
except ValueError:
pass
if loc_tuple is None:
# Geocode the location string
geo = gmaps.geocode(location, language=language)
if geo:
latlng = geo[0].get("geometry", {}).get("location", {})
loc_tuple = (latlng["lat"], latlng["lng"])
kwargs: dict[str, Any] = {"language": language}
if query:
kwargs["query"] = query
if loc_tuple:
kwargs["location"] = loc_tuple
kwargs["radius"] = int(radius)
if place_type:
kwargs["type"] = place_type
try:
if query:
result = gmaps.places(**kwargs)
else:
result = gmaps.places_nearby(**kwargs)
except Exception as e:
return _format_gmaps_error(e, "Places")
places = result.get("results", [])
if not places:
return "No places found."
lines = [f"Found {len(places)} place(s):"]
for p in places[:10]:
name = p.get("name", "?")
addr = p.get("vicinity") or p.get("formatted_address", "")
rating = p.get("rating")
place_id = p.get("place_id", "")
types = ", ".join(p.get("types", [])[:3])
loc = p.get("geometry", {}).get("location", {})
lat_p = loc.get("lat", "")
lng_p = loc.get("lng", "")
line = f"• {name}"
if addr:
line += f"\n Address: {addr}"
if lat_p and lng_p:
line += f"\n Coords: {lat_p}, {lng_p}"
if rating:
line += f"\n Rating: {rating}/5"
if types:
line += f"\n Types: {types}"
if place_id:
line += f"\n Place ID: {place_id}"
lines.append(line)
return "\n".join(lines)
def _maps_distance_matrix(args: dict) -> str:
"""Get travel time/distance between origins and destinations."""
gmaps = _get_client()
if gmaps is None:
return f"Error: {_init_error}"
origins = args.get("origins")
destinations = args.get("destinations")
if not origins or not destinations:
return "Error: Missing required parameters 'origins' and/or 'destinations'."
if isinstance(origins, str):
origins = [origins]
if isinstance(destinations, str):
destinations = [destinations]
mode = args.get("mode", "transit")
language = args.get("language", "it")
kwargs: dict[str, Any] = {
"origins": origins,
"destinations": destinations,
"mode": mode,
"language": language,
}
if mode == "transit":
kwargs["departure_time"] = datetime.now(timezone.utc)
try:
result = gmaps.distance_matrix(**kwargs)
except Exception as e:
return _format_gmaps_error(e, "Distance Matrix")
rows = result.get("rows", [])
dest_addrs = result.get("destination_addresses", destinations)
orig_addrs = result.get("origin_addresses", origins)
lines = []
for i, (row, orig) in enumerate(zip(rows, orig_addrs)):
for j, (elem, dest) in enumerate(zip(row.get("elements", []), dest_addrs)):
status = elem.get("status", "")
if status == "OK":
dur = elem.get("duration", {}).get("text", "?")
dist = elem.get("distance", {}).get("text", "?")
lines.append(f"{orig}{dest}")
lines.append(f" Duration: {dur} | Distance: {dist}")
else:
lines.append(f"{orig}{dest} [{status}]")
return "\n".join(lines) if lines else "No results."
# ── Tool manifest ──────────────────────────────────────────────────────────────
TOOLS = [
{
"name": "status",
"description": (
"Self-check that the Google Maps integration is operational: verifies the API key is "
"present and valid, the Geocoding API is enabled, and the network works, by performing "
"one cheap geocode probe. Call this first whenever another Maps tool fails, or to give "
"the user a quick yes/no on whether Maps is usable right now."
),
"inputSchema": {"type": "object", "properties": {}},
},
{
"name": "directions",
"description": (
"Get step-by-step directions from an origin to a destination. "
"Supports transit (bus, train, metro), driving, walking, bicycling. "
"For transit, returns detailed stop-by-stop info with departure/arrival times. "
"Best for 'how do I get from A to B?' or 'which train do I take to go home?'."
),
"inputSchema": {
"type": "object",
"properties": {
"origin": {
"type": "string",
"description": (
"Starting address or place name (e.g. 'Milano Centrale') "
"or coordinates as 'latitude,longitude' decimal string "
"with no spaces (e.g. '45.4654,9.1866')."
),
},
"destination": {
"type": "string",
"description": (
"Destination address or place name "
"or coordinates as 'latitude,longitude' decimal string "
"with no spaces (e.g. '45.4654,9.1866')."
),
},
"mode": {
"type": "string",
"enum": ["transit", "driving", "walking", "bicycling"],
"description": "Travel mode. Default 'transit'.",
},
"departure_time": {
"type": "string",
"description": (
"When to depart. Must be the literal string 'now' (default) "
"or an ISO 8601 datetime string with timezone offset, "
"e.g. '2025-06-15T08:30:00+02:00'. "
"Never pass a Unix timestamp integer — always use a string."
),
},
"transit_mode": {
"type": "string",
"enum": ["bus", "rail", "subway", "train", "tram"],
"description": (
"Restrict results to a specific transit vehicle type. "
"Omit to allow any vehicle. Use 'train' for intercity/regional rail, "
"'subway' for metro, 'tram' for tram lines, 'bus' for buses, "
"'rail' for any rail (train + subway + tram)."
),
},
"transit_routing_preference": {
"type": "string",
"enum": ["less_walking", "fewer_transfers"],
"description": "Optimize transit route for fewer transfers or less walking.",
},
"alternatives": {
"type": "boolean",
"description": "Return multiple route options. Default false.",
},
"language": {
"type": "string",
"description": "Language for instructions. Default 'it'.",
},
},
"required": ["origin", "destination"],
},
},
{
"name": "geocode",
"description": "Convert a place name or address into geographic coordinates (latitude, longitude) and a place_id.",
"inputSchema": {
"type": "object",
"properties": {
"address": {
"type": "string",
"description": "Address or place name to geocode.",
},
"language": {
"type": "string",
"description": "Language for results. Default 'it'.",
},
"region": {
"type": "string",
"description": "Country code bias (e.g. 'it', 'gb'). Default 'it'.",
},
},
"required": ["address"],
},
},
{
"name": "reverse_geocode",
"description": "Convert geographic coordinates (lat, lng) into a human-readable address.",
"inputSchema": {
"type": "object",
"properties": {
"lat": {"type": "number", "description": "Latitude."},
"lng": {"type": "number", "description": "Longitude."},
"language": {
"type": "string",
"description": "Language for results. Default 'it'.",
},
},
"required": ["lat", "lng"],
},
},
{
"name": "search_places",
"description": (
"Search for places near a location. "
"Useful for finding train stations, bus stops, restaurants, etc. "
"near an address or coordinates. "
"At least one of 'query' or 'location' must be provided."
),
"inputSchema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": (
"Text search query, e.g. 'stazione ferroviaria', 'bar', 'farmacia'. "
"Required unless 'location' is provided."
),
},
"location": {
"type": "string",
"description": (
"Center of the search area: address, place name, "
"or 'latitude,longitude' decimal string with no spaces "
"(e.g. '45.4654,9.1866'). Required unless 'query' is provided."
),
},
"radius": {
"type": "integer",
"description": "Search radius in meters. Default 1000.",
},
"type": {
"type": "string",
"description": (
"Filter by place type. Examples: 'train_station', 'bus_station', "
"'subway_station', 'transit_station', 'restaurant'."
),
},
"language": {
"type": "string",
"description": "Language for results. Default 'it'.",
},
},
},
},
{
"name": "distance_matrix",
"description": (
"Calculate travel times and distances between multiple origins and destinations. "
"Useful for comparing routes or checking ETAs."
),
"inputSchema": {
"type": "object",
"properties": {
"origins": {
"type": ["string", "array"],
"items": {"type": "string"},
"description": (
"One or more origins: address, place name, or 'latitude,longitude' "
"decimal string with no spaces. Pass a single string or a JSON array "
"of strings for multiple origins."
),
},
"destinations": {
"type": ["string", "array"],
"items": {"type": "string"},
"description": (
"One or more destinations: address, place name, or 'latitude,longitude' "
"decimal string with no spaces. Pass a single string or a JSON array "
"of strings for multiple destinations."
),
},
"mode": {
"type": "string",
"enum": ["transit", "driving", "walking", "bicycling"],
"description": "Travel mode. Default 'transit'.",
},
"language": {
"type": "string",
"description": "Language for results. Default 'it'.",
},
},
"required": ["origins", "destinations"],
},
},
]
# ── JSON-RPC dispatch ──────────────────────────────────────────────────────────
TOOL_DISPATCH = {
"status": _maps_status,
"directions": _maps_directions,
"geocode": _maps_geocode,
"reverse_geocode": _maps_reverse_geocode,
"search_places": _maps_search_places,
"distance_matrix": _maps_distance_matrix,
}
def _ok(req_id: Any, result: Any) -> str:
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
def _text_result(req_id: Any, text: str, is_error: bool = False) -> str:
payload: dict = {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": text}]},
}
if is_error:
payload["result"]["isError"] = True
return json.dumps(payload)
def handle_request(msg: dict) -> str | None:
method = msg.get("method", "")
req_id = msg.get("id")
if method == "initialize":
return _ok(req_id, {
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {
"name": "gmaps",
"version": "1.1.0",
},
})
if method == "notifications/initialized":
return None
if method == "tools/list":
return _ok(req_id, {"tools": TOOLS})
if method == "tools/call":
params = msg.get("params", {})
tool_name = params.get("name", "")
tool_args = params.get("arguments", {})
handler = TOOL_DISPATCH.get(tool_name)
if handler is None:
return _text_result(req_id, f"Error: Unknown tool: {tool_name}", is_error=True)
try:
text = handler(tool_args)
is_err = text.startswith("Error:")
return _text_result(req_id, text, is_error=is_err)
except Exception as e:
log(f"Unhandled exception in tool '{tool_name}': {e}")
return _text_result(req_id, f"Error: Internal error in tool '{tool_name}': {e}", is_error=True)
return json.dumps({
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
})
# ── Main loop ──────────────────────────────────────────────────────────────────
def main() -> None:
log("Starting Google Maps MCP server")
# Eagerly initialise the client so errors surface immediately.
_get_client()
try:
for line in sys.stdin:
line = line.strip()
if not line:
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
log(f"Invalid JSON input: {e}")
continue
resp = handle_request(msg)
if resp is not None:
sys.stdout.write(resp + "\n")
sys.stdout.flush()
except KeyboardInterrupt:
pass
if __name__ == "__main__":
main()