ng-postcode-mcp 0.2.4__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.
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/PKG-INFO +15 -1
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/README.md +14 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/pyproject.toml +2 -2
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/server.json +11 -2
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/src/ng_postcode_mcp/server.py +53 -14
- ng_postcode_mcp-0.4.0/tests/test_caller_key.py +94 -0
- ng_postcode_mcp-0.4.0/tests/test_http.py +49 -0
- ng_postcode_mcp-0.4.0/tests/test_reference_schema.py +74 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/tests/test_tools.py +9 -1
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/.gitignore +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/LICENSE +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/src/ng_postcode_mcp/__init__.py +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/src/ng_postcode_mcp/__main__.py +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/src/ng_postcode_mcp/py.typed +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/tests/test_metadata.py +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.0}/tests/test_resolve_tool.py +0 -0
- {ng_postcode_mcp-0.2.4 → ng_postcode_mcp-0.4.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.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
|
|
@@ -104,11 +104,25 @@ 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 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.
|
|
122
|
+
|
|
110
123
|
## Safety
|
|
111
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.
|
|
112
126
|
- Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
|
|
113
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.
|
|
114
128
|
- Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
|
|
@@ -76,11 +76,25 @@ 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 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.
|
|
94
|
+
|
|
82
95
|
## Safety
|
|
83
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.
|
|
84
98
|
- Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
|
|
85
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.
|
|
86
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.
|
|
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.
|
|
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.
|
|
16
|
+
"version": "0.4.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,9 @@
|
|
|
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, 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.
|
|
7
9
|
"""
|
|
8
10
|
|
|
9
11
|
from __future__ import annotations
|
|
@@ -33,12 +35,14 @@ from ng_postcode.api import (
|
|
|
33
35
|
lookup,
|
|
34
36
|
reverse,
|
|
35
37
|
)
|
|
36
|
-
from ng_postcode.client import AsyncClient, TransportError
|
|
38
|
+
from ng_postcode.client import TIMEOUT, AsyncClient, TransportError
|
|
37
39
|
from pydantic import BaseModel, ConfigDict, Field
|
|
38
40
|
|
|
39
41
|
T = TypeVar("T")
|
|
40
42
|
|
|
41
43
|
KEY_URL = "https://dashboard.postcode.gov.ng"
|
|
44
|
+
KEY_HEADER = "x-nipost-api-key"
|
|
45
|
+
MAX_KEY_LENGTH = 256
|
|
42
46
|
|
|
43
47
|
INSTRUCTIONS = """\
|
|
44
48
|
Tools for Nigeria's 11-character building postcode, e.g. EK-01-A03-FK-01 \
|
|
@@ -59,8 +63,8 @@ READ_ONLY_OFFLINE = ToolAnnotations(
|
|
|
59
63
|
READ_ONLY_ONLINE = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=True)
|
|
60
64
|
|
|
61
65
|
HINTS = {
|
|
62
|
-
"auth_required": f"
|
|
63
|
-
"invalid_api_key": f"
|
|
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}.",
|
|
64
68
|
"insufficient_credits": "The NIPOST account is out of credits; top up or use level 1.",
|
|
65
69
|
"level_not_granted": "The key is not granted this lookup level; use a lower level or "
|
|
66
70
|
f"request more access at {KEY_URL}.",
|
|
@@ -79,6 +83,9 @@ class Settings:
|
|
|
79
83
|
base_url: str = BASE_URL
|
|
80
84
|
geocoder_url: str | None = None
|
|
81
85
|
geocoder_contact: str | None = None
|
|
86
|
+
transport: Literal["stdio", "http"] = "stdio"
|
|
87
|
+
host: str = "127.0.0.1"
|
|
88
|
+
port: int = 8000
|
|
82
89
|
|
|
83
90
|
|
|
84
91
|
def settings_from_env(env: Mapping[str, str]) -> Settings | str:
|
|
@@ -90,12 +97,21 @@ def settings_from_env(env: Mapping[str, str]) -> Settings | str:
|
|
|
90
97
|
geocoder_contact = env.get("NG_GEOCODER_CONTACT", "").strip() or None
|
|
91
98
|
if geocoder_url == PUBLIC_NOMINATIM and geocoder_contact is None:
|
|
92
99
|
return "the public Nominatim requires NG_GEOCODER_CONTACT, a URL or email identifying you"
|
|
100
|
+
transport = env.get("NG_POSTCODE_TRANSPORT", "stdio").strip().lower()
|
|
101
|
+
if transport not in ("stdio", "http"):
|
|
102
|
+
return f"NG_POSTCODE_TRANSPORT must be stdio or http, got {transport!r}"
|
|
103
|
+
raw_port = env.get("NG_POSTCODE_PORT", "8000").strip()
|
|
104
|
+
if not (raw_port.isdecimal() and 0 < int(raw_port) < 65536):
|
|
105
|
+
return f"NG_POSTCODE_PORT must be 1 to 65535, got {raw_port!r}"
|
|
93
106
|
return Settings(
|
|
94
107
|
api_key=env.get("NG_POSTCODE_API_KEY", "").strip() or None,
|
|
95
108
|
max_level=int(raw_level),
|
|
96
109
|
base_url=env.get("NG_POSTCODE_BASE_URL", "").strip() or BASE_URL,
|
|
97
110
|
geocoder_url=geocoder_url,
|
|
98
111
|
geocoder_contact=geocoder_contact,
|
|
112
|
+
transport="http" if transport == "http" else "stdio",
|
|
113
|
+
host=env.get("NG_POSTCODE_HOST", "").strip() or "127.0.0.1",
|
|
114
|
+
port=int(raw_port),
|
|
99
115
|
)
|
|
100
116
|
|
|
101
117
|
|
|
@@ -188,8 +204,11 @@ class Location(FromLibrary):
|
|
|
188
204
|
|
|
189
205
|
@dataclass(frozen=True, slots=True)
|
|
190
206
|
class State:
|
|
191
|
-
"""What the tools share for the life of the server.
|
|
207
|
+
"""What the tools share for the life of the server. `nipost` and `geocoder` may be
|
|
208
|
+
unconfigured."""
|
|
192
209
|
|
|
210
|
+
http: httpx.AsyncClient
|
|
211
|
+
base_url: str
|
|
193
212
|
nipost: AsyncClient | None
|
|
194
213
|
geocoder: Nominatim | None
|
|
195
214
|
|
|
@@ -204,17 +223,18 @@ def create_server(
|
|
|
204
223
|
|
|
205
224
|
@asynccontextmanager
|
|
206
225
|
async def lifespan(_: MCPServer[State]) -> AsyncIterator[State]:
|
|
226
|
+
shared = http if http is not None else httpx.AsyncClient(timeout=TIMEOUT)
|
|
207
227
|
nipost = (
|
|
208
|
-
AsyncClient(settings.api_key, base_url=settings.base_url, http=
|
|
228
|
+
AsyncClient(settings.api_key, base_url=settings.base_url, http=shared)
|
|
209
229
|
if settings.api_key
|
|
210
230
|
else None
|
|
211
231
|
)
|
|
212
232
|
geocoder = geocoder_for(settings, geocoder_http)
|
|
213
233
|
try:
|
|
214
|
-
yield State(nipost, geocoder)
|
|
234
|
+
yield State(shared, settings.base_url, nipost, geocoder)
|
|
215
235
|
finally:
|
|
216
|
-
if
|
|
217
|
-
await
|
|
236
|
+
if http is None:
|
|
237
|
+
await shared.aclose()
|
|
218
238
|
if geocoder:
|
|
219
239
|
await geocoder.aclose()
|
|
220
240
|
|
|
@@ -353,8 +373,8 @@ def create_server(
|
|
|
353
373
|
if latitude is not None and longitude is not None
|
|
354
374
|
else None
|
|
355
375
|
)
|
|
356
|
-
|
|
357
|
-
resolver = Resolver(nipost=
|
|
376
|
+
geocoder = ctx.request_context.lifespan_context.geocoder
|
|
377
|
+
resolver = Resolver(nipost=nipost_for(ctx), geocoder=geocoder)
|
|
358
378
|
return await resolver.resolve(address, location, reading(landmarks, geocode_queries))
|
|
359
379
|
|
|
360
380
|
return server
|
|
@@ -448,10 +468,25 @@ def checked(text: str) -> Postcode:
|
|
|
448
468
|
raise ToolError(f"{text!r} is not a valid postcode: {error}.{maybe}")
|
|
449
469
|
|
|
450
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
|
+
|
|
451
483
|
def api(ctx: Context[State, Any]) -> AsyncClient:
|
|
452
|
-
client = ctx
|
|
484
|
+
client = nipost_for(ctx)
|
|
453
485
|
if client is None:
|
|
454
|
-
raise ToolError(
|
|
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
|
+
)
|
|
455
490
|
return client
|
|
456
491
|
|
|
457
492
|
|
|
@@ -470,4 +505,8 @@ def main() -> None:
|
|
|
470
505
|
sys.exit(f"ng-postcode-mcp: {settings}")
|
|
471
506
|
# httpx logs every request URL at INFO, which would copy postcodes into client logs.
|
|
472
507
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
473
|
-
create_server(settings)
|
|
508
|
+
server = create_server(settings)
|
|
509
|
+
if settings.transport == "http":
|
|
510
|
+
server.run("streamable-http", host=settings.host, port=settings.port)
|
|
511
|
+
else:
|
|
512
|
+
server.run()
|
|
@@ -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,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"
|
|
@@ -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
|
|
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)
|
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|