224 lines
7.7 KiB
Python
Executable File
224 lines
7.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
MCP Server for Azure Maps.
|
|
|
|
This server provides tools to interact with the Azure Maps REST Services,
|
|
specifically for retrieving static maps.
|
|
"""
|
|
|
|
import os
|
|
import httpx
|
|
import base64
|
|
from typing import Optional, List, Literal
|
|
from pydantic import BaseModel, Field, ConfigDict
|
|
from mcp.server.fastmcp import FastMCP, Context
|
|
|
|
# Initialize the MCP server
|
|
mcp = FastMCP("azure_maps_mcp")
|
|
|
|
# Constants
|
|
API_BASE_URL = "https://atlas.microsoft.com/map/static"
|
|
API_VERSION = "2024-04-01"
|
|
DEFAULT_MAP_SIZE = "500,500"
|
|
DEFAULT_FORMAT = "png"
|
|
|
|
# Imagery Sets Mapping
|
|
# Mapping Bing styles to Azure (layer, style) tuples or just layer
|
|
# Azure layers: basic, hybrid, labels, terra
|
|
# Azure styles: main, dark
|
|
IMAGERY_MAP = {
|
|
"Aerial": ("hybrid", "main"), # detailed satellite/hybrid
|
|
"AerialWithLabels": ("hybrid", "main"),
|
|
"AerialWithLabelsOnDemand": ("hybrid", "main"),
|
|
"Birdseye": ("hybrid", "main"), # Best approximation
|
|
"BirdseyeWithLabels": ("hybrid", "main"),
|
|
"BirdseyeV2": ("hybrid", "main"),
|
|
"BirdseyeV2WithLabels": ("hybrid", "main"),
|
|
"CanvasDark": ("basic", "dark"),
|
|
"CanvasLight": ("basic", "main"), # lighter main
|
|
"CanvasGray": ("basic", "main"), # Best approximation
|
|
"Road": ("basic", "main"),
|
|
"RoadOnDemand": ("basic", "main")
|
|
}
|
|
|
|
ImagerySet = Literal[
|
|
"Aerial",
|
|
"AerialWithLabels",
|
|
"AerialWithLabelsOnDemand",
|
|
"Birdseye",
|
|
"BirdseyeWithLabels",
|
|
"BirdseyeV2",
|
|
"BirdseyeV2WithLabels",
|
|
"CanvasDark",
|
|
"CanvasLight",
|
|
"CanvasGray",
|
|
"Road",
|
|
"RoadOnDemand"
|
|
]
|
|
|
|
class GetStaticMapInput(BaseModel):
|
|
"""Input parameters for getting a static map."""
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
centerPoint: str = Field(
|
|
...,
|
|
description="Center point of the map as 'latitude,longitude' (e.g., '47.610,-122.107')."
|
|
)
|
|
zoomLevel: int = Field(
|
|
...,
|
|
description="Zoom level of the map (0-20).",
|
|
ge=0,
|
|
le=20
|
|
)
|
|
imagerySet: ImagerySet = Field(
|
|
default="Aerial",
|
|
description="The type of imagery to display (mapped to Azure Maps layers)."
|
|
)
|
|
mapSize: str = Field(
|
|
default=DEFAULT_MAP_SIZE,
|
|
description="Width and height of the map in pixels, formatted as 'width,height' (e.g., '500,500')."
|
|
)
|
|
format: Literal["png", "jpg", "jpeg"] = Field(
|
|
default=DEFAULT_FORMAT,
|
|
description="Image format of the returned map."
|
|
)
|
|
pushpins: Optional[List[str]] = Field(
|
|
default=None,
|
|
description="List of pushpins. Format from Bing 'style;location;title' will be converted. Location must be 'lat,lon'."
|
|
)
|
|
mapLayer: Optional[Literal["Traffic", "Transit"]] = Field(
|
|
default=None,
|
|
description="Optional layer. 'Traffic' adds traffic flow. 'Transit' is not directly supported in static image."
|
|
)
|
|
|
|
async def _get_api_key(ctx: Context) -> str:
|
|
"""Retrieves the API key from environment or context."""
|
|
api_key = os.environ.get("AZURE_MAPS_SUBSCRIPTION_KEY")
|
|
if not api_key:
|
|
# Try to elicit from user if supported by client, otherwise fail
|
|
try:
|
|
api_key = await ctx.elicit("Please provide your Azure Maps Subscription Key:", input_type="password")
|
|
except Exception:
|
|
pass
|
|
|
|
if not api_key:
|
|
raise ValueError("AZURE_MAPS_SUBSCRIPTION_KEY environment variable is not set.")
|
|
return api_key
|
|
|
|
@mcp.tool()
|
|
async def get_static_map(params: GetStaticMapInput, ctx: Context) -> dict:
|
|
"""
|
|
Retrieves a static map image from Azure Maps.
|
|
|
|
Returns a base64 encoded string of the image, wrapped in a JSON object
|
|
describing the image.
|
|
"""
|
|
api_key = await _get_api_key(ctx)
|
|
|
|
# Parse center point (Lat,Lon -> Lon,Lat)
|
|
try:
|
|
lat, lon = params.centerPoint.split(",")
|
|
center = f"{lon.strip()},{lat.strip()}"
|
|
except ValueError:
|
|
return {"error": "centerPoint must be 'latitude,longitude'"}
|
|
|
|
# Parse map size
|
|
try:
|
|
width, height = params.mapSize.split(",")
|
|
except ValueError:
|
|
width, height = "500", "500"
|
|
|
|
# Map imagery set
|
|
layer, style = IMAGERY_MAP.get(params.imagerySet, ("basic", "main"))
|
|
|
|
query_params = {
|
|
"api-version": API_VERSION,
|
|
"subscription-key": api_key,
|
|
"center": center,
|
|
"zoom": params.zoomLevel,
|
|
"width": width,
|
|
"height": height,
|
|
"format": params.format,
|
|
"layer": layer,
|
|
"style": style
|
|
}
|
|
|
|
# Handle Traffic (Azure Maps Static Image doesn't have simple 'Traffic' layer param usually,
|
|
# but let's check if we can append it to layer or use traffic flow tiles.
|
|
# Standard static API: layer=basic, hybrid, labels.
|
|
# To keep it simple and safe, we will ignore Transit.
|
|
# If Traffic is requested, we might not be able to fulfill it easily in one request without 'traffic' layer support
|
|
# which varies by API version. We'll omit for now to ensure stability or use 'hybrid' which might show some?)
|
|
# Actually, some docs mention 'traffic' param boolean? No.
|
|
# We will log or ignore for now as it's a migration.
|
|
|
|
# Handle pushpins
|
|
# Bing: style;location;title -> Azure: pins=default||'title'|lon lat
|
|
if params.pushpins:
|
|
# Azure allows multiple 'pins' parameters
|
|
# Format: style|label|lon lat (Simplified)
|
|
# Reference: pins=default||'Label'|12.34 56.78
|
|
|
|
# We need to construct the 'pins' param string.
|
|
# Since httpx can take a list of tuples for multiple params with same name.
|
|
|
|
# Let's prepare the list of query params
|
|
api_params = list(query_params.items())
|
|
|
|
for pin in params.pushpins:
|
|
try:
|
|
parts = pin.split(";")
|
|
# Expected: style;lat,lon;title or style;lat,lon
|
|
p_style = "default" # We map everything to default for now
|
|
p_loc = ""
|
|
p_title = ""
|
|
|
|
if len(parts) >= 2:
|
|
p_loc_raw = parts[1]
|
|
if len(parts) >= 3:
|
|
p_title = parts[2]
|
|
else:
|
|
continue # Invalid format
|
|
|
|
# Parse lat,lon -> lon lat
|
|
p_lat, p_lon = p_loc_raw.split(",")
|
|
azure_loc = f"{p_lon.strip()} {p_lat.strip()}"
|
|
|
|
# Construct pin param
|
|
# default|co<color>|la<label_anchor>|lc<label_color>||'Label'|lon lat
|
|
# Simple: default||'Label'|lon lat
|
|
|
|
pin_str = f"default||'{p_title}'|{azure_loc}" if p_title else f"default|||{azure_loc}"
|
|
api_params.append(("pins", pin_str))
|
|
|
|
except Exception:
|
|
continue # Skip malformed pins
|
|
else:
|
|
api_params = list(query_params.items())
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
response = await client.get(API_BASE_URL, params=api_params)
|
|
|
|
if response.status_code != 200:
|
|
try:
|
|
error_data = response.json()
|
|
error_msg = error_data.get("error", {}).get("message", response.text)
|
|
return {"error": f"{response.status_code} - {error_msg}"}
|
|
except:
|
|
return {"error": f"{response.status_code} - {response.text}"}
|
|
|
|
# Success - return base64 image
|
|
image_data = response.content
|
|
base64_img = base64.b64encode(image_data).decode('utf-8')
|
|
|
|
return {
|
|
"format": params.format,
|
|
"width": width,
|
|
"height": height,
|
|
"description": f"Static map of {params.centerPoint} at zoom {params.zoomLevel}",
|
|
"image_data_base64": base64_img
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
mcp.run()
|