ng-postcode-mcp 0.3.0__tar.gz → 0.4.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.3.0
3
+ Version: 0.4.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
@@ -116,10 +116,13 @@ NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mc
116
116
  docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
117
117
  ```
118
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.
119
+ Over HTTP a caller can send its own NIPOST key in the `X-NIPOST-API-Key` header, and that key is used for that caller's requests only. A caller that sends none uses the server's key, if `NG_POSTCODE_API_KEY` is set.
120
+
121
+ To host the server for other people, leave `NG_POSTCODE_API_KEY` unset so every caller brings a key, and serve it over HTTPS so the header is encrypted. Callers are trusting the host with their key, and all of them share the host's geocoder, which answers one search a second. Validation still works without any key. If you do set a server key, anyone who can reach the server spends its credits, so keep it on loopback or behind your own authentication. `NG_POSTCODE_MAX_LEVEL` caps every caller either way.
120
122
 
121
123
  ## Safety
122
124
 
125
+ - The key is read from the environment or the `X-NIPOST-API-Key` header, never from tool arguments, and never appears in results or logs.
123
126
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
124
127
  - A mistyped code is never corrected and sent to the API silently. The server returns the suggestion and asks the model to confirm it with the user.
125
128
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
@@ -88,10 +88,13 @@ NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mc
88
88
  docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
89
89
  ```
90
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.
91
+ Over HTTP a caller can send its own NIPOST key in the `X-NIPOST-API-Key` header, and that key is used for that caller's requests only. A caller that sends none uses the server's key, if `NG_POSTCODE_API_KEY` is set.
92
+
93
+ To host the server for other people, leave `NG_POSTCODE_API_KEY` unset so every caller brings a key, and serve it over HTTPS so the header is encrypted. Callers are trusting the host with their key, and all of them share the host's geocoder, which answers one search a second. Validation still works without any key. If you do set a server key, anyone who can reach the server spends its credits, so keep it on loopback or behind your own authentication. `NG_POSTCODE_MAX_LEVEL` caps every caller either way.
92
94
 
93
95
  ## Safety
94
96
 
97
+ - The key is read from the environment or the `X-NIPOST-API-Key` header, never from tool arguments, and never appears in results or logs.
95
98
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
96
99
  - A mistyped code is never corrected and sent to the API silently. The server returns the suggestion and asks the model to confirm it with the user.
97
100
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ng-postcode-mcp"
7
- version = "0.3.0"
7
+ version = "0.4.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"
@@ -36,7 +36,7 @@ Repository = "https://github.com/Adeniyikayodee/ng-postcode"
36
36
  Issues = "https://github.com/Adeniyikayodee/ng-postcode/issues"
37
37
 
38
38
  [dependency-groups]
39
- dev = ["jsonschema>=4.20", "mypy>=1.13", "pytest>=8", "ruff>=0.8"]
39
+ dev = ["jsonschema>=4.20", "mypy>=1.13", "pytest>=8", "ruff>=0.8", "types-jsonschema>=4.20"]
40
40
 
41
41
  # Inside the repository, build against the local packages so they never drift.
42
42
  [tool.uv.sources]
@@ -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.3.0",
6
+ "version": "0.4.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.3.0",
16
+ "version": "0.4.0",
17
17
  "runtimeHint": "uvx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -4,7 +4,8 @@ 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
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
+ print to it. Over HTTP, a caller may send its own key in the X-NIPOST-API-Key
8
+ header; without one it uses the server's key, if the server has one.
8
9
  """
9
10
 
10
11
  from __future__ import annotations
@@ -34,12 +35,14 @@ from ng_postcode.api import (
34
35
  lookup,
35
36
  reverse,
36
37
  )
37
- from ng_postcode.client import AsyncClient, TransportError
38
+ from ng_postcode.client import TIMEOUT, AsyncClient, TransportError
38
39
  from pydantic import BaseModel, ConfigDict, Field
39
40
 
40
41
  T = TypeVar("T")
41
42
 
42
43
  KEY_URL = "https://dashboard.postcode.gov.ng"
44
+ KEY_HEADER = "x-nipost-api-key"
45
+ MAX_KEY_LENGTH = 256
43
46
 
44
47
  INSTRUCTIONS = """\
45
48
  Tools for Nigeria's 11-character building postcode, e.g. EK-01-A03-FK-01 \
@@ -60,8 +63,8 @@ READ_ONLY_OFFLINE = ToolAnnotations(
60
63
  READ_ONLY_ONLINE = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=True)
61
64
 
62
65
  HINTS = {
63
- "auth_required": f"Set NG_POSTCODE_API_KEY to a key from {KEY_URL}.",
64
- "invalid_api_key": f"NG_POSTCODE_API_KEY is invalid or revoked; create a new key at {KEY_URL}.",
66
+ "auth_required": f"Supply a NIPOST API key from {KEY_URL}.",
67
+ "invalid_api_key": f"The NIPOST API key is invalid or revoked; create a new key at {KEY_URL}.",
65
68
  "insufficient_credits": "The NIPOST account is out of credits; top up or use level 1.",
66
69
  "level_not_granted": "The key is not granted this lookup level; use a lower level or "
67
70
  f"request more access at {KEY_URL}.",
@@ -201,8 +204,11 @@ class Location(FromLibrary):
201
204
 
202
205
  @dataclass(frozen=True, slots=True)
203
206
  class State:
204
- """What the tools share for the life of the server. Either part may be unconfigured."""
207
+ """What the tools share for the life of the server. `nipost` and `geocoder` may be
208
+ unconfigured."""
205
209
 
210
+ http: httpx.AsyncClient
211
+ base_url: str
206
212
  nipost: AsyncClient | None
207
213
  geocoder: Nominatim | None
208
214
 
@@ -217,17 +223,18 @@ def create_server(
217
223
 
218
224
  @asynccontextmanager
219
225
  async def lifespan(_: MCPServer[State]) -> AsyncIterator[State]:
226
+ shared = http if http is not None else httpx.AsyncClient(timeout=TIMEOUT)
220
227
  nipost = (
221
- AsyncClient(settings.api_key, base_url=settings.base_url, http=http)
228
+ AsyncClient(settings.api_key, base_url=settings.base_url, http=shared)
222
229
  if settings.api_key
223
230
  else None
224
231
  )
225
232
  geocoder = geocoder_for(settings, geocoder_http)
226
233
  try:
227
- yield State(nipost, geocoder)
234
+ yield State(shared, settings.base_url, nipost, geocoder)
228
235
  finally:
229
- if nipost:
230
- await nipost.aclose()
236
+ if http is None:
237
+ await shared.aclose()
231
238
  if geocoder:
232
239
  await geocoder.aclose()
233
240
 
@@ -366,8 +373,8 @@ def create_server(
366
373
  if latitude is not None and longitude is not None
367
374
  else None
368
375
  )
369
- state = ctx.request_context.lifespan_context
370
- resolver = Resolver(nipost=state.nipost, geocoder=state.geocoder)
376
+ geocoder = ctx.request_context.lifespan_context.geocoder
377
+ resolver = Resolver(nipost=nipost_for(ctx), geocoder=geocoder)
371
378
  return await resolver.resolve(address, location, reading(landmarks, geocode_queries))
372
379
 
373
380
  return server
@@ -461,10 +468,25 @@ def checked(text: str) -> Postcode:
461
468
  raise ToolError(f"{text!r} is not a valid postcode: {error}.{maybe}")
462
469
 
463
470
 
471
+ def nipost_for(ctx: Context[State, Any]) -> AsyncClient | None:
472
+ """The caller's own client when an HTTP request carries a key, else the server's."""
473
+ state = ctx.request_context.lifespan_context
474
+ sent = (v for k, v in (ctx.headers or {}).items() if k.lower() == KEY_HEADER)
475
+ key = next(sent, "").strip()
476
+ if not key:
477
+ return state.nipost
478
+ if not (key.isascii() and key.isprintable() and " " not in key and len(key) <= MAX_KEY_LENGTH):
479
+ raise ToolError("The X-NIPOST-API-Key header does not hold a usable NIPOST API key.")
480
+ return AsyncClient(key, base_url=state.base_url, http=state.http)
481
+
482
+
464
483
  def api(ctx: Context[State, Any]) -> AsyncClient:
465
- client = ctx.request_context.lifespan_context.nipost
484
+ client = nipost_for(ctx)
466
485
  if client is None:
467
- raise ToolError(f"This tool needs NG_POSTCODE_API_KEY. {HINTS['auth_required']}")
486
+ raise ToolError(
487
+ "This tool needs a NIPOST API key: set NG_POSTCODE_API_KEY on the server, or over "
488
+ f"HTTP send your own in the X-NIPOST-API-Key header. Get one at {KEY_URL}."
489
+ )
468
490
  return client
469
491
 
470
492
 
@@ -0,0 +1,94 @@
1
+ """A caller's own NIPOST key over HTTP, against an in-process server with NIPOST mocked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import threading
7
+ import time
8
+ from collections.abc import Iterator
9
+ from contextlib import contextmanager
10
+
11
+ import httpx
12
+ import httpx2
13
+ import pytest
14
+ import uvicorn
15
+ from mcp import Client
16
+ from mcp.client.streamable_http import streamable_http_client
17
+ from mcp.types import CallToolResult
18
+
19
+ from ng_postcode_mcp import Settings, create_server
20
+
21
+ LOOKUP = {"postcode": "EK-01-A03-FK-01"}
22
+
23
+
24
+ @pytest.fixture
25
+ def anyio_backend() -> str:
26
+ return "asyncio"
27
+
28
+
29
+ @contextmanager
30
+ def serving(server_key: str | None, keys_seen: list[str]) -> Iterator[str]:
31
+ """Run the server over HTTP on a loopback port; yields its URL."""
32
+
33
+ def nipost(request: httpx.Request) -> httpx.Response:
34
+ keys_seen.append(request.headers["X-API-Key"])
35
+ return httpx.Response(200, json={"data": {"postcode": "EK-01-A03-FK-01", "valid": True}})
36
+
37
+ upstream = httpx.AsyncClient(transport=httpx.MockTransport(nipost))
38
+ app = create_server(Settings(api_key=server_key), http=upstream).streamable_http_app()
39
+ with socket.socket() as probe:
40
+ probe.bind(("127.0.0.1", 0))
41
+ port = int(probe.getsockname()[1])
42
+ server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=port, log_level="error"))
43
+ thread = threading.Thread(target=server.run, daemon=True)
44
+ thread.start()
45
+ deadline = time.monotonic() + 20
46
+ while not server.started and time.monotonic() < deadline:
47
+ time.sleep(0.05)
48
+ try:
49
+ yield f"http://127.0.0.1:{port}/mcp"
50
+ finally:
51
+ server.should_exit = True
52
+ thread.join(timeout=10)
53
+
54
+
55
+ async def lookup(url: str, caller_key: str | bytes | None) -> CallToolResult:
56
+ sent = caller_key.encode() if isinstance(caller_key, str) else caller_key
57
+ headers = {b"X-NIPOST-API-Key": sent} if sent else {}
58
+ async with (
59
+ httpx2.AsyncClient(headers=headers) as http,
60
+ Client(streamable_http_client(url, http_client=http)) as client,
61
+ ):
62
+ return await client.call_tool("lookup_postcode", LOOKUP)
63
+
64
+
65
+ @pytest.mark.anyio
66
+ async def test_a_callers_key_is_used_for_its_own_requests() -> None:
67
+ seen: list[str] = []
68
+ with serving(None, seen) as url:
69
+ own = await lookup(url, "caller-key")
70
+ none = await lookup(url, None)
71
+ assert not own.is_error
72
+ assert seen == ["caller-key"]
73
+ assert none.is_error
74
+ assert "X-NIPOST-API-Key" in str(none.content)
75
+
76
+
77
+ @pytest.mark.anyio
78
+ async def test_the_servers_key_is_the_fallback() -> None:
79
+ seen: list[str] = []
80
+ with serving("server-key", seen) as url:
81
+ await lookup(url, None)
82
+ await lookup(url, "caller-key")
83
+ assert seen == ["server-key", "caller-key"]
84
+
85
+
86
+ @pytest.mark.anyio
87
+ @pytest.mark.parametrize("key", ["k\xe9y".encode("latin-1"), b"two words", b"k" * 300])
88
+ async def test_an_unusable_key_is_refused_before_nipost(key: bytes) -> None:
89
+ seen: list[str] = []
90
+ with serving("server-key", seen) as url:
91
+ result = await lookup(url, key)
92
+ assert result.is_error
93
+ assert "does not hold a usable" in str(result.content)
94
+ assert seen == []
@@ -0,0 +1,74 @@
1
+ """The shared postcode reference schema, and that resolve_address answers fit it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import pytest
10
+ from jsonschema import Draft202012Validator
11
+ from ng_address.models import Resolution
12
+ from ng_postcode import Postcode, Segment
13
+
14
+ SCHEMA_FILE = Path(__file__).resolve().parents[2] / "docs/schemas/postcode-reference.schema.json"
15
+
16
+ pytestmark = pytest.mark.skipif(
17
+ not SCHEMA_FILE.exists(), reason="the schema lives in the repository, not the sdist"
18
+ )
19
+
20
+
21
+ def loaded() -> dict[str, Any]:
22
+ document: dict[str, Any] = json.loads(SCHEMA_FILE.read_text(encoding="utf-8"))
23
+ return document
24
+
25
+
26
+ @pytest.fixture(scope="module")
27
+ def schema() -> Draft202012Validator:
28
+ Draft202012Validator.check_schema(loaded())
29
+ return Draft202012Validator(loaded())
30
+
31
+
32
+ def test_its_own_examples_are_valid(schema: Draft202012Validator) -> None:
33
+ for example in loaded()["examples"]:
34
+ schema.validate(example)
35
+
36
+
37
+ @pytest.mark.parametrize(
38
+ "reference",
39
+ [
40
+ {"code": "EK-01-A03-FK-01"},
41
+ {"code": "EK-01-A03-FK-01", "level": "area"},
42
+ {"code": "EK-01-A03-FK", "level": "building"},
43
+ {"code": "EK01A03FK01", "level": "building"},
44
+ {"code": "ek-01-a03-fk-01", "level": "building"},
45
+ {"code": "EK-00", "level": "lga"},
46
+ {"code": "EK-01", "level": "street"},
47
+ {"code": "EK-01", "level": "lga", "confidence": "certain"},
48
+ ],
49
+ )
50
+ def test_rejects_malformed_references(
51
+ schema: Draft202012Validator, reference: dict[str, str]
52
+ ) -> None:
53
+ assert not schema.is_valid(reference)
54
+
55
+
56
+ def test_every_prefix_of_a_code_fits_its_level(schema: Draft202012Validator) -> None:
57
+ code = Postcode("EK01A03FK01")
58
+ levels = {"state": Segment.STATE, "lga": Segment.LGA, "district": Segment.DISTRICT}
59
+ levels |= {"area": Segment.AREA, "building": Segment.UNIT}
60
+ for level, segment in levels.items():
61
+ schema.validate({"code": code.prefix(segment), "level": level})
62
+
63
+
64
+ def test_a_resolution_is_a_reference(schema: Draft202012Validator) -> None:
65
+ answer = Resolution(
66
+ status="partial",
67
+ code="EK-01-A03-FK",
68
+ level="area",
69
+ confidence="medium",
70
+ method="geocoded",
71
+ question="Which building is it?",
72
+ evidence=[],
73
+ )
74
+ schema.validate(answer.model_dump())
@@ -161,7 +161,7 @@ async def test_lookup_never_autocorrects_before_spending() -> None:
161
161
  async def test_online_tools_explain_a_missing_or_rejected_key() -> None:
162
162
  missing = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}, key=None)
163
163
  assert missing.is_error
164
- assert "needs NG_POSTCODE_API_KEY" in text(missing)
164
+ assert "needs a NIPOST API key" in text(missing)
165
165
 
166
166
  secret = "nipost_test_never_echo_me"
167
167
  rejected = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}, key=secret)
File without changes