first
This commit is contained in:
commit
6c5daf21f8
|
|
@ -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
|
||||
Binary file not shown.
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
mcp[cli]
|
||||
httpx
|
||||
pydantic
|
||||
|
|
@ -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).
|
||||
Loading…
Reference in New Issue