ng-postcode-mcp 0.2.3__tar.gz → 0.3.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.
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/PKG-INFO +13 -2
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/README.md +11 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/pyproject.toml +2 -2
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/server.json +11 -2
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/src/ng_postcode_mcp/server.py +33 -4
- ng_postcode_mcp-0.3.0/tests/test_http.py +49 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/tests/test_resolve_tool.py +7 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/tests/test_tools.py +29 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/.gitignore +0 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/LICENSE +0 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/src/ng_postcode_mcp/__init__.py +0 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/src/ng_postcode_mcp/__main__.py +0 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/src/ng_postcode_mcp/py.typed +0 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/tests/test_metadata.py +0 -0
- {ng_postcode_mcp-0.2.3 → ng_postcode_mcp-0.3.0}/tests/test_stdio.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ng-postcode-mcp
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up, reverse-geocode and resolve addresses to postcodes.
|
|
5
5
|
Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
|
|
6
6
|
Project-URL: Issues, https://github.com/Adeniyikayodee/ng-postcode/issues
|
|
@@ -22,7 +22,7 @@ Classifier: Topic :: Scientific/Engineering :: GIS
|
|
|
22
22
|
Classifier: Typing :: Typed
|
|
23
23
|
Requires-Python: >=3.10
|
|
24
24
|
Requires-Dist: mcp<3,>=2.3
|
|
25
|
-
Requires-Dist: ng-address-resolver<0.2,>=0.1.
|
|
25
|
+
Requires-Dist: ng-address-resolver<0.2,>=0.1.4
|
|
26
26
|
Requires-Dist: ng-postcode[client]<0.3,>=0.2.1
|
|
27
27
|
Description-Content-Type: text/markdown
|
|
28
28
|
|
|
@@ -104,9 +104,20 @@ Most clients take this entry in their MCP settings:
|
|
|
104
104
|
| `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
|
|
105
105
|
| `NG_GEOCODER_URL` | none | A Nominatim server `resolve_address` uses to place described addresses. Without it, only typed postcodes and location pins resolve. |
|
|
106
106
|
| `NG_GEOCODER_CONTACT` | none | A URL or email sent in the User-Agent. Required for the public Nominatim. |
|
|
107
|
+
| `NG_POSTCODE_TRANSPORT` | `stdio` | `http` serves streamable HTTP at `/mcp` instead. |
|
|
108
|
+
| `NG_POSTCODE_HOST`, `NG_POSTCODE_PORT` | `127.0.0.1`, `8000` | Where the HTTP transport listens. |
|
|
107
109
|
|
|
108
110
|
The public Nominatim at `https://nominatim.openstreetmap.org` allows light personal use only; a service whose main job is geocoding must run its own instance. Map data © OpenStreetMap contributors.
|
|
109
111
|
|
|
112
|
+
## HTTP and Docker
|
|
113
|
+
|
|
114
|
+
```sh
|
|
115
|
+
NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mcp
|
|
116
|
+
docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
Over HTTP every caller uses the server's own NIPOST key and credits, and the server adds no authentication. It listens on loopback by default; put it behind your own authentication before setting `NG_POSTCODE_HOST` to a public address.
|
|
120
|
+
|
|
110
121
|
## Safety
|
|
111
122
|
|
|
112
123
|
- Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
|
|
@@ -76,9 +76,20 @@ Most clients take this entry in their MCP settings:
|
|
|
76
76
|
| `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
|
|
77
77
|
| `NG_GEOCODER_URL` | none | A Nominatim server `resolve_address` uses to place described addresses. Without it, only typed postcodes and location pins resolve. |
|
|
78
78
|
| `NG_GEOCODER_CONTACT` | none | A URL or email sent in the User-Agent. Required for the public Nominatim. |
|
|
79
|
+
| `NG_POSTCODE_TRANSPORT` | `stdio` | `http` serves streamable HTTP at `/mcp` instead. |
|
|
80
|
+
| `NG_POSTCODE_HOST`, `NG_POSTCODE_PORT` | `127.0.0.1`, `8000` | Where the HTTP transport listens. |
|
|
79
81
|
|
|
80
82
|
The public Nominatim at `https://nominatim.openstreetmap.org` allows light personal use only; a service whose main job is geocoding must run its own instance. Map data © OpenStreetMap contributors.
|
|
81
83
|
|
|
84
|
+
## HTTP and Docker
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mcp
|
|
88
|
+
docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Over HTTP every caller uses the server's own NIPOST key and credits, and the server adds no authentication. It listens on loopback by default; put it behind your own authentication before setting `NG_POSTCODE_HOST` to a public address.
|
|
92
|
+
|
|
82
93
|
## Safety
|
|
83
94
|
|
|
84
95
|
- Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ng-postcode-mcp"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.0"
|
|
8
8
|
description = "MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up, reverse-geocode and resolve addresses to postcodes."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -26,7 +26,7 @@ classifiers = [
|
|
|
26
26
|
"Topic :: Scientific/Engineering :: GIS",
|
|
27
27
|
"Typing :: Typed",
|
|
28
28
|
]
|
|
29
|
-
dependencies = ["mcp>=2.3,<3", "ng-address-resolver>=0.1.
|
|
29
|
+
dependencies = ["mcp>=2.3,<3", "ng-address-resolver>=0.1.4,<0.2", "ng-postcode[client]>=0.2.1,<0.3"]
|
|
30
30
|
|
|
31
31
|
[project.scripts]
|
|
32
32
|
ng-postcode-mcp = "ng_postcode_mcp.server:main"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "io.github.Adeniyikayodee/ng-postcode",
|
|
4
4
|
"title": "Nigeria Postcode",
|
|
5
5
|
"description": "Validate, look up and resolve addresses to Nigeria's NIPOST digital postcodes (NDAPS).",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.3.0",
|
|
7
7
|
"repository": {
|
|
8
8
|
"url": "https://github.com/Adeniyikayodee/ng-postcode",
|
|
9
9
|
"source": "github",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
{
|
|
14
14
|
"registryType": "pypi",
|
|
15
15
|
"identifier": "ng-postcode-mcp",
|
|
16
|
-
"version": "0.
|
|
16
|
+
"version": "0.3.0",
|
|
17
17
|
"runtimeHint": "uvx",
|
|
18
18
|
"transport": {
|
|
19
19
|
"type": "stdio"
|
|
@@ -44,6 +44,15 @@
|
|
|
44
44
|
{
|
|
45
45
|
"name": "NG_GEOCODER_CONTACT",
|
|
46
46
|
"description": "URL or email identifying you to the geocoder. Required for the public Nominatim."
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"name": "NG_POSTCODE_TRANSPORT",
|
|
50
|
+
"description": "stdio (default), or http to serve streamable HTTP at /mcp on NG_POSTCODE_HOST and NG_POSTCODE_PORT.",
|
|
51
|
+
"default": "stdio",
|
|
52
|
+
"choices": [
|
|
53
|
+
"stdio",
|
|
54
|
+
"http"
|
|
55
|
+
]
|
|
47
56
|
}
|
|
48
57
|
]
|
|
49
58
|
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Validation runs offline. Lookup, autocomplete and reverse geocoding call the
|
|
4
4
|
postcode.gov.ng API with the key in NG_POSTCODE_API_KEY, which never appears in
|
|
5
5
|
tool arguments or results. Address resolution also searches the geocoder in
|
|
6
|
-
NG_GEOCODER_URL. stdout carries the protocol, so nothing else may
|
|
6
|
+
NG_GEOCODER_URL. Over stdio, stdout carries the protocol, so nothing else may
|
|
7
|
+
print to it. Over HTTP, every caller shares the server's key.
|
|
7
8
|
"""
|
|
8
9
|
|
|
9
10
|
from __future__ import annotations
|
|
@@ -65,6 +66,7 @@ HINTS = {
|
|
|
65
66
|
"level_not_granted": "The key is not granted this lookup level; use a lower level or "
|
|
66
67
|
f"request more access at {KEY_URL}.",
|
|
67
68
|
}
|
|
69
|
+
LEVEL_2_FIELDS = ("state_name", "lga_name", "locality_name", "address")
|
|
68
70
|
STATUS_HINTS = {
|
|
69
71
|
403: "The key lacks the scope or access level for this request.",
|
|
70
72
|
429: "NIPOST rate limit reached; wait before retrying.",
|
|
@@ -78,6 +80,9 @@ class Settings:
|
|
|
78
80
|
base_url: str = BASE_URL
|
|
79
81
|
geocoder_url: str | None = None
|
|
80
82
|
geocoder_contact: str | None = None
|
|
83
|
+
transport: Literal["stdio", "http"] = "stdio"
|
|
84
|
+
host: str = "127.0.0.1"
|
|
85
|
+
port: int = 8000
|
|
81
86
|
|
|
82
87
|
|
|
83
88
|
def settings_from_env(env: Mapping[str, str]) -> Settings | str:
|
|
@@ -89,12 +94,21 @@ def settings_from_env(env: Mapping[str, str]) -> Settings | str:
|
|
|
89
94
|
geocoder_contact = env.get("NG_GEOCODER_CONTACT", "").strip() or None
|
|
90
95
|
if geocoder_url == PUBLIC_NOMINATIM and geocoder_contact is None:
|
|
91
96
|
return "the public Nominatim requires NG_GEOCODER_CONTACT, a URL or email identifying you"
|
|
97
|
+
transport = env.get("NG_POSTCODE_TRANSPORT", "stdio").strip().lower()
|
|
98
|
+
if transport not in ("stdio", "http"):
|
|
99
|
+
return f"NG_POSTCODE_TRANSPORT must be stdio or http, got {transport!r}"
|
|
100
|
+
raw_port = env.get("NG_POSTCODE_PORT", "8000").strip()
|
|
101
|
+
if not (raw_port.isdecimal() and 0 < int(raw_port) < 65536):
|
|
102
|
+
return f"NG_POSTCODE_PORT must be 1 to 65535, got {raw_port!r}"
|
|
92
103
|
return Settings(
|
|
93
104
|
api_key=env.get("NG_POSTCODE_API_KEY", "").strip() or None,
|
|
94
105
|
max_level=int(raw_level),
|
|
95
106
|
base_url=env.get("NG_POSTCODE_BASE_URL", "").strip() or BASE_URL,
|
|
96
107
|
geocoder_url=geocoder_url,
|
|
97
108
|
geocoder_contact=geocoder_contact,
|
|
109
|
+
transport="http" if transport == "http" else "stdio",
|
|
110
|
+
host=env.get("NG_POSTCODE_HOST", "").strip() or "127.0.0.1",
|
|
111
|
+
port=int(raw_port),
|
|
98
112
|
)
|
|
99
113
|
|
|
100
114
|
|
|
@@ -298,11 +312,14 @@ def create_server(
|
|
|
298
312
|
Field(ge=0, le=250, description="Search radius in metres. Defaults to 25."),
|
|
299
313
|
] = None,
|
|
300
314
|
) -> Location:
|
|
301
|
-
"""Return the postcode of the nearest building to a coordinate in Nigeria.
|
|
315
|
+
"""Return the postcode of the nearest building to a coordinate in Nigeria.
|
|
316
|
+
|
|
317
|
+
Names and the house address are withheld unless NG_POSTCODE_MAX_LEVEL is 2 or more.
|
|
318
|
+
"""
|
|
302
319
|
result = await api(ctx).send(
|
|
303
320
|
reverse(Coordinate(lat=latitude, lng=longitude), max_distance_m)
|
|
304
321
|
)
|
|
305
|
-
return Location.model_validate(unwrap(result))
|
|
322
|
+
return capped(Location.model_validate(unwrap(result)), settings.max_level)
|
|
306
323
|
|
|
307
324
|
@server.tool(
|
|
308
325
|
title="Resolve a Nigerian address to a postcode",
|
|
@@ -383,6 +400,14 @@ def geocoder_for(settings: Settings, http: httpx.AsyncClient | None) -> Nominati
|
|
|
383
400
|
)
|
|
384
401
|
|
|
385
402
|
|
|
403
|
+
def capped(location: Location, max_level: int) -> Location:
|
|
404
|
+
"""The location without the fields a level 2 key adds, unless the cap allows them."""
|
|
405
|
+
if max_level >= 2 or location.unit is None:
|
|
406
|
+
return location
|
|
407
|
+
unit = location.unit.model_copy(update=dict.fromkeys(LEVEL_2_FIELDS))
|
|
408
|
+
return location.model_copy(update={"unit": unit})
|
|
409
|
+
|
|
410
|
+
|
|
386
411
|
def completions(found: Autocomplete) -> Completions:
|
|
387
412
|
return Completions(
|
|
388
413
|
segment=None if found.segment is None else found.segment.value,
|
|
@@ -458,4 +483,8 @@ def main() -> None:
|
|
|
458
483
|
sys.exit(f"ng-postcode-mcp: {settings}")
|
|
459
484
|
# httpx logs every request URL at INFO, which would copy postcodes into client logs.
|
|
460
485
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
461
|
-
create_server(settings)
|
|
486
|
+
server = create_server(settings)
|
|
487
|
+
if settings.transport == "http":
|
|
488
|
+
server.run("streamable-http", host=settings.host, port=settings.port)
|
|
489
|
+
else:
|
|
490
|
+
server.run()
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""The real entry point over HTTP, on a loopback port."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import socket
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from mcp import Client
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.fixture
|
|
16
|
+
def anyio_backend() -> str:
|
|
17
|
+
return "asyncio"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def free_port() -> int:
|
|
21
|
+
with socket.socket() as probe:
|
|
22
|
+
probe.bind(("127.0.0.1", 0))
|
|
23
|
+
return int(probe.getsockname()[1])
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def wait_until_listening(port: int, seconds: float = 20.0) -> None:
|
|
27
|
+
deadline = time.monotonic() + seconds
|
|
28
|
+
while time.monotonic() < deadline:
|
|
29
|
+
with socket.socket() as probe:
|
|
30
|
+
if probe.connect_ex(("127.0.0.1", port)) == 0:
|
|
31
|
+
return
|
|
32
|
+
time.sleep(0.1)
|
|
33
|
+
raise TimeoutError(f"nothing listening on port {port}")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.anyio
|
|
37
|
+
async def test_serves_over_http() -> None:
|
|
38
|
+
port = free_port()
|
|
39
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("NG_POSTCODE_")}
|
|
40
|
+
env |= {"NG_POSTCODE_TRANSPORT": "http", "NG_POSTCODE_PORT": str(port)}
|
|
41
|
+
server = subprocess.Popen([sys.executable, "-m", "ng_postcode_mcp"], env=env)
|
|
42
|
+
try:
|
|
43
|
+
wait_until_listening(port)
|
|
44
|
+
async with Client(f"http://127.0.0.1:{port}/mcp") as client:
|
|
45
|
+
result = await client.call_tool("validate_postcode", {"postcode": "LA11W06TC10"})
|
|
46
|
+
finally:
|
|
47
|
+
server.terminate()
|
|
48
|
+
server.wait(timeout=10)
|
|
49
|
+
assert result.structured_content["postcode"] == "LA-11-W06-TC-10"
|
|
@@ -61,6 +61,13 @@ async def call(
|
|
|
61
61
|
return result
|
|
62
62
|
|
|
63
63
|
|
|
64
|
+
@pytest.mark.anyio
|
|
65
|
+
async def test_queries_without_landmarks_never_give_a_building() -> None:
|
|
66
|
+
queries = {k: v for k, v in BEHIND_FABIAN.items() if k != "landmarks"}
|
|
67
|
+
answer = (await call(queries)).structured_content
|
|
68
|
+
assert (answer["status"], answer["level"]) == ("partial", "area")
|
|
69
|
+
|
|
70
|
+
|
|
64
71
|
@pytest.mark.anyio
|
|
65
72
|
async def test_the_host_models_reading_drives_the_answer() -> None:
|
|
66
73
|
searched: list[httpx.Request] = []
|
|
@@ -22,6 +22,7 @@ from ng_postcode_mcp.server import (
|
|
|
22
22
|
Location,
|
|
23
23
|
NearestBuilding,
|
|
24
24
|
PostcodeDetails,
|
|
25
|
+
capped,
|
|
25
26
|
unwrap,
|
|
26
27
|
)
|
|
27
28
|
|
|
@@ -181,6 +182,26 @@ async def test_autocomplete_and_reverse() -> None:
|
|
|
181
182
|
assert (near.structured_content["found"], near.structured_content["radius_m"]) == (False, 25.0)
|
|
182
183
|
|
|
183
184
|
|
|
185
|
+
def test_the_level_cap_withholds_names_and_addresses() -> None:
|
|
186
|
+
unit = NearestBuilding(
|
|
187
|
+
postcode="EK-01-A03-FK-01",
|
|
188
|
+
display="EK 01 A03 FK 01",
|
|
189
|
+
distance_m=8.0,
|
|
190
|
+
confidence="high",
|
|
191
|
+
state_name="EKITI",
|
|
192
|
+
lga_name="ADO EKITI",
|
|
193
|
+
locality_name="ADO EKITI",
|
|
194
|
+
address="NTA ROAD",
|
|
195
|
+
)
|
|
196
|
+
found = Location(
|
|
197
|
+
found=True, unit=unit, area=None, district=None, state=None, message=None, radius_m=25.0
|
|
198
|
+
)
|
|
199
|
+
assert capped(found, 2) == found
|
|
200
|
+
held = capped(found, 1).unit
|
|
201
|
+
assert held is not None
|
|
202
|
+
assert (held.postcode, held.address, held.state_name) == ("EK-01-A03-FK-01", None, None)
|
|
203
|
+
|
|
204
|
+
|
|
184
205
|
@pytest.mark.anyio
|
|
185
206
|
async def test_rejects_out_of_range_arguments() -> None:
|
|
186
207
|
result = await call("find_postcode_at_location", {"latitude": 95, "longitude": 5.22})
|
|
@@ -203,6 +224,14 @@ def test_output_models_only_use_fields_the_library_has(
|
|
|
203
224
|
assert set(model.model_fields) <= {field.name for field in dataclasses.fields(source)}
|
|
204
225
|
|
|
205
226
|
|
|
227
|
+
def test_transport_settings() -> None:
|
|
228
|
+
served = settings_from_env({"NG_POSTCODE_TRANSPORT": "HTTP", "NG_POSTCODE_PORT": "9000"})
|
|
229
|
+
assert isinstance(served, Settings)
|
|
230
|
+
assert (served.transport, served.host, served.port) == ("http", "127.0.0.1", 9000)
|
|
231
|
+
assert isinstance(settings_from_env({"NG_POSTCODE_TRANSPORT": "sse"}), str)
|
|
232
|
+
assert isinstance(settings_from_env({"NG_POSTCODE_PORT": "0"}), str)
|
|
233
|
+
|
|
234
|
+
|
|
206
235
|
def test_settings_from_env() -> None:
|
|
207
236
|
assert settings_from_env({}) == Settings(api_key=None)
|
|
208
237
|
assert settings_from_env({"NG_POSTCODE_API_KEY": " k ", "NG_POSTCODE_MAX_LEVEL": "3"}) == (
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|