commit 6c5daf21f8cd3fc21cf692bb174678da4c64332e Author: Isaac Johnson Date: Mon Feb 2 17:21:56 2026 -0600 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..600a4e9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/BWCA_EP30_Plan.pptx b/BWCA_EP30_Plan.pptx new file mode 100644 index 0000000..f00a899 Binary files /dev/null and b/BWCA_EP30_Plan.pptx differ diff --git a/azure_maps_mcp.py b/azure_maps_mcp.py new file mode 100755 index 0000000..c878f01 --- /dev/null +++ b/azure_maps_mcp.py @@ -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|la|lc||'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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..16c98f8 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +mcp[cli] +httpx +pydantic diff --git a/setup.md b/setup.md new file mode 100644 index 0000000..8f187ff --- /dev/null +++ b/setup.md @@ -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). \ No newline at end of file