#
tokens: 24296/50000 13/13 files
lines: on (toggle) GitHub
raw markdown copy reset
# 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 | [![MCP-OSM Screenshot](osm-mcp-s.webp)](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: '&copy; <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"![Map Screenshot]({image_data})"
 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() 
```