geospatial-mcp-server 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- geospatial_mcp_server-0.1.0/.gitignore +15 -0
- geospatial_mcp_server-0.1.0/LICENSE +21 -0
- geospatial_mcp_server-0.1.0/PKG-INFO +95 -0
- geospatial_mcp_server-0.1.0/README.md +69 -0
- geospatial_mcp_server-0.1.0/pyproject.toml +42 -0
- geospatial_mcp_server-0.1.0/src/__init__.py +1 -0
- geospatial_mcp_server-0.1.0/src/clients/__init__.py +1 -0
- geospatial_mcp_server-0.1.0/src/clients/osm.py +182 -0
- geospatial_mcp_server-0.1.0/src/server.py +149 -0
- geospatial_mcp_server-0.1.0/src/tools/__init__.py +1 -0
- geospatial_mcp_server-0.1.0/src/tools/geo.py +430 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AiAgentKarl
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: geospatial-mcp-server
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Geospatial MCP Server — Geocoding, POI-Suche, Routing und Gebietsstatistiken via OpenStreetMap fuer AI Agents
|
|
5
|
+
Project-URL: Homepage, https://github.com/AiAgentKarl/geospatial-mcp-server
|
|
6
|
+
Project-URL: Repository, https://github.com/AiAgentKarl/geospatial-mcp-server
|
|
7
|
+
Project-URL: Issues, https://github.com/AiAgentKarl/geospatial-mcp-server/issues
|
|
8
|
+
Author: AiAgentKarl
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,geocoding,geospatial,mcp,nominatim,openstreetmap,osm,overpass
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: httpx>=0.27.0
|
|
23
|
+
Requires-Dist: mcp>=1.0.0
|
|
24
|
+
Requires-Dist: pydantic>=2.0.0
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# geospatial-mcp-server
|
|
28
|
+
|
|
29
|
+
<!-- mcp-name: geospatial-mcp-server -->
|
|
30
|
+
|
|
31
|
+
MCP Server for geospatial data — gives AI agents access to geocoding, reverse geocoding, POI search, routing info, and area statistics via OpenStreetMap.
|
|
32
|
+
|
|
33
|
+
**100% free, no API key required.** Uses OpenStreetMap Nominatim + Overpass API.
|
|
34
|
+
|
|
35
|
+
## Tools
|
|
36
|
+
|
|
37
|
+
| Tool | Description |
|
|
38
|
+
|------|-------------|
|
|
39
|
+
| `geocode` | Convert address/place name to coordinates |
|
|
40
|
+
| `reverse_geocode` | Convert coordinates to address |
|
|
41
|
+
| `search_nearby` | Find POIs nearby (restaurants, hospitals, schools, etc.) |
|
|
42
|
+
| `get_route_info` | Distance and bearing between two points |
|
|
43
|
+
| `get_area_stats` | Population, area, type info for a place |
|
|
44
|
+
| `find_boundaries` | Administrative boundaries of a place |
|
|
45
|
+
| `search_pois` | Search points of interest by keyword in an area |
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install geospatial-mcp-server
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage with Claude Desktop
|
|
54
|
+
|
|
55
|
+
Add to your `claude_desktop_config.json`:
|
|
56
|
+
|
|
57
|
+
```json
|
|
58
|
+
{
|
|
59
|
+
"mcpServers": {
|
|
60
|
+
"geospatial": {
|
|
61
|
+
"command": "geospatial-server"
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or with uvx (no install needed):
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{
|
|
71
|
+
"mcpServers": {
|
|
72
|
+
"geospatial": {
|
|
73
|
+
"command": "uvx",
|
|
74
|
+
"args": ["geospatial-mcp-server"]
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Examples
|
|
81
|
+
|
|
82
|
+
- "Where is the Eiffel Tower?" → `geocode("Eiffel Tower, Paris")`
|
|
83
|
+
- "What's at 48.8566, 2.3522?" → `reverse_geocode(48.8566, 2.3522)`
|
|
84
|
+
- "Find restaurants near me" → `search_nearby(48.8566, 2.3522, "restaurant")`
|
|
85
|
+
- "How far is Berlin from Munich?" → `get_route_info("Berlin", "Munich")`
|
|
86
|
+
- "Tell me about Tokyo" → `get_area_stats("Tokyo")`
|
|
87
|
+
|
|
88
|
+
## Data Sources
|
|
89
|
+
|
|
90
|
+
- [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) — Geocoding & reverse geocoding
|
|
91
|
+
- [Overpass API](https://overpass-api.de/) — POI search & spatial queries
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
MIT
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# geospatial-mcp-server
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: geospatial-mcp-server -->
|
|
4
|
+
|
|
5
|
+
MCP Server for geospatial data — gives AI agents access to geocoding, reverse geocoding, POI search, routing info, and area statistics via OpenStreetMap.
|
|
6
|
+
|
|
7
|
+
**100% free, no API key required.** Uses OpenStreetMap Nominatim + Overpass API.
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | Description |
|
|
12
|
+
|------|-------------|
|
|
13
|
+
| `geocode` | Convert address/place name to coordinates |
|
|
14
|
+
| `reverse_geocode` | Convert coordinates to address |
|
|
15
|
+
| `search_nearby` | Find POIs nearby (restaurants, hospitals, schools, etc.) |
|
|
16
|
+
| `get_route_info` | Distance and bearing between two points |
|
|
17
|
+
| `get_area_stats` | Population, area, type info for a place |
|
|
18
|
+
| `find_boundaries` | Administrative boundaries of a place |
|
|
19
|
+
| `search_pois` | Search points of interest by keyword in an area |
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install geospatial-mcp-server
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Usage with Claude Desktop
|
|
28
|
+
|
|
29
|
+
Add to your `claude_desktop_config.json`:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{
|
|
33
|
+
"mcpServers": {
|
|
34
|
+
"geospatial": {
|
|
35
|
+
"command": "geospatial-server"
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Or with uvx (no install needed):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{
|
|
45
|
+
"mcpServers": {
|
|
46
|
+
"geospatial": {
|
|
47
|
+
"command": "uvx",
|
|
48
|
+
"args": ["geospatial-mcp-server"]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
- "Where is the Eiffel Tower?" → `geocode("Eiffel Tower, Paris")`
|
|
57
|
+
- "What's at 48.8566, 2.3522?" → `reverse_geocode(48.8566, 2.3522)`
|
|
58
|
+
- "Find restaurants near me" → `search_nearby(48.8566, 2.3522, "restaurant")`
|
|
59
|
+
- "How far is Berlin from Munich?" → `get_route_info("Berlin", "Munich")`
|
|
60
|
+
- "Tell me about Tokyo" → `get_area_stats("Tokyo")`
|
|
61
|
+
|
|
62
|
+
## Data Sources
|
|
63
|
+
|
|
64
|
+
- [OpenStreetMap Nominatim](https://nominatim.openstreetmap.org/) — Geocoding & reverse geocoding
|
|
65
|
+
- [Overpass API](https://overpass-api.de/) — POI search & spatial queries
|
|
66
|
+
|
|
67
|
+
## License
|
|
68
|
+
|
|
69
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "geospatial-mcp-server"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Geospatial MCP Server — Geocoding, POI-Suche, Routing und Gebietsstatistiken via OpenStreetMap fuer AI Agents"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = {text = "MIT"}
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "AiAgentKarl"}
|
|
14
|
+
]
|
|
15
|
+
keywords = ["mcp", "geospatial", "openstreetmap", "geocoding", "osm", "nominatim", "overpass", "ai-agents"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 4 - Beta",
|
|
18
|
+
"Intended Audience :: Developers",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
26
|
+
]
|
|
27
|
+
dependencies = [
|
|
28
|
+
"mcp>=1.0.0",
|
|
29
|
+
"httpx>=0.27.0",
|
|
30
|
+
"pydantic>=2.0.0",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/AiAgentKarl/geospatial-mcp-server"
|
|
35
|
+
Repository = "https://github.com/AiAgentKarl/geospatial-mcp-server"
|
|
36
|
+
Issues = "https://github.com/AiAgentKarl/geospatial-mcp-server/issues"
|
|
37
|
+
|
|
38
|
+
[project.scripts]
|
|
39
|
+
geospatial-server = "src.server:main"
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.wheel]
|
|
42
|
+
packages = ["src"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Geospatial MCP Server — Geodaten fuer AI Agents
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# OSM API Clients
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
OpenStreetMap API Client — Nominatim + Overpass
|
|
3
|
+
Nutzt httpx fuer async HTTP-Anfragen.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# Nominatim verlangt einen User-Agent Header
|
|
11
|
+
USER_AGENT = "GeospatialMCPServer/0.1.0"
|
|
12
|
+
NOMINATIM_BASE = "https://nominatim.openstreetmap.org"
|
|
13
|
+
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
14
|
+
|
|
15
|
+
# Timeout fuer alle Anfragen (Overpass kann langsam sein)
|
|
16
|
+
TIMEOUT = httpx.Timeout(30.0, connect=10.0)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _get_headers() -> dict[str, str]:
|
|
20
|
+
"""Standard-Headers fuer Nominatim (User-Agent ist Pflicht)."""
|
|
21
|
+
return {
|
|
22
|
+
"User-Agent": USER_AGENT,
|
|
23
|
+
"Accept": "application/json",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def nominatim_search(query: str, limit: int = 5) -> list[dict[str, Any]]:
|
|
28
|
+
"""
|
|
29
|
+
Geocoding: Adresse/Ortsname -> Koordinaten.
|
|
30
|
+
Nutzt Nominatim /search Endpoint.
|
|
31
|
+
"""
|
|
32
|
+
params = {
|
|
33
|
+
"q": query,
|
|
34
|
+
"format": "json",
|
|
35
|
+
"limit": limit,
|
|
36
|
+
"addressdetails": 1,
|
|
37
|
+
"extratags": 1,
|
|
38
|
+
}
|
|
39
|
+
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
40
|
+
response = await client.get(
|
|
41
|
+
f"{NOMINATIM_BASE}/search",
|
|
42
|
+
params=params,
|
|
43
|
+
headers=_get_headers(),
|
|
44
|
+
)
|
|
45
|
+
response.raise_for_status()
|
|
46
|
+
return response.json()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def nominatim_reverse(lat: float, lon: float) -> dict[str, Any]:
|
|
50
|
+
"""
|
|
51
|
+
Reverse Geocoding: Koordinaten -> Adresse.
|
|
52
|
+
Nutzt Nominatim /reverse Endpoint.
|
|
53
|
+
"""
|
|
54
|
+
params = {
|
|
55
|
+
"lat": lat,
|
|
56
|
+
"lon": lon,
|
|
57
|
+
"format": "json",
|
|
58
|
+
"addressdetails": 1,
|
|
59
|
+
"extratags": 1,
|
|
60
|
+
}
|
|
61
|
+
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
62
|
+
response = await client.get(
|
|
63
|
+
f"{NOMINATIM_BASE}/reverse",
|
|
64
|
+
params=params,
|
|
65
|
+
headers=_get_headers(),
|
|
66
|
+
)
|
|
67
|
+
response.raise_for_status()
|
|
68
|
+
return response.json()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def nominatim_lookup(query: str) -> dict[str, Any] | None:
|
|
72
|
+
"""
|
|
73
|
+
Einzelnen Ort suchen und Details zurueckgeben.
|
|
74
|
+
Gibt das erste Ergebnis zurueck oder None.
|
|
75
|
+
"""
|
|
76
|
+
results = await nominatim_search(query, limit=1)
|
|
77
|
+
if results:
|
|
78
|
+
return results[0]
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def overpass_query(query: str) -> dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Overpass API Abfrage ausfuehren.
|
|
85
|
+
Erwartet eine vollstaendige Overpass QL Query.
|
|
86
|
+
"""
|
|
87
|
+
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
|
88
|
+
response = await client.post(
|
|
89
|
+
OVERPASS_URL,
|
|
90
|
+
data={"data": query},
|
|
91
|
+
headers={"User-Agent": USER_AGENT},
|
|
92
|
+
)
|
|
93
|
+
response.raise_for_status()
|
|
94
|
+
return response.json()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def overpass_nearby(
|
|
98
|
+
lat: float,
|
|
99
|
+
lon: float,
|
|
100
|
+
category: str,
|
|
101
|
+
radius_m: int = 1000,
|
|
102
|
+
) -> list[dict[str, Any]]:
|
|
103
|
+
"""
|
|
104
|
+
POIs in der Naehe suchen via Overpass.
|
|
105
|
+
Unterstuetzte Kategorien werden auf OSM-Tags gemappt.
|
|
106
|
+
"""
|
|
107
|
+
# Kategorie auf OSM-Tags mappen
|
|
108
|
+
tag_mapping = {
|
|
109
|
+
"restaurant": '"amenity"="restaurant"',
|
|
110
|
+
"cafe": '"amenity"="cafe"',
|
|
111
|
+
"bar": '"amenity"="bar"',
|
|
112
|
+
"fast_food": '"amenity"="fast_food"',
|
|
113
|
+
"hospital": '"amenity"="hospital"',
|
|
114
|
+
"pharmacy": '"amenity"="pharmacy"',
|
|
115
|
+
"doctor": '"amenity"="doctors"',
|
|
116
|
+
"school": '"amenity"="school"',
|
|
117
|
+
"university": '"amenity"="university"',
|
|
118
|
+
"kindergarten": '"amenity"="kindergarten"',
|
|
119
|
+
"bank": '"amenity"="bank"',
|
|
120
|
+
"atm": '"amenity"="atm"',
|
|
121
|
+
"fuel": '"amenity"="fuel"',
|
|
122
|
+
"parking": '"amenity"="parking"',
|
|
123
|
+
"supermarket": '"shop"="supermarket"',
|
|
124
|
+
"bakery": '"shop"="bakery"',
|
|
125
|
+
"hotel": '"tourism"="hotel"',
|
|
126
|
+
"museum": '"tourism"="museum"',
|
|
127
|
+
"park": '"leisure"="park"',
|
|
128
|
+
"playground": '"leisure"="playground"',
|
|
129
|
+
"police": '"amenity"="police"',
|
|
130
|
+
"fire_station": '"amenity"="fire_station"',
|
|
131
|
+
"post_office": '"amenity"="post_office"',
|
|
132
|
+
"library": '"amenity"="library"',
|
|
133
|
+
"cinema": '"amenity"="cinema"',
|
|
134
|
+
"theatre": '"amenity"="theatre"',
|
|
135
|
+
"gym": '"leisure"="fitness_centre"',
|
|
136
|
+
"swimming_pool": '"leisure"="swimming_pool"',
|
|
137
|
+
"bus_stop": '"highway"="bus_stop"',
|
|
138
|
+
"train_station": '"railway"="station"',
|
|
139
|
+
"subway": '"railway"="subway_entrance"',
|
|
140
|
+
"charging_station": '"amenity"="charging_station"',
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
# Wenn Kategorie bekannt, nutze spezifischen Tag
|
|
144
|
+
osm_filter = tag_mapping.get(category.lower())
|
|
145
|
+
if not osm_filter:
|
|
146
|
+
# Fallback: Suche nach Name oder beliebigem Amenity-Tag
|
|
147
|
+
osm_filter = f'"amenity"="{category}"'
|
|
148
|
+
|
|
149
|
+
query = f"""
|
|
150
|
+
[out:json][timeout:25];
|
|
151
|
+
(
|
|
152
|
+
node[{osm_filter}](around:{radius_m},{lat},{lon});
|
|
153
|
+
way[{osm_filter}](around:{radius_m},{lat},{lon});
|
|
154
|
+
);
|
|
155
|
+
out center body;
|
|
156
|
+
"""
|
|
157
|
+
|
|
158
|
+
result = await overpass_query(query)
|
|
159
|
+
return result.get("elements", [])
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def overpass_search_pois(
|
|
163
|
+
keyword: str,
|
|
164
|
+
lat: float,
|
|
165
|
+
lon: float,
|
|
166
|
+
radius_m: int = 5000,
|
|
167
|
+
) -> list[dict[str, Any]]:
|
|
168
|
+
"""
|
|
169
|
+
POIs nach Freitext-Keyword suchen via Overpass.
|
|
170
|
+
Sucht in name-Tags innerhalb des Radius.
|
|
171
|
+
"""
|
|
172
|
+
query = f"""
|
|
173
|
+
[out:json][timeout:25];
|
|
174
|
+
(
|
|
175
|
+
node["name"~"{keyword}",i](around:{radius_m},{lat},{lon});
|
|
176
|
+
way["name"~"{keyword}",i](around:{radius_m},{lat},{lon});
|
|
177
|
+
);
|
|
178
|
+
out center body;
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
result = await overpass_query(query)
|
|
182
|
+
return result.get("elements", [])
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geospatial MCP Server — Geodaten fuer AI Agents.
|
|
3
|
+
Geocoding, POI-Suche, Routing und Gebietsstatistiken via OpenStreetMap.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from mcp.server.fastmcp import FastMCP
|
|
7
|
+
|
|
8
|
+
from src.tools.geo import (
|
|
9
|
+
geocode,
|
|
10
|
+
reverse_geocode,
|
|
11
|
+
search_nearby,
|
|
12
|
+
get_route_info,
|
|
13
|
+
get_area_stats,
|
|
14
|
+
find_boundaries,
|
|
15
|
+
search_pois,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# FastMCP Server erstellen
|
|
19
|
+
mcp = FastMCP(
|
|
20
|
+
"geospatial-mcp-server",
|
|
21
|
+
instructions=(
|
|
22
|
+
"Geospatial data server for AI agents. "
|
|
23
|
+
"Provides geocoding, reverse geocoding, nearby POI search, "
|
|
24
|
+
"distance calculation, area statistics, boundary lookup, "
|
|
25
|
+
"and keyword-based POI search. "
|
|
26
|
+
"All data comes from OpenStreetMap (Nominatim + Overpass API). "
|
|
27
|
+
"No API key required."
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# --- Tools registrieren ---
|
|
33
|
+
|
|
34
|
+
@mcp.tool()
|
|
35
|
+
async def geocode_tool(query: str) -> dict:
|
|
36
|
+
"""Convert an address or place name to geographic coordinates.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
query: Address or place name (e.g. "Eiffel Tower, Paris", "1600 Pennsylvania Ave, Washington DC")
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Coordinates, address details and result type
|
|
43
|
+
"""
|
|
44
|
+
return await geocode(query)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@mcp.tool()
|
|
48
|
+
async def reverse_geocode_tool(lat: float, lon: float) -> dict:
|
|
49
|
+
"""Convert geographic coordinates to an address.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
lat: Latitude (e.g. 48.8566)
|
|
53
|
+
lon: Longitude (e.g. 2.3522)
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Address and location details
|
|
57
|
+
"""
|
|
58
|
+
return await reverse_geocode(lat, lon)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@mcp.tool()
|
|
62
|
+
async def search_nearby_tool(
|
|
63
|
+
lat: float,
|
|
64
|
+
lon: float,
|
|
65
|
+
category: str,
|
|
66
|
+
radius_m: int = 1000,
|
|
67
|
+
) -> dict:
|
|
68
|
+
"""Find points of interest nearby (restaurants, hospitals, schools, etc.).
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
lat: Center latitude
|
|
72
|
+
lon: Center longitude
|
|
73
|
+
category: POI category — one of: restaurant, cafe, bar, fast_food, hospital, pharmacy, doctor, school, university, kindergarten, bank, atm, fuel, parking, supermarket, bakery, hotel, museum, park, playground, police, fire_station, post_office, library, cinema, theatre, gym, swimming_pool, bus_stop, train_station, subway, charging_station
|
|
74
|
+
radius_m: Search radius in meters (default: 1000, max: 10000)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of nearby POIs with name, coordinates, distance
|
|
78
|
+
"""
|
|
79
|
+
return await search_nearby(lat, lon, category, radius_m)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool()
|
|
83
|
+
async def get_route_info_tool(origin: str, destination: str) -> dict:
|
|
84
|
+
"""Calculate distance and direction between two places (straight line).
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
origin: Starting place (e.g. "Berlin")
|
|
88
|
+
destination: Target place (e.g. "Munich")
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
Distance in km/miles, bearing, direction, coordinates of both points
|
|
92
|
+
"""
|
|
93
|
+
return await get_route_info(origin, destination)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@mcp.tool()
|
|
97
|
+
async def get_area_stats_tool(place_name: str) -> dict:
|
|
98
|
+
"""Get statistics about a place (population, area, type).
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
place_name: Name of the place (e.g. "Berlin", "Tokyo", "New York")
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Population, area, administrative type, Wikipedia link and more
|
|
105
|
+
"""
|
|
106
|
+
return await get_area_stats(place_name)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool()
|
|
110
|
+
async def find_boundaries_tool(place_name: str) -> dict:
|
|
111
|
+
"""Find administrative boundaries of a place.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
place_name: Name of the place (e.g. "Bavaria", "California", "Paris")
|
|
115
|
+
|
|
116
|
+
Returns:
|
|
117
|
+
Boundary info with bounding box and administrative hierarchy
|
|
118
|
+
"""
|
|
119
|
+
return await find_boundaries(place_name)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
async def search_pois_tool(
|
|
124
|
+
query: str,
|
|
125
|
+
lat: float,
|
|
126
|
+
lon: float,
|
|
127
|
+
radius_m: int = 5000,
|
|
128
|
+
) -> dict:
|
|
129
|
+
"""Search points of interest by keyword in an area.
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
query: Search keyword (e.g. "Starbucks", "Museum", "Train Station")
|
|
133
|
+
lat: Center latitude
|
|
134
|
+
lon: Center longitude
|
|
135
|
+
radius_m: Search radius in meters (default: 5000, max: 25000)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
List of matching POIs with name, coordinates, distance
|
|
139
|
+
"""
|
|
140
|
+
return await search_pois(query, lat, lon, radius_m)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def main():
|
|
144
|
+
"""Server starten."""
|
|
145
|
+
mcp.run(transport="stdio")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Geospatial Tool-Definitionen
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Geospatial MCP Tools — 7 Tools fuer Geodaten.
|
|
3
|
+
Nutzt OpenStreetMap Nominatim + Overpass API.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import math
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from src.clients.osm import (
|
|
10
|
+
nominatim_search,
|
|
11
|
+
nominatim_reverse,
|
|
12
|
+
nominatim_lookup,
|
|
13
|
+
overpass_nearby,
|
|
14
|
+
overpass_search_pois,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
19
|
+
"""
|
|
20
|
+
Haversine-Formel: Berechnet Entfernung zwischen zwei Koordinaten in km.
|
|
21
|
+
"""
|
|
22
|
+
R = 6371.0 # Erdradius in km
|
|
23
|
+
|
|
24
|
+
lat1_r = math.radians(lat1)
|
|
25
|
+
lat2_r = math.radians(lat2)
|
|
26
|
+
dlat = math.radians(lat2 - lat1)
|
|
27
|
+
dlon = math.radians(lon2 - lon1)
|
|
28
|
+
|
|
29
|
+
a = math.sin(dlat / 2) ** 2 + math.cos(lat1_r) * math.cos(lat2_r) * math.sin(dlon / 2) ** 2
|
|
30
|
+
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
|
31
|
+
|
|
32
|
+
return R * c
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _bearing(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
|
36
|
+
"""Berechnet die Peilung (Bearing) in Grad von Punkt 1 zu Punkt 2."""
|
|
37
|
+
lat1_r = math.radians(lat1)
|
|
38
|
+
lat2_r = math.radians(lat2)
|
|
39
|
+
dlon = math.radians(lon2 - lon1)
|
|
40
|
+
|
|
41
|
+
x = math.sin(dlon) * math.cos(lat2_r)
|
|
42
|
+
y = math.cos(lat1_r) * math.sin(lat2_r) - math.sin(lat1_r) * math.cos(lat2_r) * math.cos(dlon)
|
|
43
|
+
|
|
44
|
+
bearing_rad = math.atan2(x, y)
|
|
45
|
+
return (math.degrees(bearing_rad) + 360) % 360
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _bearing_to_direction(bearing: float) -> str:
|
|
49
|
+
"""Wandelt Bearing in Himmelsrichtung um."""
|
|
50
|
+
directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
|
|
51
|
+
index = round(bearing / 45) % 8
|
|
52
|
+
return directions[index]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _format_element(element: dict[str, Any]) -> dict[str, Any]:
|
|
56
|
+
"""Formatiert ein Overpass-Element fuer die Ausgabe."""
|
|
57
|
+
tags = element.get("tags", {})
|
|
58
|
+
# Koordinaten: Bei Way-Elementen center nutzen
|
|
59
|
+
lat = element.get("lat") or element.get("center", {}).get("lat")
|
|
60
|
+
lon = element.get("lon") or element.get("center", {}).get("lon")
|
|
61
|
+
|
|
62
|
+
result = {
|
|
63
|
+
"name": tags.get("name", "Unbekannt"),
|
|
64
|
+
"type": element.get("type", "unknown"),
|
|
65
|
+
"lat": lat,
|
|
66
|
+
"lon": lon,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Nuetzliche Tags hinzufuegen wenn vorhanden
|
|
70
|
+
useful_tags = [
|
|
71
|
+
"addr:street", "addr:housenumber", "addr:city", "addr:postcode",
|
|
72
|
+
"phone", "website", "opening_hours", "cuisine", "brand",
|
|
73
|
+
"wheelchair", "internet_access",
|
|
74
|
+
]
|
|
75
|
+
for tag in useful_tags:
|
|
76
|
+
if tag in tags:
|
|
77
|
+
result[tag.replace(":", "_")] = tags[tag]
|
|
78
|
+
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def geocode(query: str) -> dict[str, Any]:
|
|
83
|
+
"""
|
|
84
|
+
Adresse oder Ortsname in Koordinaten umwandeln.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
query: Adresse oder Ortsname (z.B. "Brandenburger Tor, Berlin")
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
Koordinaten, Adressdetails und Typ des Ergebnisses
|
|
91
|
+
"""
|
|
92
|
+
results = await nominatim_search(query, limit=5)
|
|
93
|
+
|
|
94
|
+
if not results:
|
|
95
|
+
return {"error": f"Keine Ergebnisse fuer '{query}' gefunden."}
|
|
96
|
+
|
|
97
|
+
formatted = []
|
|
98
|
+
for r in results:
|
|
99
|
+
entry = {
|
|
100
|
+
"name": r.get("display_name", ""),
|
|
101
|
+
"lat": float(r.get("lat", 0)),
|
|
102
|
+
"lon": float(r.get("lon", 0)),
|
|
103
|
+
"type": r.get("type", "unknown"),
|
|
104
|
+
"class": r.get("class", "unknown"),
|
|
105
|
+
"importance": round(float(r.get("importance", 0)), 4),
|
|
106
|
+
}
|
|
107
|
+
# Adressdetails hinzufuegen
|
|
108
|
+
address = r.get("address", {})
|
|
109
|
+
if address:
|
|
110
|
+
entry["address"] = {
|
|
111
|
+
k: v for k, v in address.items()
|
|
112
|
+
if k not in ("country_code",)
|
|
113
|
+
}
|
|
114
|
+
formatted.append(entry)
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
"query": query,
|
|
118
|
+
"results_count": len(formatted),
|
|
119
|
+
"results": formatted,
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def reverse_geocode(lat: float, lon: float) -> dict[str, Any]:
|
|
124
|
+
"""
|
|
125
|
+
Koordinaten in Adresse umwandeln.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
lat: Breitengrad (z.B. 48.8566)
|
|
129
|
+
lon: Laengengrad (z.B. 2.3522)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Adresse und Details zum Standort
|
|
133
|
+
"""
|
|
134
|
+
try:
|
|
135
|
+
result = await nominatim_reverse(lat, lon)
|
|
136
|
+
except Exception as e:
|
|
137
|
+
return {"error": f"Reverse Geocoding fehlgeschlagen: {str(e)}"}
|
|
138
|
+
|
|
139
|
+
if "error" in result:
|
|
140
|
+
return {"error": result["error"]}
|
|
141
|
+
|
|
142
|
+
address = result.get("address", {})
|
|
143
|
+
return {
|
|
144
|
+
"lat": lat,
|
|
145
|
+
"lon": lon,
|
|
146
|
+
"display_name": result.get("display_name", ""),
|
|
147
|
+
"type": result.get("type", "unknown"),
|
|
148
|
+
"class": result.get("class", "unknown"),
|
|
149
|
+
"address": {
|
|
150
|
+
k: v for k, v in address.items()
|
|
151
|
+
if k not in ("country_code",)
|
|
152
|
+
},
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def search_nearby(
|
|
157
|
+
lat: float,
|
|
158
|
+
lon: float,
|
|
159
|
+
category: str,
|
|
160
|
+
radius_m: int = 1000,
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
"""
|
|
163
|
+
POIs in der Naehe suchen (Restaurants, Krankenhaeuser, Schulen, etc.).
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
lat: Breitengrad des Mittelpunkts
|
|
167
|
+
lon: Laengengrad des Mittelpunkts
|
|
168
|
+
category: Kategorie (restaurant, hospital, school, pharmacy, supermarket, hotel, park, bank, fuel, etc.)
|
|
169
|
+
radius_m: Suchradius in Metern (Standard: 1000, Max: 10000)
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
Liste der gefundenen POIs mit Name, Koordinaten und Details
|
|
173
|
+
"""
|
|
174
|
+
# Radius begrenzen
|
|
175
|
+
radius_m = min(max(radius_m, 100), 10000)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
elements = await overpass_nearby(lat, lon, category, radius_m)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
return {"error": f"Overpass-Abfrage fehlgeschlagen: {str(e)}"}
|
|
181
|
+
|
|
182
|
+
# Ergebnisse formatieren und Entfernung berechnen
|
|
183
|
+
pois = []
|
|
184
|
+
for el in elements:
|
|
185
|
+
formatted = _format_element(el)
|
|
186
|
+
if formatted["lat"] and formatted["lon"]:
|
|
187
|
+
formatted["distance_m"] = round(
|
|
188
|
+
_haversine(lat, lon, formatted["lat"], formatted["lon"]) * 1000
|
|
189
|
+
)
|
|
190
|
+
pois.append(formatted)
|
|
191
|
+
|
|
192
|
+
# Nach Entfernung sortieren
|
|
193
|
+
pois.sort(key=lambda x: x.get("distance_m", 99999))
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
"center": {"lat": lat, "lon": lon},
|
|
197
|
+
"category": category,
|
|
198
|
+
"radius_m": radius_m,
|
|
199
|
+
"results_count": len(pois),
|
|
200
|
+
"results": pois[:50], # Max 50 Ergebnisse
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
async def get_route_info(origin: str, destination: str) -> dict[str, Any]:
|
|
205
|
+
"""
|
|
206
|
+
Entfernung und Richtung zwischen zwei Orten berechnen.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
origin: Startort (z.B. "Berlin")
|
|
210
|
+
destination: Zielort (z.B. "Muenchen")
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Luftlinie-Entfernung in km, Richtung und Koordinaten beider Punkte
|
|
214
|
+
"""
|
|
215
|
+
# Beide Orte geocoden
|
|
216
|
+
origin_result = await nominatim_lookup(origin)
|
|
217
|
+
if not origin_result:
|
|
218
|
+
return {"error": f"Startort '{origin}' nicht gefunden."}
|
|
219
|
+
|
|
220
|
+
dest_result = await nominatim_lookup(destination)
|
|
221
|
+
if not dest_result:
|
|
222
|
+
return {"error": f"Zielort '{destination}' nicht gefunden."}
|
|
223
|
+
|
|
224
|
+
lat1 = float(origin_result["lat"])
|
|
225
|
+
lon1 = float(origin_result["lon"])
|
|
226
|
+
lat2 = float(dest_result["lat"])
|
|
227
|
+
lon2 = float(dest_result["lon"])
|
|
228
|
+
|
|
229
|
+
distance_km = _haversine(lat1, lon1, lat2, lon2)
|
|
230
|
+
bearing_deg = _bearing(lat1, lon1, lat2, lon2)
|
|
231
|
+
direction = _bearing_to_direction(bearing_deg)
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
"origin": {
|
|
235
|
+
"name": origin_result.get("display_name", origin),
|
|
236
|
+
"lat": lat1,
|
|
237
|
+
"lon": lon1,
|
|
238
|
+
},
|
|
239
|
+
"destination": {
|
|
240
|
+
"name": dest_result.get("display_name", destination),
|
|
241
|
+
"lat": lat2,
|
|
242
|
+
"lon": lon2,
|
|
243
|
+
},
|
|
244
|
+
"distance_km": round(distance_km, 2),
|
|
245
|
+
"distance_miles": round(distance_km * 0.621371, 2),
|
|
246
|
+
"bearing_degrees": round(bearing_deg, 1),
|
|
247
|
+
"direction": direction,
|
|
248
|
+
"note": "Luftlinie (Haversine). Tatsaechliche Strecke kann laenger sein.",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def get_area_stats(place_name: str) -> dict[str, Any]:
|
|
253
|
+
"""
|
|
254
|
+
Statistiken ueber einen Ort abrufen (Bevoelkerung, Flaeche, Typ).
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
place_name: Name des Ortes (z.B. "Berlin", "Tokyo", "New York")
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
Bevoelkerung, Flaeche, Verwaltungstyp und weitere Infos
|
|
261
|
+
"""
|
|
262
|
+
result = await nominatim_lookup(place_name)
|
|
263
|
+
if not result:
|
|
264
|
+
return {"error": f"Ort '{place_name}' nicht gefunden."}
|
|
265
|
+
|
|
266
|
+
address = result.get("address", {})
|
|
267
|
+
extratags = result.get("extratags", {})
|
|
268
|
+
|
|
269
|
+
stats: dict[str, Any] = {
|
|
270
|
+
"name": result.get("display_name", place_name),
|
|
271
|
+
"lat": float(result.get("lat", 0)),
|
|
272
|
+
"lon": float(result.get("lon", 0)),
|
|
273
|
+
"type": result.get("type", "unknown"),
|
|
274
|
+
"class": result.get("class", "unknown"),
|
|
275
|
+
"importance": round(float(result.get("importance", 0)), 4),
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
# Bounding Box wenn vorhanden
|
|
279
|
+
bbox = result.get("boundingbox")
|
|
280
|
+
if bbox and len(bbox) == 4:
|
|
281
|
+
stats["bounding_box"] = {
|
|
282
|
+
"south": float(bbox[0]),
|
|
283
|
+
"north": float(bbox[1]),
|
|
284
|
+
"west": float(bbox[2]),
|
|
285
|
+
"east": float(bbox[3]),
|
|
286
|
+
}
|
|
287
|
+
# Ungefaehre Flaeche aus Bounding Box berechnen
|
|
288
|
+
height_km = _haversine(float(bbox[0]), float(bbox[2]), float(bbox[1]), float(bbox[2]))
|
|
289
|
+
width_km = _haversine(float(bbox[0]), float(bbox[2]), float(bbox[0]), float(bbox[3]))
|
|
290
|
+
stats["approx_area_km2"] = round(height_km * width_km, 2)
|
|
291
|
+
|
|
292
|
+
# Extratags auswerten (Population, Wikipedia, etc.)
|
|
293
|
+
if extratags:
|
|
294
|
+
if "population" in extratags:
|
|
295
|
+
try:
|
|
296
|
+
stats["population"] = int(extratags["population"])
|
|
297
|
+
except (ValueError, TypeError):
|
|
298
|
+
stats["population"] = extratags["population"]
|
|
299
|
+
if "wikipedia" in extratags:
|
|
300
|
+
stats["wikipedia"] = extratags["wikipedia"]
|
|
301
|
+
if "wikidata" in extratags:
|
|
302
|
+
stats["wikidata"] = extratags["wikidata"]
|
|
303
|
+
if "website" in extratags:
|
|
304
|
+
stats["website"] = extratags["website"]
|
|
305
|
+
if "capital" in extratags:
|
|
306
|
+
stats["capital"] = extratags["capital"]
|
|
307
|
+
if "timezone" in extratags:
|
|
308
|
+
stats["timezone"] = extratags["timezone"]
|
|
309
|
+
|
|
310
|
+
# Adress-Hierarchie
|
|
311
|
+
if address:
|
|
312
|
+
stats["address"] = {
|
|
313
|
+
k: v for k, v in address.items()
|
|
314
|
+
if k not in ("country_code",)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return stats
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
async def find_boundaries(place_name: str) -> dict[str, Any]:
|
|
321
|
+
"""
|
|
322
|
+
Administrative Grenzen eines Ortes finden.
|
|
323
|
+
|
|
324
|
+
Args:
|
|
325
|
+
place_name: Name des Ortes (z.B. "Bayern", "California", "Paris")
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
Verwaltungsgrenzen mit Bounding Box und Hierarchie
|
|
329
|
+
"""
|
|
330
|
+
# Nominatim-Suche mit Polygon-Bounding-Box
|
|
331
|
+
results = await nominatim_search(place_name, limit=3)
|
|
332
|
+
|
|
333
|
+
if not results:
|
|
334
|
+
return {"error": f"Keine Grenzen fuer '{place_name}' gefunden."}
|
|
335
|
+
|
|
336
|
+
# Beste Ergebnisse filtern (bevorzugt administrative Grenzen)
|
|
337
|
+
boundaries = []
|
|
338
|
+
for r in results:
|
|
339
|
+
entry: dict[str, Any] = {
|
|
340
|
+
"name": r.get("display_name", ""),
|
|
341
|
+
"type": r.get("type", "unknown"),
|
|
342
|
+
"class": r.get("class", "unknown"),
|
|
343
|
+
"osm_type": r.get("osm_type", ""),
|
|
344
|
+
"osm_id": r.get("osm_id", ""),
|
|
345
|
+
"lat": float(r.get("lat", 0)),
|
|
346
|
+
"lon": float(r.get("lon", 0)),
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
# Bounding Box
|
|
350
|
+
bbox = r.get("boundingbox")
|
|
351
|
+
if bbox and len(bbox) == 4:
|
|
352
|
+
entry["bounding_box"] = {
|
|
353
|
+
"south": float(bbox[0]),
|
|
354
|
+
"north": float(bbox[1]),
|
|
355
|
+
"west": float(bbox[2]),
|
|
356
|
+
"east": float(bbox[3]),
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
# Adress-Hierarchie zeigt administrative Ebenen
|
|
360
|
+
address = r.get("address", {})
|
|
361
|
+
if address:
|
|
362
|
+
entry["admin_hierarchy"] = {
|
|
363
|
+
k: v for k, v in address.items()
|
|
364
|
+
if k not in ("country_code",)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
# Extratags fuer weitere Details
|
|
368
|
+
extratags = r.get("extratags", {})
|
|
369
|
+
if extratags:
|
|
370
|
+
admin_level = extratags.get("admin_level") or extratags.get("linked_place")
|
|
371
|
+
if admin_level:
|
|
372
|
+
entry["admin_level"] = admin_level
|
|
373
|
+
if "border_type" in extratags:
|
|
374
|
+
entry["border_type"] = extratags["border_type"]
|
|
375
|
+
|
|
376
|
+
boundaries.append(entry)
|
|
377
|
+
|
|
378
|
+
return {
|
|
379
|
+
"query": place_name,
|
|
380
|
+
"results_count": len(boundaries),
|
|
381
|
+
"boundaries": boundaries,
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
async def search_pois(
|
|
386
|
+
query: str,
|
|
387
|
+
lat: float,
|
|
388
|
+
lon: float,
|
|
389
|
+
radius_m: int = 5000,
|
|
390
|
+
) -> dict[str, Any]:
|
|
391
|
+
"""
|
|
392
|
+
Points of Interest nach Freitext-Keyword in einem Gebiet suchen.
|
|
393
|
+
|
|
394
|
+
Args:
|
|
395
|
+
query: Suchbegriff (z.B. "Starbucks", "Museum", "Bahnhof")
|
|
396
|
+
lat: Breitengrad des Mittelpunkts
|
|
397
|
+
lon: Laengengrad des Mittelpunkts
|
|
398
|
+
radius_m: Suchradius in Metern (Standard: 5000, Max: 25000)
|
|
399
|
+
|
|
400
|
+
Returns:
|
|
401
|
+
Liste der gefundenen POIs mit Name, Koordinaten und Entfernung
|
|
402
|
+
"""
|
|
403
|
+
# Radius begrenzen
|
|
404
|
+
radius_m = min(max(radius_m, 100), 25000)
|
|
405
|
+
|
|
406
|
+
try:
|
|
407
|
+
elements = await overpass_search_pois(query, lat, lon, radius_m)
|
|
408
|
+
except Exception as e:
|
|
409
|
+
return {"error": f"POI-Suche fehlgeschlagen: {str(e)}"}
|
|
410
|
+
|
|
411
|
+
# Ergebnisse formatieren
|
|
412
|
+
pois = []
|
|
413
|
+
for el in elements:
|
|
414
|
+
formatted = _format_element(el)
|
|
415
|
+
if formatted["lat"] and formatted["lon"]:
|
|
416
|
+
formatted["distance_m"] = round(
|
|
417
|
+
_haversine(lat, lon, formatted["lat"], formatted["lon"]) * 1000
|
|
418
|
+
)
|
|
419
|
+
pois.append(formatted)
|
|
420
|
+
|
|
421
|
+
# Nach Entfernung sortieren
|
|
422
|
+
pois.sort(key=lambda x: x.get("distance_m", 99999))
|
|
423
|
+
|
|
424
|
+
return {
|
|
425
|
+
"query": query,
|
|
426
|
+
"center": {"lat": lat, "lon": lon},
|
|
427
|
+
"radius_m": radius_m,
|
|
428
|
+
"results_count": len(pois),
|
|
429
|
+
"results": pois[:50], # Max 50 Ergebnisse
|
|
430
|
+
}
|