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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ng-postcode-mcp
3
- Version: 0.2.3
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.3
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.2.3"
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.3,<0.2", "ng-postcode[client]>=0.2.1,<0.3"]
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.2.3",
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.2.3",
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 print to it.
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).run()
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