openplz-mcp 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.
- openplz_mcp-0.1.0/.github/workflows/publish.yml +20 -0
- openplz_mcp-0.1.0/.github/workflows/registry.yml +95 -0
- openplz_mcp-0.1.0/.github/workflows/smoke.yml +21 -0
- openplz_mcp-0.1.0/LICENSE +21 -0
- openplz_mcp-0.1.0/PKG-INFO +66 -0
- openplz_mcp-0.1.0/README-de.md +54 -0
- openplz_mcp-0.1.0/README-fa.md +54 -0
- openplz_mcp-0.1.0/README.md +54 -0
- openplz_mcp-0.1.0/pyproject.toml +22 -0
- openplz_mcp-0.1.0/server.json +1 -0
- openplz_mcp-0.1.0/src/openplz_mcp/__init__.py +1 -0
- openplz_mcp-0.1.0/src/openplz_mcp/domain.py +224 -0
- openplz_mcp-0.1.0/src/openplz_mcp/http.py +112 -0
- openplz_mcp-0.1.0/src/openplz_mcp/server.py +171 -0
- openplz_mcp-0.1.0/tests/__init__.py +0 -0
- openplz_mcp-0.1.0/tests/fixtures/districts-berlin.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/federal-states.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/fulltext-10115-berlin.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/government-regions-bayern.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/localities-10115.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/localities-berlin-state.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/localities-muenchen.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/municipalities-bayern.json +1 -0
- openplz_mcp-0.1.0/tests/fixtures/streets-ackerstr-10115.json +1 -0
- openplz_mcp-0.1.0/tests/smoke_live.py +17 -0
- openplz_mcp-0.1.0/tests/test_domain.py +187 -0
- openplz_mcp-0.1.0/tests/test_live.py +100 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
publish:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
permissions:
|
|
11
|
+
id-token: write # required for PyPI trusted publishing (OIDC)
|
|
12
|
+
contents: read
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- run: pip install build
|
|
19
|
+
- run: python -m build
|
|
20
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
name: Publish to MCP Registry
|
|
2
|
+
|
|
3
|
+
# Official MCP Registry (registry.modelcontextprotocol.io) via GitHub OIDC.
|
|
4
|
+
# No secret, no interactive login: the runner's OIDC token proves ownership
|
|
5
|
+
# of the io.github.mohammad-abbasi-sh/* namespace.
|
|
6
|
+
# Refs: https://github.com/modelcontextprotocol/registry
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
workflow_dispatch:
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
publish:
|
|
15
|
+
runs-on: ubuntu-latest
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write # required for OIDC -> MCP Registry
|
|
18
|
+
contents: read
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@v4
|
|
21
|
+
|
|
22
|
+
- name: Sync server.json version to tag
|
|
23
|
+
# Only sync when running on a version tag (v*). On workflow_dispatch
|
|
24
|
+
# (ref = refs/heads/main) keep the version committed in server.json.
|
|
25
|
+
run: |
|
|
26
|
+
VER=""
|
|
27
|
+
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
|
|
28
|
+
TAG="${GITHUB_REF#refs/tags/}"
|
|
29
|
+
VER="${TAG#v}"
|
|
30
|
+
fi
|
|
31
|
+
if [ -n "$VER" ]; then
|
|
32
|
+
python3 - "$VER" << 'EOF'
|
|
33
|
+
import json, sys
|
|
34
|
+
ver = sys.argv[1]
|
|
35
|
+
d = json.load(open('server.json'))
|
|
36
|
+
d['version'] = ver
|
|
37
|
+
for pkg in d.get('packages', []):
|
|
38
|
+
pkg['version'] = ver
|
|
39
|
+
json.dump(d, open('server.json', 'w'), indent=2, ensure_ascii=False)
|
|
40
|
+
print('server.json version ->', ver)
|
|
41
|
+
EOF
|
|
42
|
+
else
|
|
43
|
+
echo "No version tag ($GITHUB_REF); keeping server.json version as-is"
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
- name: Wait for PyPI
|
|
47
|
+
# The registry validates that the PyPI package + version exists,
|
|
48
|
+
# but the PyPI publish workflow runs in parallel with this one.
|
|
49
|
+
# Poll until the version appears instead of racing it: a 400 here
|
|
50
|
+
# used to fail silently under continue-on-error and left 40/43
|
|
51
|
+
# servers unlisted in the registry (2026-11-11).
|
|
52
|
+
run: |
|
|
53
|
+
python3 - << 'EOF'
|
|
54
|
+
import json, sys, time, urllib.request, urllib.error
|
|
55
|
+
d = json.load(open('server.json'))
|
|
56
|
+
pkg = d['packages'][0]
|
|
57
|
+
name = pkg.get('identifier') or pkg.get('name')
|
|
58
|
+
ver = pkg['version']
|
|
59
|
+
if not name:
|
|
60
|
+
print('ERROR: no package identifier/name in server.json')
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
url = f'https://pypi.org/pypi/{name}/json'
|
|
63
|
+
for attempt in range(1, 21):
|
|
64
|
+
try:
|
|
65
|
+
with urllib.request.urlopen(url, timeout=30) as r:
|
|
66
|
+
data = json.load(r)
|
|
67
|
+
if ver in data.get('releases', {}):
|
|
68
|
+
print(f'PyPI {name}=={ver} is live (attempt {attempt})')
|
|
69
|
+
sys.exit(0)
|
|
70
|
+
except urllib.error.HTTPError as e:
|
|
71
|
+
print(f'attempt {attempt}: PyPI returned {e.code}')
|
|
72
|
+
except Exception as e:
|
|
73
|
+
print(f'attempt {attempt}: {e}')
|
|
74
|
+
print(f'attempt {attempt}: {name}=={ver} not on PyPI yet, waiting 30s...')
|
|
75
|
+
time.sleep(30)
|
|
76
|
+
print(f'ERROR: {name}=={ver} did not appear on PyPI after 10 minutes')
|
|
77
|
+
sys.exit(1)
|
|
78
|
+
EOF
|
|
79
|
+
|
|
80
|
+
- name: Install mcp-publisher
|
|
81
|
+
run: |
|
|
82
|
+
curl -sL "https://github.com/modelcontextprotocol/registry/releases/download/v1.8.7/mcp-publisher_linux_amd64.tar.gz" -o /tmp/mcp-publisher.tar.gz
|
|
83
|
+
tar -xzf /tmp/mcp-publisher.tar.gz -C /tmp
|
|
84
|
+
sudo mv /tmp/mcp-publisher /usr/local/bin/mcp-publisher
|
|
85
|
+
mcp-publisher --version
|
|
86
|
+
|
|
87
|
+
- name: Validate server.json
|
|
88
|
+
run: mcp-publisher validate server.json
|
|
89
|
+
|
|
90
|
+
- name: Publish to MCP Registry (OIDC)
|
|
91
|
+
# Failures must fail the run visibly: a silent publish failure
|
|
92
|
+
# once left 40/43 servers unlisted in the registry (2026-11-11).
|
|
93
|
+
run: |
|
|
94
|
+
mcp-publisher login github-oidc
|
|
95
|
+
mcp-publisher publish server.json
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
name: Daily live smoke
|
|
2
|
+
|
|
3
|
+
# One fixed probe per day: catches fingerprint rot / bot-wall changes
|
|
4
|
+
# before users hit them. Fails visibly on 403/timeout.
|
|
5
|
+
on:
|
|
6
|
+
schedule:
|
|
7
|
+
- cron: "0 6 * * *"
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
smoke:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
steps:
|
|
14
|
+
- uses: actions/checkout@v4
|
|
15
|
+
- uses: actions/setup-python@v5
|
|
16
|
+
with:
|
|
17
|
+
python-version: "3.12"
|
|
18
|
+
- name: Install
|
|
19
|
+
run: pip install -e .
|
|
20
|
+
- name: Smoke probe
|
|
21
|
+
run: python tests/smoke_live.py
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Mohammad Abbasi
|
|
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,66 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openplz-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial read-only MCP server for German postal data (OpenPLZ API) — localities, streets, municipalities, districts & federal states
|
|
5
|
+
Author: Mohammad Abbasi
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: httpx>=0.27
|
|
10
|
+
Requires-Dist: mcp<2
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# openplz-mcp 🧭
|
|
14
|
+
|
|
15
|
+
mcp-name: io.github.mohammad-abbasi-sh/openplz-mcp
|
|
16
|
+
|
|
17
|
+
Unofficial **read-only** MCP server for **German postal data** via the [OpenPLZ API](https://openplzapi.org) (by the openpotato project): localities, streets, municipalities, districts, government regions and the 16 federal states (Bundesländer) — with the official administrative keys. No key, no login.
|
|
18
|
+
|
|
19
|
+
> Built by Mohammad Abbasi as part of the "Germany MCP factory". Not affiliated with the OpenPLZ API / openpotato project.
|
|
20
|
+
|
|
21
|
+
## For AI agents
|
|
22
|
+
|
|
23
|
+
Add to your MCP client config (stdio):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"mcpServers": {
|
|
28
|
+
"openplz": {
|
|
29
|
+
"command": "uvx",
|
|
30
|
+
"args": ["openplz-mcp==0.1.0"]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Or run locally: `python -m openplz_mcp.server` (needs `mcp<2`, `httpx`).
|
|
37
|
+
|
|
38
|
+
## Tools
|
|
39
|
+
|
|
40
|
+
| Tool | Description |
|
|
41
|
+
|------|-------------|
|
|
42
|
+
| `openplz_lookup_postal_code` | Which locality (or localities) a German postal code belongs to |
|
|
43
|
+
| `openplz_search_locality` | Search localities (Orte) by name, optionally within one postal code |
|
|
44
|
+
| `openplz_search_street` | Search streets by name, postal code and/or locality |
|
|
45
|
+
| `openplz_full_text_search` | Free-form street search across street name, postal code and city |
|
|
46
|
+
| `openplz_list_federal_states` | All 16 federal states (Bundesländer) with key + capital |
|
|
47
|
+
| `openplz_admin_children` | Walk one step down the administrative tree (state → district → municipality …) |
|
|
48
|
+
| `openplz_health` | Reachability check (ok / blocked / error) — one polite request |
|
|
49
|
+
|
|
50
|
+
Example output (`openplz_lookup_postal_code` / 10115):
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
{"postal_code": "10115", "count": 1,
|
|
54
|
+
"localities": [{"postal_code": "10115", "name": "Berlin",
|
|
55
|
+
"municipality": {"key": "11000000", "name": "Berlin, Stadt", "type": "Kreisfreie Stadt"},
|
|
56
|
+
"federal_state": {"key": "11", "name": "Berlin"}}]}
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Notes:
|
|
60
|
+
- A German postal code is exactly 5 digits (e.g. 10115 = Berlin-Mitte, 80331 = München).
|
|
61
|
+
- Official administrative keys: federal states `"01"`–`"16"` (Berlin `"11"`, Bayern `"09"`), districts 5 digits (`"11000"` = Berlin), municipalities 8 digits (`"09162000"` = München). Use them with `openplz_admin_children` — e.g. `federal_state "11" → districts` returns the single Berlin district; `district "11000" → localities` lists Berlin's localities.
|
|
62
|
+
- Street-name search matches the stored, abbreviated form (e.g. `"Ackerstr."`). For free-form queries (e.g. `"10115 Berlin"` or `"München Marienplatz"`) use `openplz_full_text_search`.
|
|
63
|
+
- A postal code can cover several localities, and a locality can have several postal codes — `openplz_lookup_postal_code` returns all of them.
|
|
64
|
+
- Data covers Germany only (`/de/` endpoints); the OpenPLZ API also serves Austria (`/at/`) and Switzerland (`/ch/`), which this server does not expose.
|
|
65
|
+
|
|
66
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# openplz-mcp 🧭
|
|
2
|
+
|
|
3
|
+
mcp-name: io.github.mohammad-abbasi-sh/openplz-mcp
|
|
4
|
+
|
|
5
|
+
Inoffizieller **Nur-Lesen**-MCP-Server für **deutsche Postdaten** über die [OpenPLZ API](https://openplzapi.org) (vom openpotato-Projekt): Orte, Straßen, Gemeinden, Kreise, Regierungsbezirke und die 16 Bundesländer — mit den amtlichen Verwaltungsschlüsseln. Kein Schlüssel, keine Anmeldung nötig.
|
|
6
|
+
|
|
7
|
+
> Gebaut von Mohammad Abbasi im Rahmen der „Germany MCP factory". Keine Verbindung zur OpenPLZ API / zum openpotato-Projekt.
|
|
8
|
+
|
|
9
|
+
## Für KI-Agenten
|
|
10
|
+
|
|
11
|
+
In die MCP-Client-Konfiguration eintragen (stdio):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"openplz": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["openplz-mcp==0.1.0"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Oder lokal starten: `python -m openplz_mcp.server` (benötigt `mcp<2`, `httpx`).
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | Beschreibung |
|
|
29
|
+
|------|--------------|
|
|
30
|
+
| `openplz_lookup_postal_code` | Zu welchem Ort (oder welchen Orten) eine deutsche Postleitzahl gehört |
|
|
31
|
+
| `openplz_search_locality` | Orte nach Namen suchen, optional innerhalb einer Postleitzahl |
|
|
32
|
+
| `openplz_search_street` | Straßen nach Name, Postleitzahl und/oder Ort suchen |
|
|
33
|
+
| `openplz_full_text_search` | Freitextsuche über Straßenname, Postleitzahl und Stadt |
|
|
34
|
+
| `openplz_list_federal_states` | Alle 16 Bundesländer mit Schlüssel + Hauptstadt |
|
|
35
|
+
| `openplz_admin_children` | Einen Schritt im Verwaltungsbaum nach unten gehen (Land → Kreis → Gemeinde …) |
|
|
36
|
+
| `openplz_health` | Erreichbarkeitsprüfung (ok / blocked / error) — eine höfliche Anfrage |
|
|
37
|
+
|
|
38
|
+
Beispielausgabe (`openplz_lookup_postal_code` / 10115):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"postal_code": "10115", "count": 1,
|
|
42
|
+
"localities": [{"postal_code": "10115", "name": "Berlin",
|
|
43
|
+
"municipality": {"key": "11000000", "name": "Berlin, Stadt", "type": "Kreisfreie Stadt"},
|
|
44
|
+
"federal_state": {"key": "11", "name": "Berlin"}}]}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Hinweise:
|
|
48
|
+
- Eine deutsche Postleitzahl hat genau 5 Ziffern (z. B. 10115 = Berlin-Mitte, 80331 = München).
|
|
49
|
+
- Amtliche Verwaltungsschlüssel: Bundesländer `"01"`–`"16"` (Berlin `"11"`, Bayern `"09"`), Kreise 5-stellig (`"11000"` = Berlin), Gemeinden 8-stellig (`"09162000"` = München). Mit `openplz_admin_children` nutzen — z. B. `federal_state "11" → districts` liefert den einzigen Berliner Kreis; `district "11000" → localities` listet Berlins Orte.
|
|
50
|
+
- Die Straßennamensuche trifft die gespeicherte, abgekürzte Form (z. B. `"Ackerstr."`). Für freie Anfragen (z. B. `"10115 Berlin"` oder `"München Marienplatz"`) `openplz_full_text_search` verwenden.
|
|
51
|
+
- Eine Postleitzahl kann mehrere Orte umfassen, und ein Ort kann mehrere Postleitzahlen haben — `openplz_lookup_postal_code` gibt alle zurück.
|
|
52
|
+
- Die Daten decken nur Deutschland ab (`/de/`-Endpunkte); die OpenPLZ API bedient außerdem Österreich (`/at/`) und die Schweiz (`/ch/`), die dieser Server nicht anbietet.
|
|
53
|
+
|
|
54
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# openplz-mcp 🧭
|
|
2
|
+
|
|
3
|
+
mcp-name: io.github.mohammad-abbasi-sh/openplz-mcp
|
|
4
|
+
|
|
5
|
+
سرور MCP غیررسمی و **فقطخواندنی** برای **دادههای پستی آلمان** از طریق [OpenPLZ API](https://openplzapi.org) (پروژهٔ openpotato): شهرها و روستاها، خیابانها، شهرداریها، بخشها، ناحیههای دولتی و ۱۶ ایالت فدرال (Bundesländer) — همراه با کدهای رسمی اداری. بدون کلید و بدون ورود.
|
|
6
|
+
|
|
7
|
+
> ساختهٔ محمد عباسی در چارچوب «کارخانهٔ MCP آلمان». وابسته به OpenPLZ API / پروژهٔ openpotato نیست.
|
|
8
|
+
|
|
9
|
+
## برای ایجنتهای هوش مصنوعی
|
|
10
|
+
|
|
11
|
+
به پیکربندی MCP Client اضافه کنید (stdio):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"openplz": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["openplz-mcp==0.1.0"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
یا بهصورت محلی اجرا کنید: `python -m openplz_mcp.server` (نیازمند `mcp<2` و `httpx`).
|
|
25
|
+
|
|
26
|
+
## ابزارها
|
|
27
|
+
|
|
28
|
+
| ابزار | توضیح |
|
|
29
|
+
|------|-------|
|
|
30
|
+
| `openplz_lookup_postal_code` | کد پستی آلمان متعلق به کدام شهر (یا شهرها) است |
|
|
31
|
+
| `openplz_search_locality` | جستوجوی شهرها بر اساس نام، اختیاری درون یک کد پستی |
|
|
32
|
+
| `openplz_search_street` | جستوجوی خیابانها بر اساس نام، کد پستی و/یا شهر |
|
|
33
|
+
| `openplz_full_text_search` | جستوجوی آزاد در نام خیابان، کد پستی و شهر |
|
|
34
|
+
| `openplz_list_federal_states` | هر ۱۶ ایالت فدرال با کد + مرکز |
|
|
35
|
+
| `openplz_admin_children` | یک قدم پایینتر در درخت اداری (ایالت ← بخش ← شهرداری …) |
|
|
36
|
+
| `openplz_health` | بررسی دسترسی به API (ok / blocked / error) — یک درخواست مؤدبانه |
|
|
37
|
+
|
|
38
|
+
نمونهٔ خروجی (`openplz_lookup_postal_code` / 10115):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"postal_code": "10115", "count": 1,
|
|
42
|
+
"localities": [{"postal_code": "10115", "name": "Berlin",
|
|
43
|
+
"municipality": {"key": "11000000", "name": "Berlin, Stadt", "type": "Kreisfreie Stadt"},
|
|
44
|
+
"federal_state": {"key": "11", "name": "Berlin"}}]}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
نکتهها:
|
|
48
|
+
- کد پستی آلمان دقیقاً ۵ رقم است (مثلاً 10115 = برلین-میته، 80331 = مونیخ).
|
|
49
|
+
- کدهای رسمی اداری: ایالتها `"01"` تا `"16"` (برلین `"11"`، بایرن `"09"`)، بخشها ۵ رقمی (`"11000"` = برلین)، شهرداریها ۸ رقمی (`"09162000"` = مونیخ). با `openplz_admin_children` استفاده کنید — مثلاً `federal_state "11" → districts` تنها بخش برلین را برمیگرداند؛ `district "11000" → localities` شهرهای برلین را فهرست میکند.
|
|
50
|
+
- جستوجوی نام خیابان با شکل ذخیرهشدهٔ مخفف مطابقت میکند (مثلاً `"Ackerstr."`). برای جستوجوی آزاد (مثلاً `"10115 Berlin"` یا `"München Marienplatz"`) از `openplz_full_text_search` استفاده کنید.
|
|
51
|
+
- یک کد پستی میتواند چند شهر را پوشش دهد و یک شهر میتواند چند کد پستی داشته باشد — `openplz_lookup_postal_code` همه را برمیگرداند.
|
|
52
|
+
- دادهها فقط آلمان را پوشش میدهند (مسیرهای `/de/`)؛ OpenPLZ API اتریش (`/at/`) و سوئیس (`/ch/`) را هم دارد که این سرور ارائه نمیکند.
|
|
53
|
+
|
|
54
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# openplz-mcp 🧭
|
|
2
|
+
|
|
3
|
+
mcp-name: io.github.mohammad-abbasi-sh/openplz-mcp
|
|
4
|
+
|
|
5
|
+
Unofficial **read-only** MCP server for **German postal data** via the [OpenPLZ API](https://openplzapi.org) (by the openpotato project): localities, streets, municipalities, districts, government regions and the 16 federal states (Bundesländer) — with the official administrative keys. No key, no login.
|
|
6
|
+
|
|
7
|
+
> Built by Mohammad Abbasi as part of the "Germany MCP factory". Not affiliated with the OpenPLZ API / openpotato project.
|
|
8
|
+
|
|
9
|
+
## For AI agents
|
|
10
|
+
|
|
11
|
+
Add to your MCP client config (stdio):
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"mcpServers": {
|
|
16
|
+
"openplz": {
|
|
17
|
+
"command": "uvx",
|
|
18
|
+
"args": ["openplz-mcp==0.1.0"]
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Or run locally: `python -m openplz_mcp.server` (needs `mcp<2`, `httpx`).
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | Description |
|
|
29
|
+
|------|-------------|
|
|
30
|
+
| `openplz_lookup_postal_code` | Which locality (or localities) a German postal code belongs to |
|
|
31
|
+
| `openplz_search_locality` | Search localities (Orte) by name, optionally within one postal code |
|
|
32
|
+
| `openplz_search_street` | Search streets by name, postal code and/or locality |
|
|
33
|
+
| `openplz_full_text_search` | Free-form street search across street name, postal code and city |
|
|
34
|
+
| `openplz_list_federal_states` | All 16 federal states (Bundesländer) with key + capital |
|
|
35
|
+
| `openplz_admin_children` | Walk one step down the administrative tree (state → district → municipality …) |
|
|
36
|
+
| `openplz_health` | Reachability check (ok / blocked / error) — one polite request |
|
|
37
|
+
|
|
38
|
+
Example output (`openplz_lookup_postal_code` / 10115):
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{"postal_code": "10115", "count": 1,
|
|
42
|
+
"localities": [{"postal_code": "10115", "name": "Berlin",
|
|
43
|
+
"municipality": {"key": "11000000", "name": "Berlin, Stadt", "type": "Kreisfreie Stadt"},
|
|
44
|
+
"federal_state": {"key": "11", "name": "Berlin"}}]}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Notes:
|
|
48
|
+
- A German postal code is exactly 5 digits (e.g. 10115 = Berlin-Mitte, 80331 = München).
|
|
49
|
+
- Official administrative keys: federal states `"01"`–`"16"` (Berlin `"11"`, Bayern `"09"`), districts 5 digits (`"11000"` = Berlin), municipalities 8 digits (`"09162000"` = München). Use them with `openplz_admin_children` — e.g. `federal_state "11" → districts` returns the single Berlin district; `district "11000" → localities` lists Berlin's localities.
|
|
50
|
+
- Street-name search matches the stored, abbreviated form (e.g. `"Ackerstr."`). For free-form queries (e.g. `"10115 Berlin"` or `"München Marienplatz"`) use `openplz_full_text_search`.
|
|
51
|
+
- A postal code can cover several localities, and a locality can have several postal codes — `openplz_lookup_postal_code` returns all of them.
|
|
52
|
+
- Data covers Germany only (`/de/` endpoints); the OpenPLZ API also serves Austria (`/at/`) and Switzerland (`/ch/`), which this server does not expose.
|
|
53
|
+
|
|
54
|
+
Built by [Mohammad Abbasi](https://github.com/mohammad-abbasi-sh) — [Almanibama](https://almanibama.ir) · [mabbasi.ir](https://mabbasi.ir)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "openplz-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial read-only MCP server for German postal data (OpenPLZ API) — localities, streets, municipalities, districts & federal states"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
requires-python = ">=3.10"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"mcp<2",
|
|
14
|
+
"httpx>=0.27",
|
|
15
|
+
]
|
|
16
|
+
authors = [{ name = "Mohammad Abbasi" }]
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
openplz-mcp = "openplz_mcp.server:main"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/openplz_mcp"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"$schema":"https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json","description":"Unofficial read-only MCP server for German postal data (OpenPLZ API) — PLZ lookup, streets & admin tree.","name":"io.github.mohammad-abbasi-sh/openplz-mcp","packages":[{"identifier":"openplz-mcp","registryBaseUrl":"https://pypi.org","registryType":"pypi","transport":{"type":"stdio"},"version":"0.1.0"}],"repository":{"source":"github","url":"https://github.com/mohammad-abbasi-sh/openplz-mcp"},"title":"OpenPLZ — German postal codes, streets & administrative tree","version":"0.1.0","websiteUrl":"https://github.com/mohammad-abbasi-sh/openplz-mcp"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""openplz_mcp package — unofficial read-only MCP access to German postal data (OpenPLZ API)."""
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Domain logic: shape OpenPLZ API responses into compact dicts (stdlib only).
|
|
2
|
+
|
|
3
|
+
Data source: the free OpenPLZ API v1 (openplzapi.org, by the openpotato
|
|
4
|
+
project) — German postal data, no key, no login. German endpoints live
|
|
5
|
+
under /de/ (the API also serves /at/ Austria and /ch/ Switzerland, which
|
|
6
|
+
this server does not expose). All responses are plain JSON arrays.
|
|
7
|
+
|
|
8
|
+
- /de/Localities?postalCode=&name= — localities (Orte) with their
|
|
9
|
+
municipality, district and federal state. A postal code can cover
|
|
10
|
+
several localities; a locality can have several postal codes.
|
|
11
|
+
- /de/Streets?name=&postalCode=&locality= — streets (Straßen) with
|
|
12
|
+
borough/suburb and the same administrative parents. NOTE (verified
|
|
13
|
+
2026-11-12): `name` matches the stored, abbreviated form
|
|
14
|
+
(e.g. "Ackerstr.") — it does NOT match "Friedrichstraße" via the
|
|
15
|
+
prefix "Friedrichstr."/"Friedrichstr"; use full-text search for
|
|
16
|
+
free-form street queries.
|
|
17
|
+
- /de/FullTextSearch?searchTerm= — streets matching a free-form term
|
|
18
|
+
(street name, postal code, city).
|
|
19
|
+
- /de/FederalStates — the 16 Bundesländer with their seat of government.
|
|
20
|
+
- /de/FederalStates/{key}/Districts|GovernmentRegions|Localities|
|
|
21
|
+
Municipalities|MunicipalAssociations,
|
|
22
|
+
/de/GovernmentRegions/{key}/Districts|Localities|Municipalities|
|
|
23
|
+
MunicipalAssociations,
|
|
24
|
+
/de/Districts/{key}/Localities|Municipalities|MunicipalAssociations —
|
|
25
|
+
the administrative tree below a state / region / district.
|
|
26
|
+
Keys are the official AGS-style codes: federal states "01".."16"
|
|
27
|
+
(Berlin "11", Bayern "09"), districts 5 digits ("11000" Berlin),
|
|
28
|
+
municipalities 8 digits ("09162000" München).
|
|
29
|
+
|
|
30
|
+
The API returns camelCase keys; parsers normalise to snake_case and
|
|
31
|
+
keep the fields the API returns (no invented data).
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import re
|
|
37
|
+
from urllib.parse import quote_plus
|
|
38
|
+
|
|
39
|
+
BASE = "https://openplzapi.org"
|
|
40
|
+
|
|
41
|
+
PLZ_RE = re.compile(r"^\d{5}$")
|
|
42
|
+
MAX_LIMIT = 50
|
|
43
|
+
|
|
44
|
+
# (parent_type, child_type) -> path segment template (German /de/ tree).
|
|
45
|
+
ADMIN_CHILDREN = {
|
|
46
|
+
("federal_state", "districts"): "FederalStates/{key}/Districts",
|
|
47
|
+
("federal_state", "government_regions"): "FederalStates/{key}/GovernmentRegions",
|
|
48
|
+
("federal_state", "localities"): "FederalStates/{key}/Localities",
|
|
49
|
+
("federal_state", "municipalities"): "FederalStates/{key}/Municipalities",
|
|
50
|
+
("federal_state", "municipal_associations"): "FederalStates/{key}/MunicipalAssociations",
|
|
51
|
+
("government_region", "districts"): "GovernmentRegions/{key}/Districts",
|
|
52
|
+
("government_region", "localities"): "GovernmentRegions/{key}/Localities",
|
|
53
|
+
("government_region", "municipalities"): "GovernmentRegions/{key}/Municipalities",
|
|
54
|
+
("government_region", "municipal_associations"): "GovernmentRegions/{key}/MunicipalAssociations",
|
|
55
|
+
("district", "localities"): "Districts/{key}/Localities",
|
|
56
|
+
("district", "municipalities"): "Districts/{key}/Municipalities",
|
|
57
|
+
("district", "municipal_associations"): "Districts/{key}/MunicipalAssociations",
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
PARENT_TYPES = sorted({p for p, _ in ADMIN_CHILDREN})
|
|
61
|
+
CHILD_TYPES = sorted({c for _, c in ADMIN_CHILDREN})
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def validate_postal_code(code: str) -> str:
|
|
65
|
+
"""A German postal code is exactly 5 digits."""
|
|
66
|
+
code = (code or "").strip()
|
|
67
|
+
if not PLZ_RE.fullmatch(code):
|
|
68
|
+
raise ValueError(
|
|
69
|
+
f"bad postal code {code!r} (expected 5 digits, e.g. 10115)"
|
|
70
|
+
)
|
|
71
|
+
return code
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def validate_limit(limit: int, default: int = 20) -> int:
|
|
75
|
+
"""Clamp a result limit to 1..50."""
|
|
76
|
+
try:
|
|
77
|
+
n = int(limit)
|
|
78
|
+
except (TypeError, ValueError):
|
|
79
|
+
raise ValueError(f"limit must be a number, got {limit!r}") from None
|
|
80
|
+
if not 1 <= n <= MAX_LIMIT:
|
|
81
|
+
raise ValueError(f"limit out of range: {limit} (expected 1..{MAX_LIMIT})")
|
|
82
|
+
return n
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def localities_url(postal_code: str | None = None, name: str | None = None,
|
|
86
|
+
limit: int = 20) -> str:
|
|
87
|
+
q = [f"pageSize={validate_limit(limit)}"]
|
|
88
|
+
if postal_code is not None:
|
|
89
|
+
q.append(f"postalCode={validate_postal_code(postal_code)}")
|
|
90
|
+
if name:
|
|
91
|
+
q.append(f"name={quote_plus(name.strip())}")
|
|
92
|
+
return f"{BASE}/de/Localities?{'&'.join(q)}"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def streets_url(name: str | None = None, postal_code: str | None = None,
|
|
96
|
+
locality: str | None = None, limit: int = 20) -> str:
|
|
97
|
+
if not (name or postal_code or locality):
|
|
98
|
+
raise ValueError(
|
|
99
|
+
"street search needs at least one of name, postal_code, locality"
|
|
100
|
+
)
|
|
101
|
+
q = [f"pageSize={validate_limit(limit)}"]
|
|
102
|
+
if name:
|
|
103
|
+
q.append(f"name={quote_plus(name.strip())}")
|
|
104
|
+
if postal_code is not None:
|
|
105
|
+
q.append(f"postalCode={validate_postal_code(postal_code)}")
|
|
106
|
+
if locality:
|
|
107
|
+
q.append(f"locality={quote_plus(locality.strip())}")
|
|
108
|
+
return f"{BASE}/de/Streets?{'&'.join(q)}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def full_text_url(search_term: str, limit: int = 20) -> str:
|
|
112
|
+
term = (search_term or "").strip()
|
|
113
|
+
if not term:
|
|
114
|
+
raise ValueError("search_term must not be empty")
|
|
115
|
+
return (f"{BASE}/de/FullTextSearch?searchTerm={quote_plus(term)}"
|
|
116
|
+
f"&pageSize={validate_limit(limit)}")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def federal_states_url() -> str:
|
|
120
|
+
return f"{BASE}/de/FederalStates"
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def admin_children_url(parent_type: str, parent_key: str,
|
|
124
|
+
child_type: str, limit: int = 20) -> str:
|
|
125
|
+
"""URL for one step down the administrative tree.
|
|
126
|
+
|
|
127
|
+
parent_type: federal_state | government_region | district.
|
|
128
|
+
child_type: districts | government_regions | localities |
|
|
129
|
+
municipalities | municipal_associations (only the combinations the
|
|
130
|
+
API offers; anything else raises with the valid list)."""
|
|
131
|
+
parent = (parent_type or "").strip().lower().replace("-", "_")
|
|
132
|
+
child = (child_type or "").strip().lower().replace("-", "_")
|
|
133
|
+
key = (parent_key or "").strip()
|
|
134
|
+
if not key:
|
|
135
|
+
raise ValueError("parent_key must not be empty")
|
|
136
|
+
template = ADMIN_CHILDREN.get((parent, child))
|
|
137
|
+
if template is None:
|
|
138
|
+
valid = sorted(
|
|
139
|
+
f"{p} -> {c}" for (p, c) in ADMIN_CHILDREN
|
|
140
|
+
)
|
|
141
|
+
raise ValueError(
|
|
142
|
+
f"no {child!r} under {parent!r}; valid combinations: "
|
|
143
|
+
+ "; ".join(valid)
|
|
144
|
+
)
|
|
145
|
+
return (f"{BASE}/de/{template.format(key=quote_plus(key))}"
|
|
146
|
+
f"?pageSize={validate_limit(limit)}")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _snake(name: str) -> str:
|
|
150
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _norm(value):
|
|
154
|
+
"""Recursively camelCase -> snake_case for dict keys."""
|
|
155
|
+
if isinstance(value, dict):
|
|
156
|
+
return {_snake(k): _norm(v) for k, v in value.items()}
|
|
157
|
+
if isinstance(value, list):
|
|
158
|
+
return [_norm(v) for v in value]
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _parse_list(text: str, what: str) -> list:
|
|
163
|
+
try:
|
|
164
|
+
payload = json.loads(text)
|
|
165
|
+
except json.JSONDecodeError as e:
|
|
166
|
+
raise ValueError(f"invalid {what} response: {e}") from e
|
|
167
|
+
if not isinstance(payload, list):
|
|
168
|
+
raise ValueError(f"unexpected {what} shape: not a JSON array")
|
|
169
|
+
return [_norm(row) if isinstance(row, dict) else row for row in payload]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def parse_localities(text: str) -> list:
|
|
173
|
+
"""Locality rows -> [{postal_code, name, municipality, district?,
|
|
174
|
+
federal_state}]."""
|
|
175
|
+
rows = _parse_list(text, "localities")
|
|
176
|
+
out = []
|
|
177
|
+
for r in rows:
|
|
178
|
+
out.append({
|
|
179
|
+
"postal_code": r.get("postal_code") or "",
|
|
180
|
+
"name": r.get("name") or "",
|
|
181
|
+
"municipality": r.get("municipality") or {},
|
|
182
|
+
"district": r.get("district") or None,
|
|
183
|
+
"federal_state": r.get("federal_state") or {},
|
|
184
|
+
})
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def parse_streets(text: str) -> list:
|
|
189
|
+
"""Street rows -> [{name, postal_code, locality, borough, suburb,
|
|
190
|
+
municipality, federal_state}]."""
|
|
191
|
+
rows = _parse_list(text, "streets")
|
|
192
|
+
out = []
|
|
193
|
+
for r in rows:
|
|
194
|
+
out.append({
|
|
195
|
+
"name": r.get("name") or "",
|
|
196
|
+
"postal_code": r.get("postal_code") or "",
|
|
197
|
+
"locality": r.get("locality") or "",
|
|
198
|
+
"borough": r.get("borough") or None,
|
|
199
|
+
"suburb": r.get("suburb") or None,
|
|
200
|
+
"municipality": r.get("municipality") or {},
|
|
201
|
+
"federal_state": r.get("federal_state") or {},
|
|
202
|
+
})
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def parse_federal_states(text: str) -> list:
|
|
207
|
+
"""Federal-state rows -> [{key, name, seat_of_government}]."""
|
|
208
|
+
rows = _parse_list(text, "federal states")
|
|
209
|
+
out = []
|
|
210
|
+
for r in rows:
|
|
211
|
+
out.append({
|
|
212
|
+
"key": r.get("key") or "",
|
|
213
|
+
"name": r.get("name") or "",
|
|
214
|
+
"seat_of_government": r.get("seat_of_government") or "",
|
|
215
|
+
})
|
|
216
|
+
out.sort(key=lambda s: s["key"])
|
|
217
|
+
return out
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def parse_admin_children(text: str) -> list:
|
|
221
|
+
"""Administrative child rows (districts / municipalities / government
|
|
222
|
+
regions / localities / municipal associations) — normalised, fields
|
|
223
|
+
kept as the API returns them."""
|
|
224
|
+
return _parse_list(text, "administrative children")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""HTTP layer for openplz_mcp — plain httpx GETs against the OpenPLZ API.
|
|
2
|
+
|
|
3
|
+
No key, no login. One retry on 429/5xx; error taxonomy (403/429/other).
|
|
4
|
+
The OpenPLZ API is a free community service (openplzapi.org, by the
|
|
5
|
+
openpotato project) serving German postal data: localities, streets,
|
|
6
|
+
municipalities, districts, government regions and the 16 federal states.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import ssl
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
UA = (
|
|
17
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
18
|
+
"(KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
|
19
|
+
)
|
|
20
|
+
TIMEOUT = 25.0
|
|
21
|
+
PROBE_URL = "https://openplzapi.org/de/FederalStates"
|
|
22
|
+
SOURCE = "OpenPLZ API (openplzapi.org)"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class OpenPlzMcpError(RuntimeError):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _verify():
|
|
30
|
+
"""TLS trust store: the OS bundle when present, else httpx/certifi."""
|
|
31
|
+
for path in (
|
|
32
|
+
"/etc/ssl/certs/ca-certificates.crt",
|
|
33
|
+
ssl.get_default_verify_paths().openssl_cafile,
|
|
34
|
+
):
|
|
35
|
+
if path and os.path.exists(path):
|
|
36
|
+
return ssl.create_default_context(cafile=path)
|
|
37
|
+
return True
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _client() -> httpx.Client:
|
|
41
|
+
# NOTE: we read the proxy URL from the environment but never log it.
|
|
42
|
+
proxy = os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
|
|
43
|
+
return httpx.Client(
|
|
44
|
+
headers={"User-Agent": UA, "Accept": "application/json, */*"},
|
|
45
|
+
timeout=TIMEOUT,
|
|
46
|
+
proxy=proxy or None,
|
|
47
|
+
trust_env=False,
|
|
48
|
+
verify=_verify(),
|
|
49
|
+
follow_redirects=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def fetch_text(url: str) -> str:
|
|
54
|
+
"""GET one URL and return its text. One retry on 429/5xx."""
|
|
55
|
+
last_exc: Exception | None = None
|
|
56
|
+
r: httpx.Response | None = None
|
|
57
|
+
for attempt in range(2):
|
|
58
|
+
try:
|
|
59
|
+
with _client() as c:
|
|
60
|
+
r = c.get(url)
|
|
61
|
+
except httpx.HTTPError as e:
|
|
62
|
+
last_exc = e
|
|
63
|
+
if attempt == 0:
|
|
64
|
+
time.sleep(1.0)
|
|
65
|
+
continue
|
|
66
|
+
raise OpenPlzMcpError(f"network error fetching {url}: {e}") from e
|
|
67
|
+
if r.status_code in (429, 500, 502, 503):
|
|
68
|
+
if attempt == 0:
|
|
69
|
+
time.sleep(1.0)
|
|
70
|
+
continue
|
|
71
|
+
break
|
|
72
|
+
assert r is not None # loop always assigns r or raises
|
|
73
|
+
if r.status_code == 403:
|
|
74
|
+
raise OpenPlzMcpError(
|
|
75
|
+
f"{SOURCE} returned HTTP 403 for {url} (bot wall) — try again later"
|
|
76
|
+
)
|
|
77
|
+
if r.status_code == 400:
|
|
78
|
+
raise OpenPlzMcpError(
|
|
79
|
+
f"{SOURCE} rejected the request (HTTP 400) — check the "
|
|
80
|
+
"postal code, state key or search term"
|
|
81
|
+
)
|
|
82
|
+
if r.status_code == 404:
|
|
83
|
+
raise OpenPlzMcpError(
|
|
84
|
+
f"{SOURCE} returned HTTP 404 for {url} — unknown key or path"
|
|
85
|
+
)
|
|
86
|
+
if r.status_code == 429:
|
|
87
|
+
raise OpenPlzMcpError(
|
|
88
|
+
f"{SOURCE} rate-limited the request (HTTP 429) — try again later"
|
|
89
|
+
)
|
|
90
|
+
if r.status_code != 200:
|
|
91
|
+
raise OpenPlzMcpError(f"{SOURCE} returned HTTP {r.status_code} for {url}")
|
|
92
|
+
return r.text
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def health_check() -> dict:
|
|
96
|
+
"""One polite probe of the source API.
|
|
97
|
+
|
|
98
|
+
Returns {"status": "ok"|"blocked"|"error", "http_status": int|None,
|
|
99
|
+
"url": ..., "detail": ...}. Used by the `openplz_health` tool
|
|
100
|
+
and the daily smoke CI.
|
|
101
|
+
"""
|
|
102
|
+
url = PROBE_URL
|
|
103
|
+
try:
|
|
104
|
+
fetch_text(url)
|
|
105
|
+
except OpenPlzMcpError as e:
|
|
106
|
+
msg = str(e)
|
|
107
|
+
if "403" in msg:
|
|
108
|
+
return {"status": "blocked", "http_status": 403, "url": url, "detail": msg}
|
|
109
|
+
if "429" in msg:
|
|
110
|
+
return {"status": "blocked", "http_status": 429, "url": url, "detail": msg}
|
|
111
|
+
return {"status": "error", "http_status": None, "url": url, "detail": msg}
|
|
112
|
+
return {"status": "ok", "http_status": 200, "url": url}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""MCP server: unofficial read-only access to German postal data via the
|
|
2
|
+
OpenPLZ API (openplzapi.org) — localities, streets, municipalities,
|
|
3
|
+
districts, government regions and the 16 federal states. No key, no login.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
|
|
11
|
+
from mcp.server.fastmcp import FastMCP
|
|
12
|
+
|
|
13
|
+
from . import domain as _dom
|
|
14
|
+
from . import http as _http
|
|
15
|
+
|
|
16
|
+
INSTRUCTIONS = """\
|
|
17
|
+
Unofficial read-only MCP server for German postal data — the OpenPLZ API
|
|
18
|
+
(openplzapi.org, by the openpotato project): localities, streets,
|
|
19
|
+
municipalities, districts, government regions and the 16 federal states
|
|
20
|
+
(Bundesländer), with the official administrative keys (federal states
|
|
21
|
+
"01".."16", e.g. Berlin "11", Bayern "09"; districts 5 digits, e.g.
|
|
22
|
+
"11000" Berlin; municipalities 8 digits, e.g. "09162000" München).
|
|
23
|
+
A German postal code is exactly 5 digits (e.g. 10115 = Berlin-Mitte).
|
|
24
|
+
Street-name search matches the stored, abbreviated form (e.g.
|
|
25
|
+
"Ackerstr."); for free-form queries use openplz_full_text_search.
|
|
26
|
+
Built by Mohammad Abbasi (mohammad-abbasi-sh) — https://almanibama.ir · https://mabbasi.ir — \
|
|
27
|
+
Germany MCP factory.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
_SOURCE = "OpenPLZ API v1 (openplzapi.org)"
|
|
31
|
+
_DISCLAIMER = "Unofficial read-only data from the OpenPLZ API; verify on openplzapi.org before acting on it."
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _envelope() -> dict:
|
|
35
|
+
return {
|
|
36
|
+
"fetched_at": datetime.now(timezone.utc).isoformat(),
|
|
37
|
+
"source": _SOURCE,
|
|
38
|
+
"disclaimer": _DISCLAIMER,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ok(payload: dict) -> str:
|
|
43
|
+
return json.dumps({**_envelope(), **payload}, ensure_ascii=False)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _err(e: Exception) -> str:
|
|
47
|
+
return _ok({"error": str(e)})
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
mcp = FastMCP("openplz-mcp", instructions=INSTRUCTIONS)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@mcp.tool()
|
|
54
|
+
def openplz_lookup_postal_code(postal_code: str) -> str:
|
|
55
|
+
"""Which locality (or localities) a German postal code belongs to.
|
|
56
|
+
postal_code: exactly 5 digits, e.g. 10115 (Berlin-Mitte)."""
|
|
57
|
+
try:
|
|
58
|
+
rows = _dom.parse_localities(
|
|
59
|
+
_http.fetch_text(_dom.localities_url(postal_code=postal_code, limit=50)))
|
|
60
|
+
except (_http.OpenPlzMcpError, ValueError) as e:
|
|
61
|
+
return _err(e)
|
|
62
|
+
return _ok({"postal_code": postal_code.strip(), "count": len(rows),
|
|
63
|
+
"localities": rows})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@mcp.tool()
|
|
67
|
+
def openplz_search_locality(name: str, postal_code: str | None = None,
|
|
68
|
+
limit: int = 20) -> str:
|
|
69
|
+
"""Search German localities (Orte) by name, optionally restricted to
|
|
70
|
+
one postal code. name: e.g. München (matches München, Münchenbernsdorf
|
|
71
|
+
...). Returns each locality's postal code, municipality, district and
|
|
72
|
+
federal state. limit: 1..50 (default 20)."""
|
|
73
|
+
try:
|
|
74
|
+
rows = _dom.parse_localities(_http.fetch_text(
|
|
75
|
+
_dom.localities_url(postal_code=postal_code, name=name,
|
|
76
|
+
limit=limit)))
|
|
77
|
+
except (_http.OpenPlzMcpError, ValueError) as e:
|
|
78
|
+
return _err(e)
|
|
79
|
+
return _ok({"name": (name or "").strip(),
|
|
80
|
+
"postal_code": postal_code,
|
|
81
|
+
"total_results": len(rows), "limit": limit,
|
|
82
|
+
"count": len(rows), "localities": rows})
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
def openplz_search_street(name: str | None = None,
|
|
87
|
+
postal_code: str | None = None,
|
|
88
|
+
locality: str | None = None,
|
|
89
|
+
limit: int = 20) -> str:
|
|
90
|
+
"""Search German streets by name, postal code and/or locality — at
|
|
91
|
+
least one filter is required. name matches the stored, abbreviated
|
|
92
|
+
form (e.g. "Ackerstr."); for free-form queries use
|
|
93
|
+
openplz_full_text_search. Returns street, postal code, locality,
|
|
94
|
+
borough/suburb, municipality and federal state. limit: 1..50."""
|
|
95
|
+
try:
|
|
96
|
+
rows = _dom.parse_streets(_http.fetch_text(
|
|
97
|
+
_dom.streets_url(name=name, postal_code=postal_code,
|
|
98
|
+
locality=locality, limit=limit)))
|
|
99
|
+
except (_http.OpenPlzMcpError, ValueError) as e:
|
|
100
|
+
return _err(e)
|
|
101
|
+
return _ok({"name": name, "postal_code": postal_code,
|
|
102
|
+
"locality": locality, "total_results": len(rows),
|
|
103
|
+
"limit": limit, "count": len(rows), "streets": rows})
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool()
|
|
107
|
+
def openplz_full_text_search(search_term: str, limit: int = 20) -> str:
|
|
108
|
+
"""Free-form street search across street name, postal code and city,
|
|
109
|
+
e.g. "10115 Berlin" or "München Marienplatz". Returns matching
|
|
110
|
+
streets with postal code, locality, borough/suburb, municipality and
|
|
111
|
+
federal state. limit: 1..50 (default 20)."""
|
|
112
|
+
try:
|
|
113
|
+
rows = _dom.parse_streets(_http.fetch_text(
|
|
114
|
+
_dom.full_text_url(search_term, limit=limit)))
|
|
115
|
+
except (_http.OpenPlzMcpError, ValueError) as e:
|
|
116
|
+
return _err(e)
|
|
117
|
+
return _ok({"search_term": (search_term or "").strip(),
|
|
118
|
+
"total_results": len(rows), "limit": limit,
|
|
119
|
+
"count": len(rows), "streets": rows})
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@mcp.tool()
|
|
123
|
+
def openplz_list_federal_states() -> str:
|
|
124
|
+
"""All 16 German federal states (Bundesländer) with their official
|
|
125
|
+
key ("01".."16") and seat of government (capital)."""
|
|
126
|
+
try:
|
|
127
|
+
rows = _dom.parse_federal_states(
|
|
128
|
+
_http.fetch_text(_dom.federal_states_url()))
|
|
129
|
+
except (_http.OpenPlzMcpError, ValueError) as e:
|
|
130
|
+
return _err(e)
|
|
131
|
+
return _ok({"count": len(rows), "federal_states": rows})
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@mcp.tool()
|
|
135
|
+
def openplz_admin_children(parent_type: str, parent_key: str,
|
|
136
|
+
child_type: str, limit: int = 20) -> str:
|
|
137
|
+
"""Walk one step down the German administrative tree.
|
|
138
|
+
parent_type: federal_state | government_region | district.
|
|
139
|
+
parent_key: official key, e.g. "11" (Berlin), "09" (Bayern),
|
|
140
|
+
"11000" (Berlin district), "091" (Oberbayern region).
|
|
141
|
+
child_type: districts | government_regions | localities |
|
|
142
|
+
municipalities | municipal_associations — only the combinations the
|
|
143
|
+
API offers (e.g. federal_state -> districts, district -> localities).
|
|
144
|
+
limit: 1..50 (default 20)."""
|
|
145
|
+
try:
|
|
146
|
+
rows = _dom.parse_admin_children(_http.fetch_text(
|
|
147
|
+
_dom.admin_children_url(parent_type, parent_key, child_type,
|
|
148
|
+
limit=limit)))
|
|
149
|
+
except (_http.OpenPlzMcpError, ValueError) as e:
|
|
150
|
+
return _err(e)
|
|
151
|
+
return _ok({"parent_type": parent_type, "parent_key": parent_key,
|
|
152
|
+
"child_type": child_type, "total_results": len(rows),
|
|
153
|
+
"limit": limit, "count": len(rows), "children": rows})
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@mcp.tool()
|
|
157
|
+
def openplz_health() -> str:
|
|
158
|
+
"""Check whether the OpenPLZ API is reachable right now.
|
|
159
|
+
|
|
160
|
+
Returns status ok (HTTP 200), blocked (HTTP 403/429 — bot wall or
|
|
161
|
+
rate limit) or error, plus the HTTP status and the probed URL.
|
|
162
|
+
One polite request; it does not fetch any listings."""
|
|
163
|
+
return _ok(_http.health_check())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main() -> None:
|
|
167
|
+
mcp.run()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
sys.exit(main())
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"key":"11000","name":"Berlin, Stadt","type":"Kreisfreie Stadt","federalState":{"key":"11","name":"Berlin"},"administrativeHeadquarters":"Berlin"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"key":"01","name":"Schleswig-Holstein","seatOfGovernment":"Kiel"},{"key":"02","name":"Hamburg","seatOfGovernment":"Hamburg"},{"key":"03","name":"Niedersachsen","seatOfGovernment":"Hannover"},{"key":"04","name":"Bremen","seatOfGovernment":"Bremen"},{"key":"05","name":"Nordrhein-Westfalen","seatOfGovernment":"Düsseldorf"},{"key":"06","name":"Hessen","seatOfGovernment":"Wiesbaden"},{"key":"07","name":"Rheinland-Pfalz","seatOfGovernment":"Mainz"},{"key":"08","name":"Baden-Württemberg","seatOfGovernment":"Stuttgart"},{"key":"09","name":"Bayern","seatOfGovernment":"München"},{"key":"10","name":"Saarland","seatOfGovernment":"Saarbrücken, Landeshauptstadt"},{"key":"11","name":"Berlin","seatOfGovernment":"Berlin"},{"key":"12","name":"Brandenburg","seatOfGovernment":"Potsdam"},{"key":"13","name":"Mecklenburg-Vorpommern","seatOfGovernment":"Schwerin"},{"key":"14","name":"Sachsen","seatOfGovernment":"Dresden"},{"key":"15","name":"Sachsen-Anhalt","seatOfGovernment":"Magdeburg"},{"key":"16","name":"Thüringen","seatOfGovernment":"Erfurt"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name":"Ackerstr.","postalCode":"10115","locality":"Berlin","borough":"Mitte","suburb":"Mitte","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}},{"name":"Am Nordbahnhof","postalCode":"10115","locality":"Berlin","borough":"Mitte","suburb":"Mitte","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}},{"name":"Am Pankepark","postalCode":"10115","locality":"Berlin","borough":"Mitte","suburb":"Mitte","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}},{"name":"An der Kieler Brücke","postalCode":"10115","locality":"Berlin","borough":"Mitte","suburb":"Wedding","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}},{"name":"An der Kieler Brücke","postalCode":"10115","locality":"Berlin","borough":"Mitte","suburb":"Mitte","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"key":"091","name":"Oberbayern","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"München"},{"key":"092","name":"Niederbayern","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"Landshut"},{"key":"093","name":"Oberpfalz","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"Regensburg"},{"key":"094","name":"Oberfranken","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"Bayreuth"},{"key":"095","name":"Mittelfranken","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"Ansbach"},{"key":"096","name":"Unterfranken","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"Würzburg"},{"key":"097","name":"Schwaben","federalState":{"key":"09","name":"Bayern"},"administrativeHeadquarters":"Augsburg"}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"postalCode":"10115","name":"Berlin","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"postalCode":"10115","name":"Berlin","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}},{"postalCode":"10117","name":"Berlin","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}},{"postalCode":"10119","name":"Berlin","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"postalCode":"07589","name":"Münchenbernsdorf","municipality":{"key":"16076049","name":"Münchenbernsdorf, Stadt","type":"Stadt"},"district":{"key":"16076","name":"Greiz","type":"Landkreis"},"federalState":{"key":"16","name":"Thüringen"}},{"postalCode":"80331","name":"München","municipality":{"key":"09162000","name":"München, Landeshauptstadt","type":"Kreisfreie Stadt"},"district":{"key":"09162","name":"München, Landeshauptstadt","type":"Kreisfreie Stadt"},"federalState":{"key":"09","name":"Bayern"}},{"postalCode":"80333","name":"München","municipality":{"key":"09162000","name":"München, Landeshauptstadt","type":"Kreisfreie Stadt"},"district":{"key":"09162","name":"München, Landeshauptstadt","type":"Kreisfreie Stadt"},"federalState":{"key":"09","name":"Bayern"}}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"key":"09161000","name":"Ingolstadt","type":"Kreisfreie Stadt","postalCode":"85047","multiplePostalCodes":true,"district":{"key":"09161","name":"Ingolstadt","type":"Kreisfreie Stadt"},"governmentRegion":{"key":"091","name":"Oberbayern"},"federalState":{"key":"09","name":"Bayern"}},{"key":"09162000","name":"München, Landeshauptstadt","type":"Kreisfreie Stadt","postalCode":"80313","multiplePostalCodes":true,"district":{"key":"09162","name":"München, Landeshauptstadt","type":"Kreisfreie Stadt"},"governmentRegion":{"key":"091","name":"Oberbayern"},"federalState":{"key":"09","name":"Bayern"}},{"key":"09163000","name":"Rosenheim","type":"Kreisfreie Stadt","postalCode":"83013","multiplePostalCodes":true,"district":{"key":"09163","name":"Rosenheim","type":"Kreisfreie Stadt"},"governmentRegion":{"key":"091","name":"Oberbayern"},"federalState":{"key":"09","name":"Bayern"}}]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
[{"name":"Ackerstr.","postalCode":"10115","locality":"Berlin","borough":"Mitte","suburb":"Mitte","municipality":{"key":"11000000","name":"Berlin, Stadt","type":"Kreisfreie Stadt"},"federalState":{"key":"11","name":"Berlin"}}]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Daily live smoke: one polite probe of the source API.
|
|
2
|
+
|
|
3
|
+
Exits 0 only when the API answers HTTP 200. Run by
|
|
4
|
+
.github/workflows/smoke.yml; also runnable locally:
|
|
5
|
+
python tests/smoke_live.py
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
11
|
+
|
|
12
|
+
from openplz_mcp import http as _http
|
|
13
|
+
|
|
14
|
+
if __name__ == "__main__":
|
|
15
|
+
result = _http.health_check()
|
|
16
|
+
print(result)
|
|
17
|
+
sys.exit(0 if result.get("status") == "ok" else 1)
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Offline tests — domain logic against recorded OpenPLZ fixtures.
|
|
2
|
+
|
|
3
|
+
Run: pytest tests/test_domain.py -v
|
|
4
|
+
"""
|
|
5
|
+
import pathlib
|
|
6
|
+
import sys
|
|
7
|
+
import unittest
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
|
10
|
+
|
|
11
|
+
from openplz_mcp import domain as dom # noqa: E402
|
|
12
|
+
|
|
13
|
+
FIX = pathlib.Path(__file__).resolve().parent / "fixtures"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _read(name: str) -> str:
|
|
17
|
+
return (FIX / name).read_text(encoding="utf-8")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TestValidation(unittest.TestCase):
|
|
21
|
+
def test_postal_code_ok(self):
|
|
22
|
+
self.assertEqual(dom.validate_postal_code("10115"), "10115")
|
|
23
|
+
self.assertEqual(dom.validate_postal_code(" 80331 "), "80331")
|
|
24
|
+
|
|
25
|
+
def test_postal_code_bad(self):
|
|
26
|
+
for bad in ("", "1011", "101155", "ABCDE", "10 115", "1011a"):
|
|
27
|
+
with self.assertRaises(ValueError, msg=bad):
|
|
28
|
+
dom.validate_postal_code(bad)
|
|
29
|
+
|
|
30
|
+
def test_limit_ok(self):
|
|
31
|
+
self.assertEqual(dom.validate_limit(1), 1)
|
|
32
|
+
self.assertEqual(dom.validate_limit(50), 50)
|
|
33
|
+
self.assertEqual(dom.validate_limit("20"), 20)
|
|
34
|
+
|
|
35
|
+
def test_limit_bad(self):
|
|
36
|
+
for bad in (0, 51, -1, "many", None):
|
|
37
|
+
with self.assertRaises(ValueError, msg=str(bad)):
|
|
38
|
+
dom.validate_limit(bad)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestUrls(unittest.TestCase):
|
|
42
|
+
def test_localities_url(self):
|
|
43
|
+
self.assertEqual(
|
|
44
|
+
dom.localities_url(postal_code="10115", limit=50),
|
|
45
|
+
"https://openplzapi.org/de/Localities?pageSize=50&postalCode=10115")
|
|
46
|
+
self.assertEqual(
|
|
47
|
+
dom.localities_url(name="München", limit=3),
|
|
48
|
+
"https://openplzapi.org/de/Localities?pageSize=3&name=M%C3%BCnchen")
|
|
49
|
+
|
|
50
|
+
def test_streets_url(self):
|
|
51
|
+
self.assertEqual(
|
|
52
|
+
dom.streets_url(name="Ackerstr.", postal_code="10115"),
|
|
53
|
+
"https://openplzapi.org/de/Streets?pageSize=20&name=Ackerstr."
|
|
54
|
+
"&postalCode=10115")
|
|
55
|
+
self.assertEqual(
|
|
56
|
+
dom.streets_url(postal_code="10115", limit=3),
|
|
57
|
+
"https://openplzapi.org/de/Streets?pageSize=3&postalCode=10115")
|
|
58
|
+
|
|
59
|
+
def test_streets_url_needs_filter(self):
|
|
60
|
+
with self.assertRaises(ValueError):
|
|
61
|
+
dom.streets_url()
|
|
62
|
+
|
|
63
|
+
def test_full_text_url(self):
|
|
64
|
+
self.assertEqual(
|
|
65
|
+
dom.full_text_url("10115 Berlin", limit=5),
|
|
66
|
+
"https://openplzapi.org/de/FullTextSearch?searchTerm=10115+Berlin"
|
|
67
|
+
"&pageSize=5")
|
|
68
|
+
with self.assertRaises(ValueError):
|
|
69
|
+
dom.full_text_url(" ")
|
|
70
|
+
|
|
71
|
+
def test_federal_states_url(self):
|
|
72
|
+
self.assertEqual(dom.federal_states_url(),
|
|
73
|
+
"https://openplzapi.org/de/FederalStates")
|
|
74
|
+
|
|
75
|
+
def test_admin_children_url(self):
|
|
76
|
+
self.assertEqual(
|
|
77
|
+
dom.admin_children_url("federal_state", "11", "districts"),
|
|
78
|
+
"https://openplzapi.org/de/FederalStates/11/Districts?pageSize=20")
|
|
79
|
+
self.assertEqual(
|
|
80
|
+
dom.admin_children_url("district", "11000", "localities", limit=3),
|
|
81
|
+
"https://openplzapi.org/de/Districts/11000/Localities?pageSize=3")
|
|
82
|
+
self.assertEqual(
|
|
83
|
+
dom.admin_children_url("government_region", "091", "districts"),
|
|
84
|
+
"https://openplzapi.org/de/GovernmentRegions/091/Districts?pageSize=20")
|
|
85
|
+
|
|
86
|
+
def test_admin_children_url_bad_combo(self):
|
|
87
|
+
with self.assertRaises(ValueError) as cm:
|
|
88
|
+
dom.admin_children_url("district", "11000", "districts")
|
|
89
|
+
self.assertIn("federal_state -> districts", str(cm.exception))
|
|
90
|
+
with self.assertRaises(ValueError):
|
|
91
|
+
dom.admin_children_url("federal_state", "", "districts")
|
|
92
|
+
with self.assertRaises(ValueError):
|
|
93
|
+
dom.admin_children_url("city", "11", "districts")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestParsing(unittest.TestCase):
|
|
97
|
+
def test_parse_localities_10115(self):
|
|
98
|
+
rows = dom.parse_localities(_read("localities-10115.json"))
|
|
99
|
+
self.assertEqual(len(rows), 1)
|
|
100
|
+
r = rows[0]
|
|
101
|
+
self.assertEqual(r["postal_code"], "10115")
|
|
102
|
+
self.assertEqual(r["name"], "Berlin")
|
|
103
|
+
self.assertEqual(r["municipality"]["key"], "11000000")
|
|
104
|
+
self.assertEqual(r["municipality"]["name"], "Berlin, Stadt")
|
|
105
|
+
self.assertEqual(r["federal_state"]["key"], "11")
|
|
106
|
+
self.assertEqual(r["federal_state"]["name"], "Berlin")
|
|
107
|
+
|
|
108
|
+
def test_parse_localities_muenchen(self):
|
|
109
|
+
rows = dom.parse_localities(_read("localities-muenchen.json"))
|
|
110
|
+
self.assertEqual(len(rows), 3)
|
|
111
|
+
munich = next(r for r in rows if r["postal_code"] == "80331")
|
|
112
|
+
self.assertEqual(munich["name"], "München")
|
|
113
|
+
self.assertEqual(munich["federal_state"]["key"], "09")
|
|
114
|
+
self.assertEqual(munich["federal_state"]["name"], "Bayern")
|
|
115
|
+
self.assertEqual(munich["municipality"]["key"], "09162000")
|
|
116
|
+
|
|
117
|
+
def test_parse_federal_states(self):
|
|
118
|
+
rows = dom.parse_federal_states(_read("federal-states.json"))
|
|
119
|
+
self.assertEqual(len(rows), 16)
|
|
120
|
+
self.assertEqual([r["key"] for r in rows],
|
|
121
|
+
[f"{i:02d}" for i in range(1, 17)])
|
|
122
|
+
berlin = next(r for r in rows if r["key"] == "11")
|
|
123
|
+
self.assertEqual(berlin["name"], "Berlin")
|
|
124
|
+
self.assertEqual(berlin["seat_of_government"], "Berlin")
|
|
125
|
+
bayern = next(r for r in rows if r["key"] == "09")
|
|
126
|
+
self.assertEqual(bayern["seat_of_government"], "München")
|
|
127
|
+
|
|
128
|
+
def test_parse_streets_ackerstr(self):
|
|
129
|
+
rows = dom.parse_streets(_read("streets-ackerstr-10115.json"))
|
|
130
|
+
self.assertEqual(len(rows), 1)
|
|
131
|
+
r = rows[0]
|
|
132
|
+
self.assertEqual(r["name"], "Ackerstr.")
|
|
133
|
+
self.assertEqual(r["postal_code"], "10115")
|
|
134
|
+
self.assertEqual(r["locality"], "Berlin")
|
|
135
|
+
self.assertEqual(r["borough"], "Mitte")
|
|
136
|
+
self.assertEqual(r["suburb"], "Mitte")
|
|
137
|
+
|
|
138
|
+
def test_parse_fulltext(self):
|
|
139
|
+
rows = dom.parse_streets(_read("fulltext-10115-berlin.json"))
|
|
140
|
+
self.assertEqual(len(rows), 5)
|
|
141
|
+
self.assertTrue(all(r["postal_code"] == "10115" for r in rows))
|
|
142
|
+
self.assertTrue(all(r["locality"] == "Berlin" for r in rows))
|
|
143
|
+
self.assertEqual(rows[0]["name"], "Ackerstr.")
|
|
144
|
+
|
|
145
|
+
def test_parse_districts_berlin(self):
|
|
146
|
+
rows = dom.parse_admin_children(_read("districts-berlin.json"))
|
|
147
|
+
self.assertEqual(len(rows), 1)
|
|
148
|
+
r = rows[0]
|
|
149
|
+
self.assertEqual(r["key"], "11000")
|
|
150
|
+
self.assertEqual(r["name"], "Berlin, Stadt")
|
|
151
|
+
self.assertEqual(r["type"], "Kreisfreie Stadt")
|
|
152
|
+
self.assertEqual(r["federal_state"]["key"], "11")
|
|
153
|
+
|
|
154
|
+
def test_parse_municipalities_bayern(self):
|
|
155
|
+
rows = dom.parse_admin_children(_read("municipalities-bayern.json"))
|
|
156
|
+
self.assertEqual(len(rows), 3)
|
|
157
|
+
ingolstadt = rows[0]
|
|
158
|
+
self.assertEqual(ingolstadt["key"], "09161000")
|
|
159
|
+
self.assertEqual(ingolstadt["name"], "Ingolstadt")
|
|
160
|
+
self.assertEqual(ingolstadt["postal_code"], "85047")
|
|
161
|
+
self.assertTrue(ingolstadt["multiple_postal_codes"])
|
|
162
|
+
self.assertEqual(ingolstadt["federal_state"]["key"], "09")
|
|
163
|
+
|
|
164
|
+
def test_parse_government_regions_bayern(self):
|
|
165
|
+
rows = dom.parse_admin_children(
|
|
166
|
+
_read("government-regions-bayern.json"))
|
|
167
|
+
self.assertEqual(len(rows), 7)
|
|
168
|
+
self.assertEqual(rows[0]["key"], "091")
|
|
169
|
+
self.assertEqual(rows[0]["name"], "Oberbayern")
|
|
170
|
+
self.assertEqual(rows[0]["administrative_headquarters"], "München")
|
|
171
|
+
|
|
172
|
+
def test_parse_localities_berlin_state(self):
|
|
173
|
+
rows = dom.parse_localities(_read("localities-berlin-state.json"))
|
|
174
|
+
self.assertEqual(len(rows), 3)
|
|
175
|
+
self.assertTrue(all(r["federal_state"]["key"] == "11" for r in rows))
|
|
176
|
+
|
|
177
|
+
def test_parse_bad_json(self):
|
|
178
|
+
with self.assertRaises(ValueError):
|
|
179
|
+
dom.parse_localities("not json {")
|
|
180
|
+
with self.assertRaises(ValueError):
|
|
181
|
+
dom.parse_localities('{"not": "a list"}')
|
|
182
|
+
with self.assertRaises(ValueError):
|
|
183
|
+
dom.parse_federal_states("[] trailing")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
unittest.main()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Live tests — hit the real OpenPLZ API. Cautious: few requests.
|
|
2
|
+
|
|
3
|
+
Run: pytest tests/test_live.py -v
|
|
4
|
+
Skip: pytest -m "not live" (or set OPENPLZ_SKIP_LIVE=1)
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import pathlib
|
|
9
|
+
import sys
|
|
10
|
+
import unittest
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent / "src"))
|
|
15
|
+
|
|
16
|
+
from openplz_mcp import server as srv # noqa: E402
|
|
17
|
+
|
|
18
|
+
pytestmark = pytest.mark.live
|
|
19
|
+
SKIP = os.environ.get("OPENPLZ_SKIP_LIVE") == "1"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.skipif(SKIP, reason="OPENPLZ_SKIP_LIVE=1")
|
|
23
|
+
class TestLive(unittest.TestCase):
|
|
24
|
+
def test_lookup_postal_code_live(self):
|
|
25
|
+
out = json.loads(srv.openplz_lookup_postal_code("10115"))
|
|
26
|
+
self.assertNotIn("error", out)
|
|
27
|
+
self.assertEqual(out["postal_code"], "10115")
|
|
28
|
+
self.assertGreaterEqual(out["count"], 1)
|
|
29
|
+
self.assertEqual(out["localities"][0]["name"], "Berlin")
|
|
30
|
+
print(f"\nLIVE 10115: {out['count']} locality/localities")
|
|
31
|
+
|
|
32
|
+
def test_search_locality_live(self):
|
|
33
|
+
out = json.loads(srv.openplz_search_locality("München", limit=5))
|
|
34
|
+
self.assertNotIn("error", out)
|
|
35
|
+
codes = [r["postal_code"] for r in out["localities"]]
|
|
36
|
+
self.assertIn("80331", codes)
|
|
37
|
+
munich = next(r for r in out["localities"]
|
|
38
|
+
if r["postal_code"] == "80331")
|
|
39
|
+
self.assertEqual(munich["federal_state"]["name"], "Bayern")
|
|
40
|
+
print(f"\nLIVE München: {out['count']} rows, 80331 present")
|
|
41
|
+
|
|
42
|
+
def test_search_street_live(self):
|
|
43
|
+
out = json.loads(srv.openplz_search_street(
|
|
44
|
+
name="Ackerstr.", postal_code="10115"))
|
|
45
|
+
self.assertNotIn("error", out)
|
|
46
|
+
self.assertEqual(out["count"], 1)
|
|
47
|
+
self.assertEqual(out["streets"][0]["borough"], "Mitte")
|
|
48
|
+
print("\nLIVE Ackerstr. 10115: 1 street, borough Mitte")
|
|
49
|
+
|
|
50
|
+
def test_search_street_no_filter_live(self):
|
|
51
|
+
out = json.loads(srv.openplz_search_street())
|
|
52
|
+
self.assertIn("error", out)
|
|
53
|
+
self.assertIn("at least one", out["error"])
|
|
54
|
+
|
|
55
|
+
def test_full_text_live(self):
|
|
56
|
+
out = json.loads(srv.openplz_full_text_search("10115 Berlin",
|
|
57
|
+
limit=5))
|
|
58
|
+
self.assertNotIn("error", out)
|
|
59
|
+
self.assertGreaterEqual(out["count"], 1)
|
|
60
|
+
self.assertTrue(all(s["postal_code"] == "10115"
|
|
61
|
+
for s in out["streets"]))
|
|
62
|
+
print(f"\nLIVE full-text '10115 Berlin': {out['count']} streets")
|
|
63
|
+
|
|
64
|
+
def test_federal_states_live(self):
|
|
65
|
+
out = json.loads(srv.openplz_list_federal_states())
|
|
66
|
+
self.assertNotIn("error", out)
|
|
67
|
+
self.assertEqual(out["count"], 16)
|
|
68
|
+
names = [s["name"] for s in out["federal_states"]]
|
|
69
|
+
self.assertIn("Berlin", names)
|
|
70
|
+
self.assertIn("Bayern", names)
|
|
71
|
+
print("\nLIVE federal states: 16")
|
|
72
|
+
|
|
73
|
+
def test_admin_children_live(self):
|
|
74
|
+
out = json.loads(srv.openplz_admin_children(
|
|
75
|
+
"federal_state", "11", "districts"))
|
|
76
|
+
self.assertNotIn("error", out)
|
|
77
|
+
self.assertEqual(out["count"], 1)
|
|
78
|
+
self.assertEqual(out["children"][0]["key"], "11000")
|
|
79
|
+
out2 = json.loads(srv.openplz_admin_children(
|
|
80
|
+
"district", "16076", "localities", limit=3))
|
|
81
|
+
self.assertNotIn("error", out2)
|
|
82
|
+
self.assertGreaterEqual(out2["count"], 1)
|
|
83
|
+
self.assertEqual(out2["children"][0]["name"], "Bethenhausen")
|
|
84
|
+
self.assertEqual(out2["children"][0]["district"]["key"], "16076")
|
|
85
|
+
print("\nLIVE admin: Berlin state -> 1 district; Greiz -> localities")
|
|
86
|
+
|
|
87
|
+
def test_admin_children_bad_combo_live(self):
|
|
88
|
+
out = json.loads(srv.openplz_admin_children(
|
|
89
|
+
"district", "11000", "districts"))
|
|
90
|
+
self.assertIn("error", out)
|
|
91
|
+
|
|
92
|
+
def test_health_live(self):
|
|
93
|
+
out = json.loads(srv.openplz_health())
|
|
94
|
+
self.assertEqual(out["status"], "ok")
|
|
95
|
+
self.assertEqual(out["http_status"], 200)
|
|
96
|
+
print(f"\nLIVE health: {out['status']} {out['http_status']}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == "__main__":
|
|
100
|
+
unittest.main()
|