ng-postcode-mcp 0.2.4__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.4
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
@@ -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.4"
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"
@@ -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.4",
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.4",
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
@@ -79,6 +80,9 @@ class Settings:
79
80
  base_url: str = BASE_URL
80
81
  geocoder_url: str | None = None
81
82
  geocoder_contact: str | None = None
83
+ transport: Literal["stdio", "http"] = "stdio"
84
+ host: str = "127.0.0.1"
85
+ port: int = 8000
82
86
 
83
87
 
84
88
  def settings_from_env(env: Mapping[str, str]) -> Settings | str:
@@ -90,12 +94,21 @@ def settings_from_env(env: Mapping[str, str]) -> Settings | str:
90
94
  geocoder_contact = env.get("NG_GEOCODER_CONTACT", "").strip() or None
91
95
  if geocoder_url == PUBLIC_NOMINATIM and geocoder_contact is None:
92
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}"
93
103
  return Settings(
94
104
  api_key=env.get("NG_POSTCODE_API_KEY", "").strip() or None,
95
105
  max_level=int(raw_level),
96
106
  base_url=env.get("NG_POSTCODE_BASE_URL", "").strip() or BASE_URL,
97
107
  geocoder_url=geocoder_url,
98
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),
99
112
  )
100
113
 
101
114
 
@@ -470,4 +483,8 @@ def main() -> None:
470
483
  sys.exit(f"ng-postcode-mcp: {settings}")
471
484
  # httpx logs every request URL at INFO, which would copy postcodes into client logs.
472
485
  logging.getLogger("httpx").setLevel(logging.WARNING)
473
- 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"
@@ -224,6 +224,14 @@ def test_output_models_only_use_fields_the_library_has(
224
224
  assert set(model.model_fields) <= {field.name for field in dataclasses.fields(source)}
225
225
 
226
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
+
227
235
  def test_settings_from_env() -> None:
228
236
  assert settings_from_env({}) == Settings(api_key=None)
229
237
  assert settings_from_env({"NG_POSTCODE_API_KEY": " k ", "NG_POSTCODE_MAX_LEVEL": "3"}) == (
File without changes