ng-postcode-mcp 0.3.0__tar.gz → 0.4.1__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.
@@ -6,3 +6,4 @@ dist/
6
6
  .mypy_cache/
7
7
  .ruff_cache/
8
8
  .pytest_cache/
9
+ *.mcpb
@@ -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.1
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
@@ -95,6 +95,10 @@ Most clients take this entry in their MCP settings:
95
95
  | VS Code | The same server object in `.vscode/mcp.json`, under a top-level `"servers"` key instead of `"mcpServers"` |
96
96
  | Others | Any client that launches stdio servers: use the command, arguments and environment above |
97
97
 
98
+ ### As a bundle
99
+
100
+ [`bundle/`](bundle) holds an MCPB manifest for clients that install `.mcpb` files, such as Claude Desktop. It runs the published package with uv and asks for the API key in the client's own settings. Build it with `npx @anthropic-ai/mcpb pack mcp/bundle ng-postcode.mcpb` from the repository root. [`bundle-python/`](bundle-python) is the same bundle for hosts that only run Python bundles, such as Smithery; it starts the server with `uvx`, so uv must be installed. `scripts/smithery_bundle.py` builds the copy Smithery accepts, which also carries each tool's input schema.
101
+
98
102
  ## Configuration
99
103
 
100
104
  | Variable | Default | Purpose |
@@ -116,10 +120,13 @@ NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mc
116
120
  docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
117
121
  ```
118
122
 
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.
123
+ 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.
124
+
125
+ 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
126
 
121
127
  ## Safety
122
128
 
129
+ - 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
130
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
124
131
  - 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
132
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
@@ -67,6 +67,10 @@ Most clients take this entry in their MCP settings:
67
67
  | VS Code | The same server object in `.vscode/mcp.json`, under a top-level `"servers"` key instead of `"mcpServers"` |
68
68
  | Others | Any client that launches stdio servers: use the command, arguments and environment above |
69
69
 
70
+ ### As a bundle
71
+
72
+ [`bundle/`](bundle) holds an MCPB manifest for clients that install `.mcpb` files, such as Claude Desktop. It runs the published package with uv and asks for the API key in the client's own settings. Build it with `npx @anthropic-ai/mcpb pack mcp/bundle ng-postcode.mcpb` from the repository root. [`bundle-python/`](bundle-python) is the same bundle for hosts that only run Python bundles, such as Smithery; it starts the server with `uvx`, so uv must be installed. `scripts/smithery_bundle.py` builds the copy Smithery accepts, which also carries each tool's input schema.
73
+
70
74
  ## Configuration
71
75
 
72
76
  | Variable | Default | Purpose |
@@ -88,10 +92,13 @@ NG_POSTCODE_TRANSPORT=http uvx ng-postcode-mcp # http://127.0.0.1:8000/mc
88
92
  docker build -t ng-postcode-mcp . && docker run --rm -i ng-postcode-mcp # from the repository root
89
93
  ```
90
94
 
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.
95
+ 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.
96
+
97
+ 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
98
 
93
99
  ## Safety
94
100
 
101
+ - 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
102
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
96
103
  - 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
104
  - 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.1"
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.1",
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.1",
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
 
@@ -304,8 +311,12 @@ def create_server(
304
311
  structured_output=True,
305
312
  )
306
313
  async def find_postcode_at_location(
307
- latitude: Annotated[float, Field(ge=-90, le=90)],
308
- longitude: Annotated[float, Field(ge=-180, le=180)],
314
+ latitude: Annotated[
315
+ float, Field(ge=-90, le=90, description="Latitude in decimal degrees, e.g. 7.6211.")
316
+ ],
317
+ longitude: Annotated[
318
+ float, Field(ge=-180, le=180, description="Longitude in decimal degrees, e.g. 5.2214.")
319
+ ],
309
320
  ctx: Context[State, Any],
310
321
  max_distance_m: Annotated[
311
322
  float | None,
@@ -346,9 +357,13 @@ def create_server(
346
357
  ),
347
358
  ] = None,
348
359
  latitude: Annotated[
349
- float | None, Field(ge=-90, le=90, description="A location pin the user shared.")
360
+ float | None,
361
+ Field(ge=-90, le=90, description="Latitude of a location pin the user shared."),
362
+ ] = None,
363
+ longitude: Annotated[
364
+ float | None,
365
+ Field(ge=-180, le=180, description="Longitude of that pin. Give it with latitude."),
350
366
  ] = None,
351
- longitude: Annotated[float | None, Field(ge=-180, le=180)] = None,
352
367
  ) -> Resolution:
353
368
  """Turn a described Nigerian address into a postcode, only as precisely as the
354
369
  evidence allows.
@@ -366,8 +381,8 @@ def create_server(
366
381
  if latitude is not None and longitude is not None
367
382
  else None
368
383
  )
369
- state = ctx.request_context.lifespan_context
370
- resolver = Resolver(nipost=state.nipost, geocoder=state.geocoder)
384
+ geocoder = ctx.request_context.lifespan_context.geocoder
385
+ resolver = Resolver(nipost=nipost_for(ctx), geocoder=geocoder)
371
386
  return await resolver.resolve(address, location, reading(landmarks, geocode_queries))
372
387
 
373
388
  return server
@@ -461,10 +476,25 @@ def checked(text: str) -> Postcode:
461
476
  raise ToolError(f"{text!r} is not a valid postcode: {error}.{maybe}")
462
477
 
463
478
 
479
+ def nipost_for(ctx: Context[State, Any]) -> AsyncClient | None:
480
+ """The caller's own client when an HTTP request carries a key, else the server's."""
481
+ state = ctx.request_context.lifespan_context
482
+ sent = (v for k, v in (ctx.headers or {}).items() if k.lower() == KEY_HEADER)
483
+ key = next(sent, "").strip()
484
+ if not key:
485
+ return state.nipost
486
+ if not (key.isascii() and key.isprintable() and " " not in key and len(key) <= MAX_KEY_LENGTH):
487
+ raise ToolError("The X-NIPOST-API-Key header does not hold a usable NIPOST API key.")
488
+ return AsyncClient(key, base_url=state.base_url, http=state.http)
489
+
490
+
464
491
  def api(ctx: Context[State, Any]) -> AsyncClient:
465
- client = ctx.request_context.lifespan_context.nipost
492
+ client = nipost_for(ctx)
466
493
  if client is None:
467
- raise ToolError(f"This tool needs NG_POSTCODE_API_KEY. {HINTS['auth_required']}")
494
+ raise ToolError(
495
+ "This tool needs a NIPOST API key: set NG_POSTCODE_API_KEY on the server, or over "
496
+ f"HTTP send your own in the X-NIPOST-API-Key header. Get one at {KEY_URL}."
497
+ )
468
498
  return client
469
499
 
470
500
 
@@ -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 == []
@@ -29,3 +29,23 @@ def test_readme_proves_registry_ownership() -> None:
29
29
 
30
30
  def test_registry_description_fits() -> None:
31
31
  assert len(server_json()["description"]) <= 100
32
+
33
+
34
+ def test_the_bundle_pins_this_version() -> None:
35
+ bundle = ROOT / "bundle"
36
+ if not bundle.exists(): # the bundle lives in the repository, not the sdist
37
+ return
38
+ released = version("ng-postcode-mcp")
39
+ manifest: dict[str, Any] = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
40
+ assert manifest["version"] == released
41
+ assert f'"ng-postcode-mcp=={released}"' in (bundle / "pyproject.toml").read_text()
42
+
43
+
44
+ def test_the_python_bundle_pins_this_version() -> None:
45
+ bundle = ROOT / "bundle-python"
46
+ if not bundle.exists(): # the bundle lives in the repository, not the sdist
47
+ return
48
+ released = version("ng-postcode-mcp")
49
+ manifest: dict[str, Any] = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
50
+ assert manifest["version"] == released
51
+ assert f'"ng-postcode-mcp=={released}"' in (bundle / "server" / "main.py").read_text()
@@ -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