This commit is contained in:
Isaac Johnson 2026-02-02 17:21:56 -06:00
commit 6c5daf21f8
5 changed files with 491 additions and 0 deletions

191
.gitignore vendored Normal file
View File

@ -0,0 +1,191 @@
# Created by https://www.toptal.com/developers/gitignore/api/python,linux
# Edit at https://www.toptal.com/developers/gitignore?templates=python,linux
### Linux ###
*~
# temporary files which can be created if a process still has a handle open of a deleted file
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder which might appear on any partition or disk
.Trash-*
# .nfs files are created when an open file is removed but is still being accessed
.nfs*
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python,linux

BIN
BWCA_EP30_Plan.pptx Normal file

Binary file not shown.

223
azure_maps_mcp.py Executable file
View File

@ -0,0 +1,223 @@
#!/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()

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
mcp[cli]
httpx
pydantic

74
setup.md Normal file
View File

@ -0,0 +1,74 @@
# Azure Maps MCP Server Setup
This MCP server provides access to Azure Maps static imagery.
## Prerequisites
- Python 3.10 or higher
- An Azure Maps Subscription Key
## 1. Get an Azure Maps Subscription Key
1. Go to the [Azure Portal](https://portal.azure.com/).
2. Create an **Azure Maps Account** resource.
3. Once created, go to **Authentication** in the left menu.
4. Copy the **Primary Key** (or Secondary Key). This is your Subscription Key.
## 2. Installation
1. Clone or navigate to this repository.
2. Install the required Python packages:
```bash
pip install -r requirements.txt
# OR if using the provided pyproject.toml
pip install .
```
(Note: You may need to create a virtual environment first: `python -m venv venv && source venv/bin/activate`)
## 3. Configuration
Set your API key as an environment variable:
```bash
export AZURE_MAPS_SUBSCRIPTION_KEY="your_subscription_key_here"
```
## 4. Running the Server
You can run the server directly (stdio mode):
```bash
python azure_maps_mcp.py
```
## 5. Using with MCP Clients
### Claude Desktop
Add the following to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"azure-maps": {
"command": "python",
"args": ["/absolute/path/to/azure_maps_mcp.py"],
"env": {
"AZURE_MAPS_SUBSCRIPTION_KEY": "your_subscription_key_here"
}
}
}
}
```
### Inspecting with MCP Inspector
To test the server interactively:
```bash
npx @modelcontextprotocol/inspector python azure_maps_mcp.py
```
(Ensure you have `AZURE_MAPS_SUBSCRIPTION_KEY` set in your terminal session before running the inspector).