# Directory Structure ``` ├── .cursorrules ├── .gitignore ├── map_demo.py ├── mcp_osm │ ├── __init__.py │ ├── __main__.py │ ├── flask_server.py │ └── server.py ├── mcp.py ├── osm-mcp-s.webp ├── osm-mcp.webp ├── pyproject.toml ├── README.md ├── run.py ├── setup.py ├── templates │ └── index.html └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Python-generated files 2 | __pycache__/ 3 | *.py[oc] 4 | build/ 5 | dist/ 6 | wheels/ 7 | *.egg-info 8 | 9 | # Virtual environments 10 | .venv 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | 140 | # VS Code 141 | .vscode/ 142 | 143 | # PyCharm 144 | .idea/ 145 | ``` -------------------------------------------------------------------------------- /.cursorrules: -------------------------------------------------------------------------------- ``` 1 | 2 | # New Rules to Address Overzealous Agentic Functions 3 | 4 | ## Pacing and Scope Control 5 | 1. **Explicit Checkpoint Requirements** 6 | - You must pause after completing each logical unit of work and wait for explicit approval before continuing. 7 | - Never implement more than one task in a single session without confirmation. 8 | 9 | 2. **Minimalist Implementation Rule** 10 | - Always implement the absolute minimum to meet the specified task requirements. 11 | - When in doubt about scope, choose the narrower interpretation. 12 | 13 | 3. **Staged Development Protocol** 14 | - Follow a strict 'propose → approve → implement → review' cycle for every change. 15 | - After implementing each component, stop and provide a clear summary of what was changed and what remains to be done. 16 | 17 | 4. **Scope Boundary Enforcement** 18 | - If a task appears to require changes outside the initially identified files or components, pause and request explicit permission. 19 | - Never perform 'while I'm at it' improvements without prior approval. 20 | 21 | ## Communications 22 | 1. **Mandatory Checkpoints** 23 | - After every change, pause and summarize what you've done and what you're planning next. 24 | - Mark each implemented feature as [COMPLETE] and ask if you should continue to the next item. 25 | 26 | 2. **Complexity Warning System** 27 | - If implementation requires touching more than 3 files, flag this as [COMPLEX CHANGE] and wait for confirmation. 28 | - Proactively identify potential ripple effects before implementing any change. 29 | 30 | 3. **Change Magnitude Indicators** 31 | - Classify all proposed changes as [MINOR] (1-5 lines), [MODERATE] (5-20 lines), or [MAJOR] (20+ lines). 32 | - For [MAJOR] changes, provide a detailed implementation plan and wait for explicit approval. 33 | 34 | 4. **Testability Focus** 35 | - Every implementation must pause at the earliest point where testing is possible. 36 | - Never proceed past a testable checkpoint without confirmation that the current implementation works. ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # MCP-OSM: OpenStreetMap Integration for MCP 2 | 3 | This package provides OpenStreetMap integration for MCP, allowing users to query 4 | and visualize map data through an MCP interface. 5 | 6 | [](osm-mcp.webp) 7 | 8 | ## Features 9 | 10 | - Web-based map viewer using Leaflet and OpenStreetMap 11 | - Server-to-client communication via Server-Sent Events (SSE) 12 | - MCP tools for map control (adding markers, polygons, setting view, getting view) 13 | - PostgreSQL/PostGIS query interface for OpenStreetMap data 14 | 15 | ## Installation 16 | 17 | This is my `claude_desktop_config.json`: 18 | ```json 19 | { 20 | "mcpServers": { 21 | "OSM PostgreSQL Server": { 22 | "command": "/Users/wiseman/.local/bin/uv", 23 | "args": [ 24 | "run", 25 | "--env-file", 26 | ".env", 27 | "--with", 28 | "mcp[cli]", 29 | "--with", 30 | "psycopg2", 31 | "--with-editable", 32 | "/Users/wiseman/src/mcp-osm", 33 | "--directory", 34 | "/Users/wiseman/src/mcp-osm", 35 | "mcp", 36 | "run", 37 | "mcp.py" 38 | ] 39 | } 40 | } 41 | } 42 | ``` 43 | 44 | When the MCP server starts it also starts a web server at http://localhost:8889/ 45 | that has the map interface. 46 | 47 | ### Environment Variables 48 | 49 | The following environment variables can be used to configure the MCP: 50 | 51 | - `FLASK_HOST` - Host for the Flask server (default: 127.0.0.1) 52 | - `FLASK_PORT` - Port for the Flask server (default: 8889) 53 | - `PGHOST` - PostgreSQL host (default: localhost) 54 | - `PGPORT` - PostgreSQL port (default: 5432) 55 | - `PGDB` - PostgreSQL database name (default: osm) 56 | - `PGUSER` - PostgreSQL username (default: postgres) 57 | - `PGPASSWORD` - PostgreSQL password (default: postgres) 58 | 59 | ### MCP Tools 60 | 61 | The following MCP tools are available: 62 | 63 | - `get_map_view` - Get the current map view 64 | - `set_map_view` - Set the map view to specific coordinates or bounds 65 | - `set_map_title` - Set the title displayed at the bottom right of the map 66 | - `add_map_marker` - Add a marker at specific coordinates 67 | - `add_map_line` - Add a line defined by a set of coordinates 68 | - `add_map_polygon` - Add a polygon defined by a set of coordinates 69 | - `query_osm_postgres` - Execute a SQL query against the OpenStreetMap database 70 | ``` -------------------------------------------------------------------------------- /mcp.py: -------------------------------------------------------------------------------- ```python 1 | from mcp_osm import server 2 | mcp = server.mcp 3 | ``` -------------------------------------------------------------------------------- /mcp_osm/__init__.py: -------------------------------------------------------------------------------- ```python 1 | """ 2 | MCP-OSM: MCP server with OpenStreetMap integration 3 | """ 4 | 5 | __version__ = "0.1.0" ``` -------------------------------------------------------------------------------- /mcp_osm/__main__.py: -------------------------------------------------------------------------------- ```python 1 | """Main entry point for the MCP-OSM package.""" 2 | 3 | from mcp_osm.server import run_server 4 | 5 | if __name__ == "__main__": 6 | run_server() ``` -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Run the MCP-OSM server 4 | """ 5 | 6 | from mcp_osm.server import run_server 7 | 8 | if __name__ == "__main__": 9 | run_server() ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "mcp-osm" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "mcp[cli]>=1.3.0", 9 | "psycopg2>=2.9.10", 10 | "flask>=3.1.0", 11 | ] 12 | 13 | [project.optional-dependencies] 14 | dev = ["coverage>=6.0.0"] 15 | ``` -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | from setuptools import setup, find_packages 3 | 4 | setup( 5 | name="mcp-osm", 6 | version="0.1.0", 7 | description="MCP server with OpenStreetMap integration", 8 | author="Your Name", 9 | author_email="[email protected]", 10 | # Explicitly define packages to include 11 | packages=find_packages(exclude=["static", "templates"]), 12 | # Include our templates and static files as package data 13 | package_data={ 14 | "": ["templates/*", "static/*", "static/*/*"], 15 | }, 16 | include_package_data=True, 17 | # Define dependencies 18 | install_requires=[ 19 | "flask>=3.1.0", 20 | "psycopg2>=2.9.10", 21 | "fastmcp", 22 | ], 23 | python_requires=">=3.7", 24 | ) ``` -------------------------------------------------------------------------------- /map_demo.py: -------------------------------------------------------------------------------- ```python 1 | #!/usr/bin/env python3 2 | """ 3 | Map Control Demo - Shows how to use the Flask server to control the map display 4 | """ 5 | 6 | import sys 7 | import os 8 | import time 9 | 10 | # Set up the path to find our package 11 | sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) 12 | 13 | from mcp_osm.flask_server import FlaskServer 14 | 15 | def demo_map_controls(): 16 | # Initialize and start the Flask server 17 | server = FlaskServer() 18 | server.start() 19 | 20 | print("Map server started. Open http://127.0.0.1:5000 in your browser.") 21 | print("This demo will show various map features after you open the page.") 22 | time.sleep(5) # Give some time for the browser to connect 23 | 24 | # Example 1: Set the map title 25 | print("\nSetting map title...") 26 | server.set_title("San Francisco Landmarks", {"color": "#0066cc"}) 27 | time.sleep(2) 28 | 29 | # Example 2: Set the map view 30 | print("\nSetting map view to San Francisco...") 31 | server.set_view(center=[37.7749, -122.4194], zoom=12) 32 | time.sleep(3) 33 | 34 | # Example 3: Show a marker 35 | print("\nAdding a marker at Coit Tower...") 36 | server.show_marker( 37 | coordinates=[37.8024, -122.4058], 38 | text="Coit Tower, San Francisco", 39 | options={"title": "Coit Tower"} 40 | ) 41 | time.sleep(3) 42 | 43 | # Example 4: Show a polygon around Golden Gate Park 44 | print("\nAdding a polygon around Golden Gate Park...") 45 | golden_gate_park = [ 46 | [37.7694, -122.5110], 47 | [37.7694, -122.4566], 48 | [37.7646, -122.4566], 49 | [37.7646, -122.5110] 50 | ] 51 | server.show_polygon( 52 | coordinates=golden_gate_park, 53 | options={"color": "green", "fillOpacity": 0.3} 54 | ) 55 | time.sleep(3) 56 | 57 | # Example 5: Show a line for Market Street 58 | print("\nAdding a line along Market Street...") 59 | market_street = [ 60 | [37.7944, -122.3953], # Ferry Building 61 | [37.7932, -122.3967], 62 | [37.7909, -122.4013], 63 | [37.7891, -122.4051], 64 | [37.7865, -122.4113], # Powell Street 65 | [37.7835, -122.4173], 66 | [37.7811, -122.4219] # Civic Center 67 | ] 68 | server.show_line( 69 | coordinates=market_street, 70 | options={"color": "red", "weight": 5, "dashArray": "10,15"} 71 | ) 72 | time.sleep(3) 73 | 74 | # Update the title with additional information 75 | print("\nUpdating map title with additional information...") 76 | server.set_title("San Francisco Landmarks - Golden Gate Park Area", 77 | {"color": "#006600", "backgroundColor": "rgba(255, 255, 255, 0.9)"}) 78 | time.sleep(2) 79 | 80 | # Example 6: Get the current view 81 | print("\nCurrent map view:") 82 | current_view = server.get_current_view() 83 | print(f" Center: {current_view['center']}") 84 | print(f" Zoom: {current_view['zoom']}") 85 | if current_view['bounds']: 86 | print(f" Bounds: {current_view['bounds']}") 87 | 88 | print("\nDemo complete! Keep the browser window open to interact with the map.") 89 | print("Press Ctrl+C to stop the server when you're done.") 90 | 91 | # Keep the script running 92 | try: 93 | while True: 94 | time.sleep(1) 95 | except KeyboardInterrupt: 96 | print("\nStopping server...") 97 | server.stop() 98 | print("Server stopped.") 99 | 100 | if __name__ == "__main__": 101 | demo_map_controls() ``` -------------------------------------------------------------------------------- /mcp_osm/flask_server.py: -------------------------------------------------------------------------------- ```python 1 | import json 2 | import logging 3 | import os 4 | import queue 5 | import socket 6 | import sys 7 | import threading 8 | import time 9 | import io 10 | import base64 11 | from contextlib import redirect_stdout 12 | 13 | from unittest import mock 14 | 15 | _log = logging.getLogger('werkzeug') 16 | _log.setLevel(logging.WARNING) 17 | 18 | # Redirect all Flask/Werkzeug logging to stderr 19 | for handler in _log.handlers: 20 | handler.setStream(sys.stderr) 21 | 22 | from flask import ( 23 | Flask, 24 | Response, 25 | jsonify, 26 | render_template, 27 | request, 28 | send_from_directory, 29 | ) 30 | 31 | 32 | # Redirect all logging to stderr. 33 | logging.basicConfig(stream=sys.stderr) 34 | 35 | # Create a logger for this module 36 | logger = logging.getLogger(__name__) 37 | 38 | 39 | class FlaskServer: 40 | def __init__(self, host="127.0.0.1", port=5000): 41 | self.host = host 42 | self.port = port 43 | self.app = Flask(__name__, 44 | template_folder=os.path.join(os.path.dirname(os.path.dirname(__file__)), "templates"), 45 | static_folder=os.path.join(os.path.dirname(os.path.dirname(__file__)), "static")) 46 | 47 | # Capture and redirect Flask's initialization output to stderr 48 | with io.StringIO() as buf, redirect_stdout(buf): 49 | self.setup_routes() 50 | output = buf.getvalue() 51 | if output: 52 | logger.info(output.strip()) 53 | 54 | self.server_thread = None 55 | self.clients = {} # Maps client_id to message queue 56 | self.client_counter = 0 57 | self.current_view = { 58 | "center": [0, 0], 59 | "zoom": 2, 60 | "bounds": [[-85, -180], [85, 180]] 61 | } 62 | self.sse_clients = {} # Changed from list to dict to store queues 63 | self.latest_screenshot = None # Store the latest screenshot 64 | 65 | # Add storage for geolocate requests and responses 66 | self.geolocate_requests = {} 67 | self.geolocate_responses = {} 68 | 69 | def setup_routes(self): 70 | @self.app.route("/") 71 | def index(): 72 | return render_template("index.html") 73 | 74 | @self.app.route("/static/<path:path>") 75 | def send_static(path): 76 | return send_from_directory("static", path) 77 | 78 | @self.app.route("/api/sse") 79 | def sse(): 80 | def event_stream(client_id): 81 | # Create a queue for this client 82 | client_queue = queue.Queue() 83 | self.sse_clients[client_id] = client_queue 84 | 85 | try: 86 | # Initial connection message 87 | yield 'data: {"type": "connected", "id": %d}\n\n' % client_id 88 | 89 | while True: 90 | try: 91 | # Try to get a message from the queue with a timeout 92 | message = client_queue.get(timeout=30) 93 | yield f"data: {message}\n\n" 94 | except queue.Empty: 95 | # No message received in timeout period, send a ping 96 | yield 'data: {"type": "ping"}\n\n' 97 | 98 | except GeneratorExit: 99 | # Client disconnected 100 | if client_id in self.sse_clients: 101 | del self.sse_clients[client_id] 102 | logger.info( 103 | f"Client {client_id} disconnected, {len(self.sse_clients)} clients remaining" 104 | ) 105 | 106 | # Generate a unique ID for this client 107 | client_id = int(time.time() * 1000) % 1000000 108 | return Response(event_stream(client_id), mimetype="text/event-stream") 109 | 110 | @self.app.route("/api/viewChanged", methods=["POST"]) 111 | def view_changed(): 112 | data = request.json 113 | if data: 114 | if "center" in data: 115 | self.current_view["center"] = data["center"] 116 | if "zoom" in data: 117 | self.current_view["zoom"] = data["zoom"] 118 | if "bounds" in data: 119 | self.current_view["bounds"] = data["bounds"] 120 | return jsonify({"status": "success"}) 121 | 122 | @self.app.route("/api/screenshot", methods=["POST"]) 123 | def save_screenshot(): 124 | data = request.json 125 | if data and "image" in data: 126 | # Store the base64 image data 127 | self.latest_screenshot = data["image"] 128 | return jsonify({"status": "success"}) 129 | return jsonify({"status": "error", "message": "No image data provided"}), 400 130 | 131 | @self.app.route("/api/geolocateResponse", methods=["POST"]) 132 | def geolocate_response(): 133 | data = request.json 134 | if data and "requestId" in data and "results" in data: 135 | request_id = data["requestId"] 136 | results = data["results"] 137 | 138 | # Store the response 139 | self.geolocate_responses[request_id] = results 140 | logger.info(f"Received geolocate response for request {request_id} with {len(results)} results") 141 | 142 | return jsonify({"status": "success"}) 143 | return jsonify({"status": "error", "message": "Invalid geolocate response data"}), 400 144 | 145 | def is_port_in_use(self, port): 146 | """Check if a port is already in use""" 147 | with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 148 | return s.connect_ex((self.host, port)) == 0 149 | 150 | def start(self): 151 | """Start the Flask server in a separate thread""" 152 | # Try up to 10 ports, starting with self.port 153 | original_port = self.port 154 | max_attempts = 10 155 | 156 | for attempt in range(max_attempts): 157 | if self.is_port_in_use(self.port): 158 | logger.info(f"Port {self.port} is already in use, trying port {self.port + 1}") 159 | self.port += 1 160 | if attempt == max_attempts - 1: 161 | logger.error(f"Failed to find an available port after {max_attempts} attempts") 162 | # Reset port to original value 163 | self.port = original_port 164 | return False 165 | else: 166 | # Port is available, start the server 167 | def run_server(): 168 | # Redirect stdout to stderr while running Flask 169 | with redirect_stdout(sys.stderr): 170 | self.app.run( 171 | host=self.host, port=self.port, debug=False, use_reloader=False 172 | ) 173 | 174 | self.server_thread = threading.Thread(target=run_server) 175 | self.server_thread.daemon = True # Thread will exit when main thread exits 176 | self.server_thread.start() 177 | logger.info(f"Flask server started at http://{self.host}:{self.port}") 178 | return True 179 | 180 | return False 181 | 182 | def stop(self): 183 | """Stop the Flask server""" 184 | # Flask doesn't provide a clean way to stop the server from outside 185 | # In a production environment, you would use a more robust server like gunicorn 186 | # For this example, we'll rely on the daemon thread to exit when the main thread exits 187 | logger.info("Flask server stopping...") 188 | 189 | # Map control methods 190 | def send_map_command(self, command_type, data): 191 | """ 192 | Send a command to all connected SSE clients 193 | 194 | Args: 195 | command_type (str): Type of command (SHOW_POLYGON, SHOW_MARKER, SET_VIEW) 196 | data (dict): Data for the command 197 | """ 198 | command = {"type": command_type, "data": data} 199 | message = json.dumps(command) 200 | 201 | # Send the message to all connected clients 202 | clients_count = len(self.sse_clients) 203 | if clients_count == 0: 204 | logger.info("No connected clients to send message to") 205 | return 206 | 207 | logger.info(f"Sending {command_type} to {clients_count} clients") 208 | for client_id, client_queue in list(self.sse_clients.items()): 209 | try: 210 | client_queue.put(message) 211 | except Exception as e: 212 | logger.error(f"Error sending to client {client_id}: {e}") 213 | 214 | def show_polygon(self, coordinates, options=None): 215 | """ 216 | Display a polygon on the map 217 | 218 | Args: 219 | coordinates (list): List of [lat, lng] coordinates 220 | options (dict, optional): Styling options 221 | """ 222 | data = {"coordinates": coordinates, "options": options or {}} 223 | self.send_map_command("SHOW_POLYGON", data) 224 | 225 | def show_marker(self, coordinates, text=None, options=None): 226 | """ 227 | Display a marker on the map 228 | 229 | Args: 230 | coordinates (list): [lat, lng] coordinates 231 | text (str, optional): Popup text 232 | options (dict, optional): Styling options 233 | """ 234 | data = {"coordinates": coordinates, "text": text, "options": options or {}} 235 | self.send_map_command("SHOW_MARKER", data) 236 | 237 | def show_line(self, coordinates, options=None): 238 | """ 239 | Display a line (polyline) on the map 240 | 241 | Args: 242 | coordinates (list): List of [lat, lng] coordinates 243 | options (dict, optional): Styling options 244 | """ 245 | data = {"coordinates": coordinates, "options": options or {}} 246 | self.send_map_command("SHOW_LINE", data) 247 | 248 | def set_view(self, bounds=None, center=None, zoom=None): 249 | """ 250 | Set the map view 251 | 252 | Args: 253 | bounds (list, optional): [[south, west], [north, east]] 254 | center (list, optional): [lat, lng] center point 255 | zoom (int, optional): Zoom level 256 | """ 257 | data = {} 258 | if bounds: 259 | data["bounds"] = bounds 260 | if center: 261 | data["center"] = center 262 | if zoom: 263 | data["zoom"] = zoom 264 | 265 | self.send_map_command("SET_VIEW", data) 266 | 267 | def get_current_view(self): 268 | """ 269 | Get the current map view 270 | 271 | Returns: 272 | dict: Current view information 273 | """ 274 | return self.current_view 275 | 276 | def set_title(self, title, options=None): 277 | """ 278 | Set the map title displayed at the bottom right of the map 279 | 280 | Args: 281 | title (str): Title text to display 282 | options (dict, optional): Styling options like fontSize, color, etc. 283 | """ 284 | data = {"title": title, "options": options or {}} 285 | self.send_map_command("SET_TITLE", data) 286 | 287 | def capture_screenshot(self): 288 | """ 289 | Request a screenshot from the map and wait for it to be received 290 | 291 | Returns: 292 | str: Base64-encoded image data, or None if no screenshot is available 293 | """ 294 | # Send command to capture screenshot 295 | self.send_map_command("CAPTURE_SCREENSHOT", {}) 296 | 297 | # Wait for the screenshot to be received (with timeout) 298 | start_time = time.time() 299 | timeout = 5 # seconds 300 | 301 | while time.time() - start_time < timeout: 302 | if self.latest_screenshot: 303 | screenshot = self.latest_screenshot 304 | self.latest_screenshot = None # Clear after retrieving 305 | return screenshot 306 | time.sleep(0.1) 307 | 308 | logger.warning("Screenshot capture timed out") 309 | return None 310 | 311 | def geolocate(self, query): 312 | """ 313 | Send a geolocate request to the web client and wait for the response 314 | 315 | Args: 316 | query (str): The location name to search for 317 | 318 | Returns: 319 | list: Nominatim search results or None if the request times out 320 | """ 321 | # Generate a unique request ID 322 | request_id = str(int(time.time() * 1000)) 323 | 324 | # Send the geolocate command to the web client 325 | data = {"requestId": request_id, "query": query} 326 | self.send_map_command("GEOLOCATE", data) 327 | 328 | # Wait for the response (with timeout) 329 | start_time = time.time() 330 | timeout = 10 # seconds 331 | 332 | while time.time() - start_time < timeout: 333 | if request_id in self.geolocate_responses: 334 | results = self.geolocate_responses.pop(request_id) 335 | return results 336 | time.sleep(0.1) 337 | 338 | logger.warning(f"Geolocate request for '{query}' timed out") 339 | return None 340 | 341 | 342 | # For testing the Flask server directly 343 | if __name__ == "__main__": 344 | server = FlaskServer() 345 | server.start() 346 | 347 | # Keep the main thread running 348 | try: 349 | logger.info("Press Ctrl+C to stop the server") 350 | import time 351 | 352 | while True: 353 | time.sleep(1) 354 | except KeyboardInterrupt: 355 | server.stop() 356 | logger.info("Server stopped") 357 | ``` -------------------------------------------------------------------------------- /templates/index.html: -------------------------------------------------------------------------------- ```html 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 | <title>OpenStreetMap Viewer</title> 7 | 8 | <!-- Leaflet CSS --> 9 | <link rel="stylesheet" href="https://unpkg.com/[email protected]/dist/leaflet.css" 10 | integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" 11 | crossorigin=""/> 12 | 13 | <!-- html2canvas for screenshot functionality --> 14 | <script src="https://html2canvas.hertzen.com/dist/html2canvas.min.js"></script> 15 | 16 | <style> 17 | body, html { 18 | margin: 0; 19 | padding: 0; 20 | height: 100%; 21 | width: 100%; 22 | } 23 | #map { 24 | height: 100vh; 25 | width: 100%; 26 | } 27 | .info-panel { 28 | padding: 10px; 29 | background: white; 30 | border-radius: 5px; 31 | box-shadow: 0 0 15px rgba(0,0,0,0.2); 32 | } 33 | .map-title { 34 | position: absolute; 35 | bottom: 20px; 36 | right: 20px; 37 | padding: 10px 15px; 38 | background-color: rgba(255, 255, 255, 0.8); 39 | border-radius: 5px; 40 | box-shadow: 0 0 15px rgba(0, 0, 0, 0.2); 41 | z-index: 1000; 42 | font-size: 24px; 43 | font-weight: bold; 44 | max-width: 50%; 45 | text-align: right; 46 | } 47 | .search-container { 48 | position: absolute; 49 | top: 20px; 50 | right: 20px; 51 | z-index: 1000; 52 | width: 300px; 53 | } 54 | .search-box { 55 | width: 100%; 56 | padding: 10px; 57 | border: none; 58 | border-radius: 5px; 59 | box-shadow: 0 0 15px rgba(0,0,0,0.2); 60 | font-size: 16px; 61 | } 62 | .search-results { 63 | margin-top: 5px; 64 | max-height: 300px; 65 | overflow-y: auto; 66 | background-color: white; 67 | border-radius: 5px; 68 | box-shadow: 0 0 15px rgba(0,0,0,0.2); 69 | display: none; 70 | } 71 | .search-result-item { 72 | padding: 10px; 73 | cursor: pointer; 74 | border-bottom: 1px solid #eee; 75 | } 76 | .search-result-item:hover { 77 | background-color: #f5f5f5; 78 | } 79 | .search-result-item:last-child { 80 | border-bottom: none; 81 | } 82 | </style> 83 | </head> 84 | <body> 85 | <div id="map"></div> 86 | <div id="mapTitle" class="map-title" style="display:none;"></div> 87 | 88 | <!-- Search Box --> 89 | <div class="search-container"> 90 | <input type="text" id="searchBox" class="search-box" placeholder="Search for a place..." autocomplete="off"> 91 | <div id="searchResults" class="search-results"></div> 92 | </div> 93 | 94 | <!-- Leaflet JavaScript --> 95 | <script src="https://unpkg.com/[email protected]/dist/leaflet.js" 96 | integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" 97 | crossorigin=""></script> 98 | 99 | <script> 100 | // Initialize the map 101 | const map = L.map('map').setView([0, 0], 2); 102 | 103 | // Add OpenStreetMap tile layer 104 | L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { 105 | attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', 106 | maxZoom: 19 107 | }).addTo(map); 108 | 109 | // Storage for created objects to allow removal/updates 110 | const mapObjects = { 111 | markers: {}, 112 | polygons: {}, 113 | lines: {} 114 | }; 115 | 116 | // Counter for generating unique IDs 117 | let objectCounter = 0; 118 | 119 | // Send initial view to server 120 | sendViewChanged(); 121 | 122 | // Map event handlers 123 | map.on('moveend', sendViewChanged); 124 | map.on('zoomend', sendViewChanged); 125 | 126 | // Function to send view changes to the server 127 | function sendViewChanged() { 128 | const center = map.getCenter(); 129 | const bounds = map.getBounds(); 130 | const zoom = map.getZoom(); 131 | 132 | fetch('/api/viewChanged', { 133 | method: 'POST', 134 | headers: { 135 | 'Content-Type': 'application/json' 136 | }, 137 | body: JSON.stringify({ 138 | center: [center.lat, center.lng], 139 | zoom: zoom, 140 | bounds: [ 141 | [bounds.getSouth(), bounds.getWest()], 142 | [bounds.getNorth(), bounds.getEast()] 143 | ] 144 | }) 145 | }) 146 | .catch(error => console.error('Error sending view change:', error)); 147 | } 148 | 149 | // Search functionality 150 | const searchBox = document.getElementById('searchBox'); 151 | const searchResults = document.getElementById('searchResults'); 152 | let searchTimeout = null; 153 | 154 | // Add event listener for search input 155 | searchBox.addEventListener('input', function() { 156 | // Clear previous timeout 157 | if (searchTimeout) { 158 | clearTimeout(searchTimeout); 159 | } 160 | 161 | const query = this.value.trim(); 162 | 163 | // Hide results if query is empty 164 | if (!query) { 165 | searchResults.style.display = 'none'; 166 | return; 167 | } 168 | 169 | // Set a timeout to avoid making too many requests while typing 170 | searchTimeout = setTimeout(() => { 171 | searchPlace(query); 172 | }, 500); 173 | }); 174 | 175 | // Function to search for a place using Nominatim 176 | function searchPlace(query) { 177 | // Nominatim API URL 178 | const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=5`; 179 | 180 | fetch(url) 181 | .then(response => response.json()) 182 | .then(data => { 183 | displaySearchResults(data); 184 | }) 185 | .catch(error => { 186 | console.error('Error searching for place:', error); 187 | }); 188 | } 189 | 190 | // Function to display search results 191 | function displaySearchResults(results) { 192 | // Clear previous results 193 | searchResults.innerHTML = ''; 194 | 195 | if (results.length === 0) { 196 | searchResults.innerHTML = '<div class="search-result-item">No results found</div>'; 197 | searchResults.style.display = 'block'; 198 | return; 199 | } 200 | 201 | // Create result items 202 | results.forEach(result => { 203 | const resultItem = document.createElement('div'); 204 | resultItem.className = 'search-result-item'; 205 | resultItem.textContent = result.display_name; 206 | 207 | // Add click event to go to the location 208 | resultItem.addEventListener('click', () => { 209 | goToLocation(result); 210 | searchResults.style.display = 'none'; 211 | searchBox.value = result.display_name; 212 | }); 213 | 214 | searchResults.appendChild(resultItem); 215 | }); 216 | 217 | // Show results 218 | searchResults.style.display = 'block'; 219 | } 220 | 221 | // Function to go to a location 222 | function goToLocation(location) { 223 | const lat = parseFloat(location.lat); 224 | const lon = parseFloat(location.lon); 225 | 226 | // Create a marker at the location 227 | const markerId = showMarker([lat, lon], location.display_name, { openPopup: true }); 228 | 229 | // Set the view to the location 230 | map.setView([lat, lon], 14); 231 | } 232 | 233 | // Close search results when clicking outside 234 | document.addEventListener('click', function(event) { 235 | if (!event.target.closest('.search-container')) { 236 | searchResults.style.display = 'none'; 237 | } 238 | }); 239 | 240 | // Function to capture and send a screenshot of the map 241 | function captureMapScreenshot() { 242 | console.log('Capturing map screenshot...'); 243 | 244 | // Use html2canvas to capture the map element 245 | html2canvas(document.getElementById('map')).then(canvas => { 246 | // Convert canvas to base64 image data 247 | const imageData = canvas.toDataURL('image/png'); 248 | 249 | // Send the image data to the server 250 | fetch('/api/screenshot', { 251 | method: 'POST', 252 | headers: { 253 | 'Content-Type': 'application/json' 254 | }, 255 | body: JSON.stringify({ 256 | image: imageData 257 | }) 258 | }) 259 | .then(response => response.json()) 260 | .then(data => { 261 | console.log('Screenshot sent to server:', data); 262 | }) 263 | .catch(error => { 264 | console.error('Error sending screenshot:', error); 265 | }); 266 | }); 267 | } 268 | 269 | // Function to perform a geolocate search and send results back to server 270 | function performGeolocateSearch(requestId, query) { 271 | console.log(`Performing geolocate search for "${query}" (request ID: ${requestId})`); 272 | 273 | // Nominatim API URL 274 | const url = `https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(query)}&limit=10`; 275 | 276 | fetch(url) 277 | .then(response => response.json()) 278 | .then(data => { 279 | // Send the results back to the server 280 | fetch('/api/geolocateResponse', { 281 | method: 'POST', 282 | headers: { 283 | 'Content-Type': 'application/json' 284 | }, 285 | body: JSON.stringify({ 286 | requestId: requestId, 287 | results: data 288 | }) 289 | }) 290 | .then(response => response.json()) 291 | .then(data => { 292 | console.log('Geolocate response sent to server:', data); 293 | }) 294 | .catch(error => { 295 | console.error('Error sending geolocate response:', error); 296 | }); 297 | }) 298 | .catch(error => { 299 | console.error('Error performing geolocate search:', error); 300 | // Send empty results in case of error 301 | fetch('/api/geolocateResponse', { 302 | method: 'POST', 303 | headers: { 304 | 'Content-Type': 'application/json' 305 | }, 306 | body: JSON.stringify({ 307 | requestId: requestId, 308 | results: [] 309 | }) 310 | }); 311 | }); 312 | } 313 | 314 | // SSE connection to receive commands from the server 315 | function connectSSE() { 316 | const eventSource = new EventSource('/api/sse'); 317 | 318 | eventSource.onopen = function() { 319 | console.log('SSE connection established'); 320 | }; 321 | 322 | eventSource.onerror = function(error) { 323 | console.error('SSE connection error:', error); 324 | eventSource.close(); 325 | // Try to reconnect after a delay 326 | setTimeout(connectSSE, 5000); 327 | }; 328 | 329 | eventSource.onmessage = function(event) { 330 | try { 331 | const message = JSON.parse(event.data); 332 | handleServerCommand(message); 333 | } catch (error) { 334 | console.error('Error processing SSE message:', error); 335 | } 336 | }; 337 | } 338 | 339 | // Handle commands received from the server 340 | function handleServerCommand(message) { 341 | console.log('Received server command:', message); 342 | 343 | switch (message.type) { 344 | case 'SHOW_POLYGON': 345 | showPolygon(message.data.coordinates, message.data.options); 346 | break; 347 | 348 | case 'SHOW_MARKER': 349 | showMarker(message.data.coordinates, message.data.text, message.data.options); 350 | break; 351 | 352 | case 'SHOW_LINE': 353 | showLine(message.data.coordinates, message.data.options); 354 | break; 355 | 356 | case 'SET_VIEW': 357 | setView(message.data); 358 | break; 359 | 360 | case 'SET_TITLE': 361 | setTitle(message.data.title, message.data.options); 362 | break; 363 | 364 | case 'CAPTURE_SCREENSHOT': 365 | captureMapScreenshot(); 366 | break; 367 | 368 | case 'GEOLOCATE': 369 | performGeolocateSearch(message.data.requestId, message.data.query); 370 | break; 371 | 372 | case 'ping': 373 | // Just a keepalive, do nothing 374 | break; 375 | 376 | case 'connected': 377 | console.log('Connected to server with ID:', message.id); 378 | break; 379 | 380 | default: 381 | console.warn('Unknown command type:', message.type); 382 | } 383 | } 384 | 385 | // Function to show a polygon on the map 386 | function showPolygon(coordinates, options = {}) { 387 | const id = 'polygon_' + (objectCounter++); 388 | 389 | // Remove existing polygon with the same ID if it exists 390 | if (mapObjects.polygons[id]) { 391 | map.removeLayer(mapObjects.polygons[id]); 392 | } 393 | 394 | // Create and add the polygon 395 | const polygon = L.polygon(coordinates, options).addTo(map); 396 | mapObjects.polygons[id] = polygon; 397 | 398 | // Fit map to polygon bounds if requested 399 | if (options.fitBounds) { 400 | map.fitBounds(polygon.getBounds()); 401 | } 402 | 403 | return id; 404 | } 405 | 406 | // Function to show a marker on the map 407 | function showMarker(coordinates, text = null, options = {}) { 408 | const id = 'marker_' + (objectCounter++); 409 | 410 | // Remove existing marker with the same ID if it exists 411 | if (mapObjects.markers[id]) { 412 | map.removeLayer(mapObjects.markers[id]); 413 | } 414 | 415 | // Create and add the marker 416 | const marker = L.marker(coordinates, options).addTo(map); 417 | 418 | // Add popup if text is provided 419 | if (text) { 420 | marker.bindPopup(text); 421 | if (options.openPopup) { 422 | marker.openPopup(); 423 | } 424 | } 425 | 426 | mapObjects.markers[id] = marker; 427 | return id; 428 | } 429 | 430 | // Function to set the map view 431 | function setView(viewOptions) { 432 | if (viewOptions.bounds) { 433 | map.fitBounds(viewOptions.bounds); 434 | } else if (viewOptions.center && viewOptions.zoom) { 435 | map.setView(viewOptions.center, viewOptions.zoom); 436 | } else if (viewOptions.center) { 437 | map.panTo(viewOptions.center); 438 | } else if (viewOptions.zoom) { 439 | map.setZoom(viewOptions.zoom); 440 | } 441 | } 442 | 443 | // Function to set the map title 444 | function setTitle(title, options = {}) { 445 | const titleElement = document.getElementById('mapTitle'); 446 | 447 | if (!title) { 448 | titleElement.style.display = 'none'; 449 | return; 450 | } 451 | 452 | // Set title text 453 | titleElement.textContent = title; 454 | titleElement.style.display = 'block'; 455 | 456 | // Apply custom styling if provided 457 | if (options.fontSize) { 458 | titleElement.style.fontSize = options.fontSize; 459 | } 460 | 461 | if (options.color) { 462 | titleElement.style.color = options.color; 463 | } 464 | 465 | if (options.backgroundColor) { 466 | titleElement.style.backgroundColor = options.backgroundColor; 467 | } 468 | } 469 | 470 | // Function to show a line (polyline) on the map 471 | function showLine(coordinates, options = {}) { 472 | const id = 'line_' + (objectCounter++); 473 | 474 | // Remove existing line with the same ID if it exists 475 | if (mapObjects.lines[id]) { 476 | map.removeLayer(mapObjects.lines[id]); 477 | } 478 | 479 | // Create and add the line 480 | const line = L.polyline(coordinates, options).addTo(map); 481 | mapObjects.lines[id] = line; 482 | 483 | // Fit map to line bounds if requested 484 | if (options.fitBounds) { 485 | map.fitBounds(line.getBounds()); 486 | } 487 | 488 | return id; 489 | } 490 | 491 | // Connect to the SSE endpoint 492 | connectSSE(); 493 | </script> 494 | </body> 495 | </html> ``` -------------------------------------------------------------------------------- /mcp_osm/server.py: -------------------------------------------------------------------------------- ```python 1 | import logging 2 | import os 3 | import re 4 | import sys 5 | import time 6 | import json 7 | import base64 8 | from dataclasses import dataclass 9 | from typing import Any, Dict, List, Optional, Tuple, AsyncIterator 10 | from contextlib import asynccontextmanager 11 | 12 | import psycopg2 13 | import psycopg2.extras 14 | from mcp.server.fastmcp import Context, FastMCP 15 | 16 | from mcp_osm.flask_server import FlaskServer 17 | 18 | 19 | # Configure all logging to stderr 20 | logging.basicConfig( 21 | stream=sys.stderr, 22 | format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' 23 | ) 24 | 25 | # Create a logger for this module 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | # Custom database connection class 30 | @dataclass 31 | class PostgresConnection: 32 | conn: Any 33 | 34 | async def execute_query( 35 | self, query: str, params: Optional[Dict[str, Any]] = None, max_rows: int = 1000 36 | ) -> Tuple[List[Dict[str, Any]], int]: 37 | """Execute a query and return results as a list of dictionaries with total count.""" 38 | logger.info(f"Executing query: {query}, params: {params}") 39 | start_time = time.time() 40 | with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: 41 | try: 42 | # Set statement timeout to 20 seconds 43 | cur.execute("SET statement_timeout = 20000") 44 | 45 | if params: 46 | cur.execute(query, params) 47 | else: 48 | cur.execute(query) 49 | end_time = time.time() 50 | logger.info(f"Query execution time: {end_time - start_time} seconds") 51 | total_rows = cur.rowcount 52 | results = cur.fetchmany(max_rows) 53 | logger.info(f"Got {total_rows} rows") 54 | # Log first 3 rows. 55 | for row in results[:3]: 56 | logger.info(f"Row: {row}") 57 | return results, total_rows 58 | except psycopg2.errors.QueryCanceled: 59 | self.conn.rollback() 60 | raise TimeoutError("Query execution timed out. Did you use a bounding box, and ::geography?") 61 | except Exception as e: 62 | self.conn.rollback() 63 | raise e 64 | 65 | async def get_tables(self) -> List[str]: 66 | """Get list of tables in the database.""" 67 | query = """ 68 | SELECT table_name 69 | FROM information_schema.tables 70 | WHERE table_schema = 'public' 71 | ORDER BY table_name; 72 | """ 73 | with self.conn.cursor() as cur: 74 | cur.execute(query) 75 | return [row[0] for row in cur.fetchall()] 76 | 77 | async def get_table_schema(self, table_name: str) -> List[Dict[str, Any]]: 78 | """Get schema information for a table.""" 79 | query = """ 80 | SELECT column_name, data_type, is_nullable 81 | FROM information_schema.columns 82 | WHERE table_name = %s 83 | ORDER BY ordinal_position; 84 | """ 85 | with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: 86 | cur.execute(query, (table_name,)) 87 | return cur.fetchall() 88 | 89 | async def get_table_info(self, table_name: str) -> Dict[str, Any]: 90 | """Get detailed information about a table including indexes.""" 91 | # Get table columns 92 | columns = await self.get_table_schema(table_name) 93 | # Get table indexes 94 | index_query = """ 95 | SELECT indexname, indexdef 96 | FROM pg_indexes 97 | WHERE tablename = %s; 98 | """ 99 | with self.conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: 100 | cur.execute(index_query, (table_name,)) 101 | indexes = cur.fetchall() 102 | # Get table row count (approximate) 103 | count_query = f"SELECT count(*) FROM {table_name};" 104 | with self.conn.cursor() as cur: 105 | cur.execute(count_query) 106 | row_count = cur.fetchone()[0] 107 | return { 108 | "name": table_name, 109 | "columns": columns, 110 | "indexes": indexes, 111 | "approximate_row_count": row_count, 112 | } 113 | 114 | 115 | @dataclass 116 | class AppContext: 117 | db_conn: Optional[PostgresConnection] = None 118 | flask_server: Optional[FlaskServer] = None 119 | 120 | @asynccontextmanager 121 | async def app_lifespan(server: FastMCP) -> AsyncIterator[AppContext]: 122 | """Manage application lifecycle with type-safe context""" 123 | app_ctx = AppContext() 124 | try: 125 | # Initialize database connection (optional) 126 | try: 127 | logger.info("Connecting to database...") 128 | conn = psycopg2.connect( 129 | host=os.environ.get("PGHOST", "localhost"), 130 | port=os.environ.get("PGPORT", "5432"), 131 | dbname=os.environ.get("PGDB", "osm"), 132 | user=os.environ.get("PGUSER", "postgres"), 133 | password=os.environ.get("PGPASSWORD", "postgres"), 134 | ) 135 | app_ctx.db_conn = PostgresConnection(conn) 136 | logger.info("Database connection established") 137 | except Exception as e: 138 | logger.warning(f"Warning: Could not connect to database: {e}") 139 | logger.warning("Continuing without database connection") 140 | 141 | # Initialize and start Flask server 142 | logger.info("Starting Flask server...") 143 | flask_server = FlaskServer( 144 | host=os.environ.get("FLASK_HOST", "127.0.0.1"), 145 | port=int(os.environ.get("FLASK_PORT","8888")) 146 | ) 147 | flask_server.start() 148 | app_ctx.flask_server = flask_server 149 | logger.info(f"Flask server started at http://{flask_server.host}:{flask_server.port}") 150 | 151 | yield app_ctx 152 | finally: 153 | # Cleanup on shutdown 154 | if app_ctx.flask_server: 155 | logger.info("Stopping Flask server...") 156 | app_ctx.flask_server.stop() 157 | 158 | if app_ctx.db_conn and app_ctx.db_conn.conn: 159 | logger.info("Closing database connection...") 160 | app_ctx.db_conn.conn.close() 161 | 162 | 163 | # Initialize the MCP server 164 | mcp = FastMCP("OSM MCP Server", 165 | dependencies=["psycopg2>=2.9.10", "flask>=3.1.0"], 166 | lifespan=app_lifespan) 167 | 168 | 169 | def is_read_only_query(query: str) -> bool: 170 | """Check if a query is read-only.""" 171 | # Normalize query by removing comments and extra whitespace 172 | query = re.sub(r"--.*$", "", query, flags=re.MULTILINE) 173 | query = re.sub(r"/\*.*?\*/", "", query, flags=re.DOTALL) 174 | query = query.strip().lower() 175 | 176 | # Check for write operations 177 | write_operations = [ 178 | r"^\s*insert\s+", 179 | r"^\s*update\s+", 180 | r"^\s*delete\s+", 181 | r"^\s*drop\s+", 182 | r"^\s*create\s+", 183 | r"^\s*alter\s+", 184 | r"^\s*truncate\s+", 185 | r"^\s*grant\s+", 186 | r"^\s*revoke\s+", 187 | r"^\s*set\s+", 188 | ] 189 | 190 | for pattern in write_operations: 191 | if re.search(pattern, query): 192 | return False 193 | 194 | return True 195 | 196 | 197 | # Database query tools 198 | @mcp.tool() 199 | async def query_osm_postgres(query: str, ctx: Context) -> str: 200 | """ 201 | Execute SQL query against the OSM PostgreSQL database. This database 202 | contains the complete OSM data in a postgres database, and is an excellent 203 | way to analyze or query geospatial/geographic data. 204 | 205 | Args: 206 | query: SQL query to execute 207 | 208 | Returns: 209 | Query results as formatted text 210 | 211 | Example query: Find points of interest near a location 212 | ```sql 213 | SELECT osm_id, name, amenity, tourism, shop, tags 214 | FROM planet_osm_point 215 | WHERE (amenity IS NOT NULL OR tourism IS NOT NULL OR shop IS NOT NULL) 216 | AND ST_DWithin( 217 | geography(way), 218 | geography(ST_SetSRID(ST_MakePoint(-73.99, 40.71), 4326)), 219 | 1000 -- 1000 meters 220 | ); 221 | ``` 222 | 223 | The database is in postgres using the postgis extension. It was 224 | created by the osm2pgsql tool. This database is a complete dump of the 225 | OSM data. 226 | 227 | In OpenStreetMap (OSM), data is structured using nodes (points), ways 228 | (lines/polygons), and relations. Nodes represent individual points 229 | with coordinates, while ways are ordered lists of nodes forming lines 230 | or closed shapes (polygons). 231 | 232 | Remember that name alone is not sufficient to disambiguate a 233 | feature. For any name you can think of, there are dozens of features 234 | around the world with that name, probably even of the same type 235 | (e.g. lots of cities named "Los Angeles"). If you know the general 236 | location, you can use a bounding box to disambiguate. YOU MUST 237 | DISAMBIGUATE FEATURES with bounding boxes!!!!!!!!!!!! 238 | 239 | Even if you have other WHERE clauses, you MUST use a bounding box to 240 | disambiguate features. Name and other tags alone are not sufficient. 241 | 242 | PostGIS has useful features like ST_Simplify which is especially 243 | helpful to reduce data to a reasonable size when doing visualizations. 244 | 245 | Always try to get and refer to OSM IDs when possible because they are 246 | unique and are the absolute fastest way to refer again to a 247 | feature. Users don't usually care what they are but they can help you 248 | speed up subsequent queries. 249 | 250 | YOU MUST DISAMBIGUATE FEATURES with bounding boxes!!!!!!!!!!!! 251 | 252 | Speaking of speed, there's a TON of data, so queries that don't use 253 | indexes will be too slow. It's usually best to use postgres and 254 | postgis functions, and advanced sql when possible. If you need to 255 | explore the data to get a sense of tags, etc., make sure to limit the 256 | number of rows you get back to a small number or use aggregation 257 | functions. Every query will either need to be filtered with WHERE 258 | clauses or be an aggregation query. 259 | 260 | YOU MUST DISAMBIGUATE FEATURES with bounding boxes!!!!!!!!!!!! 261 | 262 | IMPORTANT: All the spatial indexes are on the geography type, not the 263 | geometry type. This means if you do a spatial query, you need to use 264 | the geography function. For example: 265 | 266 | ``` 267 | SELECT 268 | b.osm_id AS building_id, 269 | b.name AS building_name, 270 | ST_AsText(b.way) AS building_geometry 271 | FROM 272 | planet_osm_polygon b 273 | JOIN 274 | planet_osm_polygon burbank ON burbank.osm_id = -3529574 275 | JOIN 276 | planet_osm_polygon glendale ON glendale.osm_id = -2313082 277 | WHERE 278 | ST_Intersects(b.way::geography, burbank.way::geography) AND 279 | ST_Intersects(b.way::geography, glendale.way::geography) AND 280 | b.building IS NOT NULL; 281 | ``` 282 | 283 | Here's a more detailed explanation of the data representation: 284 | 285 | • Nodes: [1, 2, 3] 286 | • Represent individual points on the map with latitude and 287 | longitude coordinates. [1, 2, 3] 288 | • Can be used to represent point features like shops, lamp 289 | posts, etc. [1] 290 | • Collections of nodes are also used to define the shape of 291 | ways. [1] 292 | 293 | • Ways: [1, 2] 294 | • Represent collections of nodes. [1, 2] 295 | • Do not store their own coordinates; instead, they store an ordered 296 | list of node identifiers. [1, 2] 297 | 298 | • Ways can be open (lines) or closed (polygons). [2, 5] 299 | 300 | • Used to represent various features like roads, railways, river 301 | centerlines, powerlines, and administrative borders. [1] 302 | 303 | • Relations: [4] 304 | • Are groups of nodes and/or ways, used to represent complex features 305 | like routes, areas, or relationships between map elements. [4] 306 | 307 | [1] https://algo.win.tue.nl/tutorials/openstreetmap/ 308 | [2] https://docs.geodesk.com/intro-to-osm 309 | [3] https://wiki.openstreetmap.org/wiki/Elements 310 | [4] https://racum.blog/articles/osm-to-geojson/ 311 | [5] https://wiki.openstreetmap.org/wiki/Way 312 | 313 | Tags are key-value pairs that describe the features in the map. They 314 | are used to store information about the features, such as their name, 315 | type, or other properties. Note that in the following tables, some 316 | tags have their own columns, but all other tags are stored in the tags 317 | column as a hstore type. 318 | 319 | List of tables: 320 | | Name | 321 | |--------------------| 322 | | planet_osm_line | 323 | | planet_osm_point | 324 | | planet_osm_polygon | 325 | | planet_osm_rels | 326 | | planet_osm_roads | 327 | | planet_osm_ways | 328 | | spatial_ref_sys | 329 | 330 | Table "public.planet_osm_line": 331 | | Column | Type | 332 | |--------------------+---------------------------| 333 | | osm_id | bigint | 334 | | access | text | 335 | | addr:housename | text | 336 | | addr:housenumber | text | 337 | | addr:interpolation | text | 338 | | admin_level | text | 339 | | aerialway | text | 340 | | aeroway | text | 341 | | amenity | text | 342 | | area | text | 343 | | barrier | text | 344 | | bicycle | text | 345 | | brand | text | 346 | | bridge | text | 347 | | boundary | text | 348 | | building | text | 349 | | construction | text | 350 | | covered | text | 351 | | culvert | text | 352 | | cutting | text | 353 | | denomination | text | 354 | | disused | text | 355 | | embankment | text | 356 | | foot | text | 357 | | generator:source | text | 358 | | harbour | text | 359 | | highway | text | 360 | | historic | text | 361 | | horse | text | 362 | | intermittent | text | 363 | | junction | text | 364 | | landuse | text | 365 | | layer | text | 366 | | leisure | text | 367 | | lock | text | 368 | | man_made | text | 369 | | military | text | 370 | | motorcar | text | 371 | | name | text | 372 | | natural | text | 373 | | office | text | 374 | | oneway | text | 375 | | operator | text | 376 | | place | text | 377 | | population | text | 378 | | power | text | 379 | | power_source | text | 380 | | public_transport | text | 381 | | railway | text | 382 | | ref | text | 383 | | religion | text | 384 | | route | text | 385 | | service | text | 386 | | shop | text | 387 | | sport | text | 388 | | surface | text | 389 | | toll | text | 390 | | tourism | text | 391 | | tower:type | text | 392 | | tracktype | text | 393 | | tunnel | text | 394 | | water | text | 395 | | waterway | text | 396 | | wetland | text | 397 | | width | text | 398 | | wood | text | 399 | | z_order | integer | 400 | | way_area | real | 401 | | tags | hstore | 402 | | way | geometry(LineString,4326) | 403 | Indexes: 404 | "planet_osm_line_osm_id_idx" btree (osm_id) 405 | "planet_osm_line_tags_idx" gin (tags) 406 | "planet_osm_line_way_geog_idx" gist (geography(way)) 407 | 408 | Table "public.planet_osm_point": 409 | | Column | Type | 410 | |--------------------+----------------------| 411 | | osm_id | bigint | 412 | | access | text | 413 | | addr:housename | text | 414 | | addr:housenumber | text | 415 | | addr:interpolation | text | 416 | | admin_level | text | 417 | | aerialway | text | 418 | | aeroway | text | 419 | | amenity | text | 420 | | area | text | 421 | | barrier | text | 422 | | bicycle | text | 423 | | brand | text | 424 | | bridge | text | 425 | | boundary | text | 426 | | building | text | 427 | | capital | text | 428 | | construction | text | 429 | | covered | text | 430 | | culvert | text | 431 | | cutting | text | 432 | | denomination | text | 433 | | disused | text | 434 | | ele | text | 435 | | embankment | text | 436 | | foot | text | 437 | | generator:source | text | 438 | | harbour | text | 439 | | highway | text | 440 | | historic | text | 441 | | horse | text | 442 | | intermittent | text | 443 | | junction | text | 444 | | landuse | text | 445 | | layer | text | 446 | | leisure | text | 447 | | lock | text | 448 | | man_made | text | 449 | | military | text | 450 | | motorcar | text | 451 | | name | text | 452 | | natural | text | 453 | | office | text | 454 | | oneway | text | 455 | | operator | text | 456 | | place | text | 457 | | population | text | 458 | | power | text | 459 | | power_source | text | 460 | | public_transport | text | 461 | | railway | text | 462 | | ref | text | 463 | | religion | text | 464 | | route | text | 465 | | service | text | 466 | | shop | text | 467 | | sport | text | 468 | | surface | text | 469 | | toll | text | 470 | | tourism | text | 471 | | tower:type | text | 472 | | tunnel | text | 473 | | water | text | 474 | | waterway | text | 475 | | wetland | text | 476 | | width | text | 477 | | wood | text | 478 | | z_order | integer | 479 | | tags | hstore | 480 | | way | geometry(Point,4326) | 481 | Indexes: 482 | "planet_osm_point_osm_id_idx" btree (osm_id) 483 | "planet_osm_point_tags_idx" gin (tags) 484 | "planet_osm_point_way_geog_idx" gist (geography(way)) 485 | 486 | Table "public.planet_osm_polygon": 487 | | Column | Type | 488 | |--------------------+-------------------------| 489 | | osm_id | bigint | 490 | | access | text | 491 | | addr:housename | text | 492 | | addr:housenumber | text | 493 | | addr:interpolation | text | 494 | | admin_level | text | 495 | | aerialway | text | 496 | | aeroway | text | 497 | | amenity | text | 498 | | area | text | 499 | | barrier | text | 500 | | bicycle | text | 501 | | brand | text | 502 | | bridge | text | 503 | | boundary | text | 504 | | building | text | 505 | | construction | text | 506 | | covered | text | 507 | | culvert | text | 508 | | cutting | text | 509 | | denomination | text | 510 | | disused | text | 511 | | embankment | text | 512 | | foot | text | 513 | | generator:source | text | 514 | | harbour | text | 515 | | highway | text | 516 | | historic | text | 517 | | horse | text | 518 | | intermittent | text | 519 | | junction | text | 520 | | landuse | text | 521 | | layer | text | 522 | | leisure | text | 523 | | lock | text | 524 | | man_made | text | 525 | | military | text | 526 | | motorcar | text | 527 | | name | text | 528 | | natural | text | 529 | | office | text | 530 | | oneway | text | 531 | | operator | text | 532 | | place | text | 533 | | population | text | 534 | | power | text | 535 | | power_source | text | 536 | | public_transport | text | 537 | | railway | text | 538 | | ref | text | 539 | | religion | text | 540 | | route | text | 541 | | service | text | 542 | | shop | text | 543 | | sport | text | 544 | | surface | text | 545 | | toll | text | 546 | | tourism | text | 547 | | tower:type | text | 548 | | tracktype | text | 549 | | tunnel | text | 550 | | water | text | 551 | | waterway | text | 552 | | wetland | text | 553 | | width | text | 554 | | wood | text | 555 | | z_order | integer | 556 | | way_area | real | 557 | | tags | hstore | 558 | | way | geometry(Geometry,4326) | 559 | Indexes: 560 | "planet_osm_polygon_osm_id_idx" btree (osm_id) 561 | "planet_osm_polygon_tags_idx" gin (tags) 562 | "planet_osm_polygon_way_geog_idx" gist (geography(way)) 563 | 564 | Table "public.planet_osm_rels": 565 | | Column | Type | 566 | |---------+----------| 567 | | id | bigint | 568 | | way_off | smallint | 569 | | rel_off | smallint | 570 | | parts | bigint[] | 571 | | members | text[] | 572 | | tags | text[] | 573 | Indexes: 574 | "planet_osm_rels_pkey" PRIMARY KEY, btree (id) 575 | "planet_osm_rels_parts_idx" gin (parts) WITH (fastupdate=off) 576 | """ 577 | # Check if database connection is available 578 | if not ctx.request_context.lifespan_context.db_conn: 579 | return "Database connection is not available. Please check your PostgreSQL server." 580 | 581 | enforce_read_only = True 582 | max_rows = 100 583 | 584 | if enforce_read_only and not is_read_only_query(query): 585 | return "Error: Only read-only queries are allowed for security reasons." 586 | 587 | try: 588 | results, total_rows = await ctx.request_context.lifespan_context.db_conn.execute_query(query, max_rows=max_rows) 589 | 590 | if not results: 591 | return "Query executed successfully, but returned no results." 592 | 593 | # Format results as a table 594 | columns = list(results[0].keys()) 595 | rows = [[str(row.get(col, "")) for col in columns] for row in results] 596 | 597 | # Calculate column widths 598 | col_widths = [max(len(col), max([len(row[i]) for row in rows] + [0])) for i, col in enumerate(columns)] 599 | 600 | # Format header 601 | header = " | ".join(col.ljust(col_widths[i]) for i, col in enumerate(columns)) 602 | separator = "-+-".join("-" * width for width in col_widths) 603 | 604 | # Format rows 605 | formatted_rows = [ 606 | " | ".join(cell.ljust(col_widths[i]) for i, cell in enumerate(row)) for row in rows 607 | ] 608 | 609 | # Combine all parts 610 | table = f"{header}\n{separator}\n" + "\n".join(formatted_rows) 611 | 612 | # Add summary 613 | if total_rows > max_rows: 614 | table += f"\n\n(Showing {len(results)} of {total_rows} rows)" 615 | 616 | return table 617 | except Exception as e: 618 | return f"Error executing query: {str(e)}" 619 | 620 | 621 | # Map control tools 622 | @mcp.tool() 623 | async def set_map_view( 624 | ctx: Context, 625 | center: Optional[List[float]] = None, 626 | zoom: Optional[int] = None, 627 | bounds: Optional[List[List[float]]] = None 628 | ) -> str: 629 | """ 630 | Set the map view in the web interface. 631 | 632 | Args: 633 | center: [latitude, longitude] center point 634 | zoom: Zoom level (0-19) 635 | bounds: [[south, west], [north, east]] bounds to display 636 | 637 | Examples: 638 | - Set view to a specific location: `set_map_view(center=[37.7749, -122.4194], zoom=12)` 639 | - Set view to show a region: `set_map_view(bounds=[[37.7, -122.5], [37.8, -122.4]])` 640 | """ 641 | if not ctx.request_context.lifespan_context.flask_server: 642 | return "Map server is not available." 643 | 644 | # Validate parameters 645 | if center and (len(center) != 2 or not all(isinstance(c, (int, float)) for c in center)): 646 | return "Error: center must be a [latitude, longitude] pair of numbers." 647 | 648 | if zoom and (not isinstance(zoom, int) or zoom < 0 or zoom > 19): 649 | return "Error: zoom must be an integer between 0 and 19." 650 | 651 | if bounds: 652 | if (len(bounds) != 2 or len(bounds[0]) != 2 or len(bounds[1]) != 2 or 653 | not all(isinstance(c, (int, float)) for point in bounds for c in point)): 654 | return "Error: bounds must be [[south, west], [north, east]] coordinates." 655 | 656 | # At least one parameter must be provided 657 | if not center and zoom is None and not bounds: 658 | return "Error: at least one of center, zoom, or bounds must be provided." 659 | 660 | # Send the command to the map 661 | server = ctx.request_context.lifespan_context.flask_server 662 | server.set_view(bounds=bounds, center=center, zoom=zoom) 663 | 664 | # Generate success message 665 | message_parts = [] 666 | if bounds: 667 | message_parts.append(f"bounds={bounds}") 668 | if center: 669 | message_parts.append(f"center={center}") 670 | if zoom is not None: 671 | message_parts.append(f"zoom={zoom}") 672 | 673 | return f"Map view updated successfully: {', '.join(message_parts)}" 674 | 675 | @mcp.tool() 676 | async def set_map_title( 677 | ctx: Context, 678 | title: str, 679 | color: Optional[str] = None, 680 | font_size: Optional[str] = None, 681 | background_color: Optional[str] = None 682 | ) -> str: 683 | """ 684 | Set the title displayed at the bottom right of the map. 685 | 686 | Args: 687 | title: Text to display as the map title 688 | color: CSS color value for the text (e.g., "#0066cc", "red") 689 | font_size: CSS font size (e.g., "24px", "1.5em") 690 | background_color: CSS background color value (e.g., "rgba(255, 255, 255, 0.8)") 691 | 692 | Examples: 693 | - Set a basic title: `set_map_title("OpenStreetMap Viewer")` 694 | - Set a styled title: `set_map_title("San Francisco", color="#0066cc", font_size="28px")` 695 | """ 696 | if not ctx.request_context.lifespan_context.flask_server: 697 | return "Map server is not available." 698 | 699 | # Prepare options dictionary with only provided values 700 | options = {} 701 | if color: 702 | options["color"] = color 703 | if font_size: 704 | options["fontSize"] = font_size 705 | if background_color: 706 | options["backgroundColor"] = background_color 707 | 708 | # Send the command to the map 709 | server = ctx.request_context.lifespan_context.flask_server 710 | server.set_title(title, options) 711 | 712 | # Generate success message 713 | style_info = "" 714 | if options: 715 | style_parts = [] 716 | if color: 717 | style_parts.append(f"color: {color}") 718 | if font_size: 719 | style_parts.append(f"size: {font_size}") 720 | if background_color: 721 | style_parts.append(f"background: {background_color}") 722 | style_info = f" with {', '.join(style_parts)}" 723 | 724 | return f"Map title set to '{title}'{style_info}" 725 | 726 | @mcp.tool() 727 | async def add_map_marker( 728 | ctx: Context, 729 | coordinates: List[float], 730 | text: Optional[str] = None, 731 | title: Optional[str] = None, 732 | open_popup: bool = False 733 | ) -> str: 734 | """ 735 | Add a marker to the map at the specified coordinates. 736 | 737 | Args: 738 | coordinates: [latitude, longitude] location for the marker 739 | text: Text to display in a popup when the marker is clicked 740 | title: Tooltip text displayed on hover (optional) 741 | open_popup: Whether to automatically open the popup (default: False) 742 | 743 | Examples: 744 | - Add a simple marker: `add_map_marker([37.7749, -122.4194])` 745 | - Add a marker with popup: `add_map_marker([37.7749, -122.4194], text="San Francisco", open_popup=True)` 746 | """ 747 | if not ctx.request_context.lifespan_context.flask_server: 748 | return "Map server is not available." 749 | 750 | # Validate coordinates 751 | if len(coordinates) != 2 or not all(isinstance(c, (int, float)) for c in coordinates): 752 | return "Error: coordinates must be a [latitude, longitude] pair of numbers." 753 | 754 | # Prepare options 755 | options = {} 756 | if title: 757 | options["title"] = title 758 | options["openPopup"] = open_popup 759 | 760 | # Send the command to the map 761 | server = ctx.request_context.lifespan_context.flask_server 762 | server.show_marker(coordinates, text, options) 763 | 764 | # Generate success message 765 | details = [] 766 | if text: 767 | details.append(f"text: '{text}'") 768 | if title: 769 | details.append(f"title: '{title}'") 770 | details_str = f" with {', '.join(details)}" if details else "" 771 | 772 | return f"Marker added at coordinates [{coordinates[0]}, {coordinates[1]}]{details_str}" 773 | 774 | @mcp.tool() 775 | async def add_map_polygon( 776 | ctx: Context, 777 | coordinates: List[List[float]], 778 | color: Optional[str] = None, 779 | fill_color: Optional[str] = None, 780 | fill_opacity: Optional[float] = None, 781 | weight: Optional[int] = None, 782 | fit_bounds: bool = False 783 | ) -> str: 784 | """ 785 | Add a polygon to the map with the specified coordinates. 786 | 787 | If you're trying to add a polygon with more than 20 points, stop and use 788 | ST_Simplify to reduce the number of points. 789 | 790 | Args: 791 | coordinates: List of [latitude, longitude] points defining the polygon 792 | color: Border color (CSS color value) 793 | fill_color: Fill color (CSS color value) 794 | fill_opacity: Fill opacity (0.0 to 1.0) 795 | weight: Border width in pixels 796 | fit_bounds: Whether to zoom the map to show the entire polygon 797 | 798 | Examples: 799 | - Add a polygon: `add_map_polygon([[37.78, -122.41], [37.75, -122.41], [37.75, -122.45], [37.78, -122.45]])` 800 | - Add a styled polygon: `add_map_polygon([[37.78, -122.41], [37.75, -122.41], [37.75, -122.45]], color="red", fill_opacity=0.3)` 801 | """ 802 | if not ctx.request_context.lifespan_context.flask_server: 803 | return "Map server is not available." 804 | 805 | # Validate coordinates 806 | if not coordinates or not all(len(point) == 2 and all(isinstance(c, (int, float)) for c in point) for point in coordinates): 807 | return "Error: coordinates must be a list of [latitude, longitude] points." 808 | 809 | if len(coordinates) < 3: 810 | return "Error: a polygon requires at least 3 points." 811 | 812 | # Prepare options 813 | options = {} 814 | if color: 815 | options["color"] = color 816 | if fill_color: 817 | options["fillColor"] = fill_color 818 | if fill_opacity is not None: 819 | if not 0 <= fill_opacity <= 1: 820 | return "Error: fill_opacity must be between 0.0 and 1.0." 821 | options["fillOpacity"] = fill_opacity 822 | if weight is not None: 823 | if not isinstance(weight, int) or weight < 0: 824 | return "Error: weight must be a positive integer." 825 | options["weight"] = weight 826 | options["fitBounds"] = fit_bounds 827 | 828 | # Send the command to the map 829 | server = ctx.request_context.lifespan_context.flask_server 830 | server.show_polygon(coordinates, options) 831 | 832 | # Generate success message 833 | style_info = "" 834 | if any(key in options for key in ["color", "fillColor", "fillOpacity", "weight"]): 835 | style_parts = [] 836 | if color: 837 | style_parts.append(f"color: {color}") 838 | if fill_color: 839 | style_parts.append(f"fill: {fill_color}") 840 | if fill_opacity is not None: 841 | style_parts.append(f"opacity: {fill_opacity}") 842 | if weight is not None: 843 | style_parts.append(f"weight: {weight}") 844 | style_info = f" with {', '.join(style_parts)}" 845 | 846 | bounds_info = " (map zoomed to fit)" if fit_bounds else "" 847 | 848 | return f"Polygon added with {len(coordinates)} points{style_info}{bounds_info}" 849 | 850 | @mcp.tool() 851 | async def add_map_line( 852 | ctx: Context, 853 | coordinates: List[List[float]], 854 | color: Optional[str] = None, 855 | weight: Optional[int] = None, 856 | opacity: Optional[float] = None, 857 | dash_array: Optional[str] = None, 858 | fit_bounds: bool = False 859 | ) -> str: 860 | """ 861 | Add a line (polyline) to the map with the specified coordinates. 862 | 863 | If you're trying to add a line with more than 20 points, stop and use 864 | ST_Simplify to reduce the number of points. 865 | 866 | Args: 867 | coordinates: List of [latitude, longitude] points defining the line 868 | color: Line color (CSS color value) 869 | weight: Line width in pixels 870 | opacity: Line opacity (0.0 to 1.0) 871 | dash_array: SVG dash array pattern for creating dashed lines (e.g., "5,10") 872 | fit_bounds: Whether to zoom the map to show the entire line 873 | 874 | Examples: 875 | - Add a simple line: `add_map_line([[37.78, -122.41], [37.75, -122.41], [37.75, -122.45]])` 876 | - Add a styled line: `add_map_line([[37.78, -122.41], [37.75, -122.41]], color="blue", weight=3, dash_array="5,10")` 877 | """ 878 | if not ctx.request_context.lifespan_context.flask_server: 879 | return "Map server is not available." 880 | 881 | # Validate coordinates 882 | if not coordinates or not all(len(point) == 2 and all(isinstance(c, (int, float)) for c in point) for point in coordinates): 883 | return "Error: coordinates must be a list of [latitude, longitude] points." 884 | 885 | if len(coordinates) < 2: 886 | return "Error: a line requires at least 2 points." 887 | 888 | # Prepare options 889 | options = {} 890 | if color: 891 | options["color"] = color 892 | if weight is not None: 893 | if not isinstance(weight, int) or weight < 0: 894 | return "Error: weight must be a positive integer." 895 | options["weight"] = weight 896 | if opacity is not None: 897 | if not 0 <= opacity <= 1: 898 | return "Error: opacity must be between 0.0 and 1.0." 899 | options["opacity"] = opacity 900 | if dash_array: 901 | options["dashArray"] = dash_array 902 | options["fitBounds"] = fit_bounds 903 | 904 | # Send the command to the map 905 | server = ctx.request_context.lifespan_context.flask_server 906 | server.show_line(coordinates, options) 907 | 908 | # Generate success message 909 | style_info = "" 910 | if any(key in options for key in ["color", "weight", "opacity", "dashArray"]): 911 | style_parts = [] 912 | if color: 913 | style_parts.append(f"color: {color}") 914 | if weight is not None: 915 | style_parts.append(f"weight: {weight}") 916 | if opacity is not None: 917 | style_parts.append(f"opacity: {opacity}") 918 | if dash_array: 919 | style_parts.append(f"dash pattern: {dash_array}") 920 | style_info = f" with {', '.join(style_parts)}" 921 | 922 | bounds_info = " (map zoomed to fit)" if fit_bounds else "" 923 | 924 | return f"Line added with {len(coordinates)} points{style_info}{bounds_info}" 925 | 926 | @mcp.tool() 927 | async def get_map_view(ctx: Context) -> str: 928 | """ 929 | Get the current map view information including center coordinates, zoom 930 | level, and bounds. The user can pan and zoom the map at will, at any time, 931 | so if you ever need to know the current view, call this tool. 932 | 933 | Returns: 934 | JSON string containing the current map view information 935 | 936 | Examples: 937 | - Get current view: `get_map_view()` 938 | """ 939 | if not ctx.request_context.lifespan_context.flask_server: 940 | return "Map server is not available." 941 | 942 | # Get the current view from the map server 943 | server = ctx.request_context.lifespan_context.flask_server 944 | view_info = server.get_current_view() 945 | 946 | # Format the response 947 | response = { 948 | "center": view_info.get("center"), 949 | "zoom": view_info.get("zoom"), 950 | "bounds": view_info.get("bounds") 951 | } 952 | 953 | return json.dumps(response, indent=2) 954 | 955 | # @mcp.tool() 956 | # async def get_map_screenshot(ctx: Context) -> str: 957 | # """ 958 | # Capture a screenshot of the current map view and return it as a 959 | # base64-encoded image. 960 | 961 | # This function requests a screenshot from the map interface and returns it in 962 | # a format that can be displayed in the conversation. The screenshot shows the 963 | # exact current state of the map including all markers, polygons, lines, and 964 | # the current view. Don't use this tool to verify your actions, only use it if 965 | # the user asks for something like "What's this thing on the map?" 966 | 967 | # Returns: 968 | # A markdown string with the embedded image 969 | 970 | # Examples: 971 | # - Capture the current map view: `get_map_screenshot()` 972 | # """ 973 | # if not ctx.request_context.lifespan_context.flask_server: 974 | # return "Map server is not available." 975 | 976 | # # Get the Flask server instance 977 | # server = ctx.request_context.lifespan_context.flask_server 978 | 979 | # # Request a screenshot from the map 980 | # image_data = server.capture_screenshot() 981 | 982 | # if not image_data: 983 | # return "Failed to capture map screenshot. Make sure the map is visible in a browser." 984 | 985 | # # The image data already includes the data:image/png;base64, prefix 986 | # # Return as markdown image 987 | # return f"" 988 | 989 | @mcp.tool() 990 | async def geolocate(ctx: Context, name: str) -> str: 991 | """ 992 | Look up a location by name using the Nominatim geocoding service. 993 | This is the preferred way to look up a feature by name. 994 | 995 | Args: 996 | name: The name of the location to search for 997 | 998 | Returns: 999 | JSON string containing the Nominatim search results 1000 | 1001 | Examples: 1002 | - Find a city: `geolocate("San Francisco")` 1003 | - Find a landmark: `geolocate("Eiffel Tower")` 1004 | - Find a country: `geolocate("New Zealand")` 1005 | """ 1006 | if not ctx.request_context.lifespan_context.flask_server: 1007 | return "Map server is not available." 1008 | 1009 | # Get the Flask server instance 1010 | server = ctx.request_context.lifespan_context.flask_server 1011 | 1012 | # Send the geolocate request to the web client via the Flask server 1013 | results = server.geolocate(name) 1014 | 1015 | if results is None: 1016 | return "Geolocate request timed out or failed. Make sure the map is visible in a browser." 1017 | 1018 | if not results: 1019 | return f"No results found for '{name}'." 1020 | 1021 | # Format the results as JSON 1022 | return json.dumps(results, indent=2) 1023 | 1024 | def run_server(): 1025 | """Run the MCP server""" 1026 | mcp.run() 1027 | 1028 | 1029 | if __name__ == "__main__": 1030 | run_server() ```