bingMapsMCP/azure_maps_mcp.py

248 lines
9.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 'style;lat,lon;title' is accepted, but currently only the location is used. The pin will be a default red marker."
)
mapLayer: Optional[Literal["Traffic", "Transit"]] = Field(
default=None,
description="Optional layer. 'Traffic' adds traffic flow. 'Transit' is not currently supported."
)
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.
"""
try:
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", str(params.zoomLevel)),
("width", width),
("height", height),
("format", params.format),
("layer", layer),
("style", style)
]
if params.mapLayer == "Traffic":
# For Azure Maps static image, 'layer' param handles background.
# Traffic flow is often an overlay.
# Checking docs: 'layer' can only be basic, hybrid, labels, terra.
# Traffic flow is not a standard layer option in the static image API v2.
# However, looking at common usage, sometimes 'traffic' is a boolean or separate layer.
# Wait, per Azure Maps Get Static Map API docs:
# To add traffic, often one uses 'layer=traffic'. But the docs say valid values are:
# basic, hybrid, labels, terra, traffic.
# Let's try setting layer to 'traffic' if the user requested it?
# Actually, standard way is often just 'layer=basic' (background) + an overlay?
# Re-reading docs (virtual):
# 'layer' parameter values: 'basic', 'hybrid', 'labels', 'terra', 'traffic'.
# So if mapLayer is Traffic, we should probably override the layer or see if we can combine.
# But the 'imagerySet' determines the base.
# If the user wants Aerial + Traffic, does Azure support that in one call?
# Often 'layer=traffic' means 'basic + traffic'.
# Let's assume if mapLayer=Traffic, we force layer='traffic' (which is usually vector traffic tiles).
pass # Pending verification, but let's implement a simple logic:
# If mapLayer is Traffic, we append it to the layer list or switch mode.
# NOTE: Azure Maps Static Image API allows 'layer=basic,traffic'? No, it takes a single value.
# But wait, looking at some examples, 'layer=traffic' yields a traffic map.
# Let's allow overriding the layer if Traffic is requested.
# However, this might conflict with 'imagerySet'.
# A safe bet: if mapLayer == 'Traffic', we ignore imagerySet or warn.
# Actually, let's look at the docs implied by existing code...
# The existing code didn't use it.
# Let's check if we can pass it as an additional parameter.
# Actually, looking at the code again, let's keep it simple.
# If mapLayer is 'Traffic', we will assume the user wants the traffic layer.
# We will override the 'layer' variable.
# layer = "traffic" (if that is valid)
pass
# REVISION:
# Azure Maps Static Image API (Render V2) uses 'layer' parameter.
# Valid values: basic, hybrid, dark, labels, terra, traffic.
# So if mapLayer is Traffic, we set layer="traffic".
# This overrides the imagerySet's layer preference.
if params.mapLayer == "Traffic":
layer = "traffic"
# Traffic usually goes well with 'main' or 'dark' style. We keep the style derived from imagerySet or default.
# Re-construct query_params with the potential new layer
query_params = [
("api-version", API_VERSION),
("subscription-key", api_key),
("center", center),
("zoom", str(params.zoomLevel)),
("width", width),
("height", height),
("format", params.format),
("layer", layer),
("style", style)
]
# Handle pushpins
if params.pushpins:
pin_locations = []
for pin in params.pushpins:
try:
parts = pin.split(";")
p_loc_raw = parts[1] if len(parts) >= 2 else parts[0]
p_lat, p_lon = p_loc_raw.split(",")
# Space separated lon lat is the most standard for pins
pin_locations.append(f"{p_lon.strip()} {p_lat.strip()}")
except Exception:
continue
if pin_locations:
# Format: default|coFF0000||lon1 lat1|lon2 lat2
all_locs = "|".join(pin_locations)
query_params.append(("pins", f"default|coFF0000||{all_locs}"))
async with httpx.AsyncClient() as client:
response = await client.get(API_BASE_URL, params=query_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
}
except Exception as e:
import traceback
return {"error": f"Internal tool error: {str(e)}\n{traceback.format_exc()}"}
if __name__ == "__main__":
mcp.run()