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.
@@ -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()