ng-postcode-mcp 0.1.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.1.0/.gitignore +8 -0
- ng_postcode_mcp-0.1.0/LICENSE +21 -0
- ng_postcode_mcp-0.1.0/PKG-INFO +87 -0
- ng_postcode_mcp-0.1.0/README.md +60 -0
- ng_postcode_mcp-0.1.0/pyproject.toml +60 -0
- ng_postcode_mcp-0.1.0/server.json +37 -0
- ng_postcode_mcp-0.1.0/src/ng_postcode_mcp/__init__.py +5 -0
- ng_postcode_mcp-0.1.0/src/ng_postcode_mcp/__main__.py +3 -0
- ng_postcode_mcp-0.1.0/src/ng_postcode_mcp/py.typed +0 -0
- ng_postcode_mcp-0.1.0/src/ng_postcode_mcp/server.py +344 -0
- ng_postcode_mcp-0.1.0/tests/test_metadata.py +31 -0
- ng_postcode_mcp-0.1.0/tests/test_stdio.py +25 -0
- ng_postcode_mcp-0.1.0/tests/test_tools.py +209 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kayode Adeniyi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: ng-postcode-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up and reverse-geocode postcodes.
|
|
5
|
+
Project-URL: Repository, https://github.com/Adeniyikayodee/ng-postcode
|
|
6
|
+
Project-URL: Issues, https://github.com/Adeniyikayodee/ng-postcode/issues
|
|
7
|
+
Author: Kayode Adeniyi
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai-agents,geocoding,mcp,mcp-server,model-context-protocol,ndaps,nigeria,nipost,postcode
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: mcp<3,>=2.3
|
|
25
|
+
Requires-Dist: ng-postcode[client]<0.2,>=0.1
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# ng-postcode-mcp
|
|
29
|
+
|
|
30
|
+
MCP server for Nigeria's National Digital Alphanumeric Postcode System (NDAPS), the building-level postcode NIPOST launched in October 2026. It lets AI assistants validate postcodes offline and look them up, autocomplete them and find them by location through the [postcode.gov.ng](https://docs.postcode.gov.ng) API.
|
|
31
|
+
|
|
32
|
+
Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) library.
|
|
33
|
+
|
|
34
|
+
<!-- mcp-name: io.github.Adeniyikayodee/ng-postcode -->
|
|
35
|
+
|
|
36
|
+
## Tools
|
|
37
|
+
|
|
38
|
+
| Tool | What it does | Needs a key | Cost |
|
|
39
|
+
| --- | --- | --- | --- |
|
|
40
|
+
| `validate_postcode` | Checks structure offline; returns canonical forms, segments and a suggested fix for look-alike characters | No | Free |
|
|
41
|
+
| `lookup_postcode` | Confirms a code is assigned; level 2 adds the address, level 3 building use | Yes | Level 1 free, 2+ uses credits |
|
|
42
|
+
| `autocomplete_postcode` | Suggests the next segment of a partly typed code | Yes | Free tier |
|
|
43
|
+
| `find_postcode_at_location` | Returns the postcode of the nearest building to a coordinate | Yes | Free tier |
|
|
44
|
+
|
|
45
|
+
All tools are read-only. Errors come back as messages the model can act on, such as a missing key or an exhausted credit balance.
|
|
46
|
+
|
|
47
|
+
## Install
|
|
48
|
+
|
|
49
|
+
Requires [uv](https://docs.astral.sh/uv/). Get an API key from the [NIPOST developer dashboard](https://dashboard.postcode.gov.ng); `validate_postcode` works without one.
|
|
50
|
+
|
|
51
|
+
**Claude Code**
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
claude mcp add ng-postcode -e NG_POSTCODE_API_KEY=nipost_live_... -- uvx ng-postcode-mcp
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**Claude Desktop, Cursor and other clients** that use an `mcpServers` config:
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"mcpServers": {
|
|
62
|
+
"ng-postcode": {
|
|
63
|
+
"command": "uvx",
|
|
64
|
+
"args": ["ng-postcode-mcp"],
|
|
65
|
+
"env": { "NG_POSTCODE_API_KEY": "nipost_live_..." }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Configuration
|
|
72
|
+
|
|
73
|
+
| Variable | Default | Purpose |
|
|
74
|
+
| --- | --- | --- |
|
|
75
|
+
| `NG_POSTCODE_API_KEY` | none | NIPOST API key. Read from the environment only; never passed through tools. |
|
|
76
|
+
| `NG_POSTCODE_MAX_LEVEL` | `1` | Highest lookup level tools may request. Levels 2+ consume credits, so raise it deliberately. |
|
|
77
|
+
| `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
|
|
78
|
+
|
|
79
|
+
## Safety
|
|
80
|
+
|
|
81
|
+
- Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
|
|
82
|
+
- 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.
|
|
83
|
+
- Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ng-postcode-mcp
|
|
2
|
+
|
|
3
|
+
MCP server for Nigeria's National Digital Alphanumeric Postcode System (NDAPS), the building-level postcode NIPOST launched in October 2026. It lets AI assistants validate postcodes offline and look them up, autocomplete them and find them by location through the [postcode.gov.ng](https://docs.postcode.gov.ng) API.
|
|
4
|
+
|
|
5
|
+
Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) library.
|
|
6
|
+
|
|
7
|
+
<!-- mcp-name: io.github.Adeniyikayodee/ng-postcode -->
|
|
8
|
+
|
|
9
|
+
## Tools
|
|
10
|
+
|
|
11
|
+
| Tool | What it does | Needs a key | Cost |
|
|
12
|
+
| --- | --- | --- | --- |
|
|
13
|
+
| `validate_postcode` | Checks structure offline; returns canonical forms, segments and a suggested fix for look-alike characters | No | Free |
|
|
14
|
+
| `lookup_postcode` | Confirms a code is assigned; level 2 adds the address, level 3 building use | Yes | Level 1 free, 2+ uses credits |
|
|
15
|
+
| `autocomplete_postcode` | Suggests the next segment of a partly typed code | Yes | Free tier |
|
|
16
|
+
| `find_postcode_at_location` | Returns the postcode of the nearest building to a coordinate | Yes | Free tier |
|
|
17
|
+
|
|
18
|
+
All tools are read-only. Errors come back as messages the model can act on, such as a missing key or an exhausted credit balance.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
Requires [uv](https://docs.astral.sh/uv/). Get an API key from the [NIPOST developer dashboard](https://dashboard.postcode.gov.ng); `validate_postcode` works without one.
|
|
23
|
+
|
|
24
|
+
**Claude Code**
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
claude mcp add ng-postcode -e NG_POSTCODE_API_KEY=nipost_live_... -- uvx ng-postcode-mcp
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
**Claude Desktop, Cursor and other clients** that use an `mcpServers` config:
|
|
31
|
+
|
|
32
|
+
```json
|
|
33
|
+
{
|
|
34
|
+
"mcpServers": {
|
|
35
|
+
"ng-postcode": {
|
|
36
|
+
"command": "uvx",
|
|
37
|
+
"args": ["ng-postcode-mcp"],
|
|
38
|
+
"env": { "NG_POSTCODE_API_KEY": "nipost_live_..." }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configuration
|
|
45
|
+
|
|
46
|
+
| Variable | Default | Purpose |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `NG_POSTCODE_API_KEY` | none | NIPOST API key. Read from the environment only; never passed through tools. |
|
|
49
|
+
| `NG_POSTCODE_MAX_LEVEL` | `1` | Highest lookup level tools may request. Levels 2+ consume credits, so raise it deliberately. |
|
|
50
|
+
| `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
|
|
51
|
+
|
|
52
|
+
## Safety
|
|
53
|
+
|
|
54
|
+
- Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
|
|
55
|
+
- 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.
|
|
56
|
+
- Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ng-postcode-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up and reverse-geocode postcodes."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "Kayode Adeniyi" }]
|
|
14
|
+
keywords = ["mcp", "mcp-server", "model-context-protocol", "ai-agents", "nigeria", "postcode", "nipost", "ndaps", "geocoding"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
21
|
+
"Programming Language :: Python :: 3.10",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"Programming Language :: Python :: 3.14",
|
|
26
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
27
|
+
"Typing :: Typed",
|
|
28
|
+
]
|
|
29
|
+
dependencies = ["mcp>=2.3,<3", "ng-postcode[client]>=0.1,<0.2"]
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
ng-postcode-mcp = "ng_postcode_mcp.server:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/Adeniyikayodee/ng-postcode"
|
|
36
|
+
Issues = "https://github.com/Adeniyikayodee/ng-postcode/issues"
|
|
37
|
+
|
|
38
|
+
[dependency-groups]
|
|
39
|
+
dev = ["jsonschema>=4.20", "mypy>=1.13", "pytest>=8", "ruff>=0.8"]
|
|
40
|
+
|
|
41
|
+
# Inside the repository, build against the local library so the two never drift.
|
|
42
|
+
[tool.uv.sources]
|
|
43
|
+
ng-postcode = { path = "../python", editable = true }
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.sdist]
|
|
46
|
+
only-include = ["src", "tests", "README.md", "LICENSE", "server.json"]
|
|
47
|
+
|
|
48
|
+
[tool.ruff]
|
|
49
|
+
line-length = 100
|
|
50
|
+
target-version = "py310"
|
|
51
|
+
|
|
52
|
+
[tool.ruff.lint]
|
|
53
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "PT"]
|
|
54
|
+
|
|
55
|
+
[tool.mypy]
|
|
56
|
+
strict = true
|
|
57
|
+
files = ["src", "tests"]
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.Adeniyikayodee/ng-postcode",
|
|
4
|
+
"title": "Nigeria Postcode",
|
|
5
|
+
"description": "Validate, look up and reverse-geocode Nigeria's NIPOST digital postcodes (NDAPS).",
|
|
6
|
+
"version": "0.1.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"url": "https://github.com/Adeniyikayodee/ng-postcode",
|
|
9
|
+
"source": "github",
|
|
10
|
+
"subfolder": "mcp"
|
|
11
|
+
},
|
|
12
|
+
"packages": [
|
|
13
|
+
{
|
|
14
|
+
"registryType": "pypi",
|
|
15
|
+
"identifier": "ng-postcode-mcp",
|
|
16
|
+
"version": "0.1.0",
|
|
17
|
+
"runtimeHint": "uvx",
|
|
18
|
+
"transport": {
|
|
19
|
+
"type": "stdio"
|
|
20
|
+
},
|
|
21
|
+
"environmentVariables": [
|
|
22
|
+
{
|
|
23
|
+
"name": "NG_POSTCODE_API_KEY",
|
|
24
|
+
"description": "NIPOST Postcode API key from dashboard.postcode.gov.ng. Validation works without it.",
|
|
25
|
+
"isRequired": false,
|
|
26
|
+
"isSecret": true
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"name": "NG_POSTCODE_MAX_LEVEL",
|
|
30
|
+
"description": "Highest lookup level tools may request. Levels 2 and up consume NIPOST credits.",
|
|
31
|
+
"default": "1",
|
|
32
|
+
"choices": ["1", "2", "3", "4", "5"]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""MCP server for Nigeria's NIPOST digital postcode.
|
|
2
|
+
|
|
3
|
+
Validation runs offline. Lookup, autocomplete and reverse geocoding call the
|
|
4
|
+
postcode.gov.ng API with the key in NG_POSTCODE_API_KEY, which never appears in
|
|
5
|
+
tool arguments or results. stdout carries the protocol, so nothing else may
|
|
6
|
+
print to it.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
from collections.abc import AsyncIterator, Mapping
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from importlib.metadata import version
|
|
18
|
+
from typing import Annotated, Any, Literal, TypeVar
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from mcp.server.mcpserver import Context, MCPServer
|
|
22
|
+
from mcp.server.mcpserver.exceptions import ToolError
|
|
23
|
+
from mcp.types import ToolAnnotations
|
|
24
|
+
from ng_postcode import Corrected, Postcode, parse, parse_lenient
|
|
25
|
+
from ng_postcode.api import (
|
|
26
|
+
BASE_URL,
|
|
27
|
+
ApiError,
|
|
28
|
+
Autocomplete,
|
|
29
|
+
Coordinate,
|
|
30
|
+
autocomplete,
|
|
31
|
+
lookup,
|
|
32
|
+
reverse,
|
|
33
|
+
)
|
|
34
|
+
from ng_postcode.client import AsyncClient, TransportError
|
|
35
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
36
|
+
|
|
37
|
+
T = TypeVar("T")
|
|
38
|
+
|
|
39
|
+
KEY_URL = "https://dashboard.postcode.gov.ng"
|
|
40
|
+
|
|
41
|
+
INSTRUCTIONS = """\
|
|
42
|
+
Tools for Nigeria's 11-character building postcode, e.g. EK-01-A03-FK-01 \
|
|
43
|
+
(state, LGA, district, area, building unit).
|
|
44
|
+
- validate_postcode is offline and free: use it first on any code a user typed.
|
|
45
|
+
- lookup_postcode level 1 only confirms a code exists. Levels 2 and up add the \
|
|
46
|
+
address and building details, consume NIPOST credits, and are capped by the \
|
|
47
|
+
server's NG_POSTCODE_MAX_LEVEL.
|
|
48
|
+
- Never substitute a suggested correction without confirming it with the user.
|
|
49
|
+
- Addresses returned are personal data: use them only for the user's request."""
|
|
50
|
+
|
|
51
|
+
READ_ONLY_OFFLINE = ToolAnnotations(
|
|
52
|
+
read_only_hint=True, idempotent_hint=True, open_world_hint=False
|
|
53
|
+
)
|
|
54
|
+
READ_ONLY_ONLINE = ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=True)
|
|
55
|
+
|
|
56
|
+
HINTS = {
|
|
57
|
+
"auth_required": f"Set NG_POSTCODE_API_KEY to a key from {KEY_URL}.",
|
|
58
|
+
"invalid_api_key": f"NG_POSTCODE_API_KEY is invalid or revoked; create a new key at {KEY_URL}.",
|
|
59
|
+
"insufficient_credits": "The NIPOST account is out of credits; top up or use level 1.",
|
|
60
|
+
}
|
|
61
|
+
STATUS_HINTS = {
|
|
62
|
+
403: "The key lacks the scope or access level for this request.",
|
|
63
|
+
429: "NIPOST rate limit reached; wait before retrying.",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True, slots=True)
|
|
68
|
+
class Settings:
|
|
69
|
+
api_key: str | None
|
|
70
|
+
max_level: int = 1
|
|
71
|
+
base_url: str = BASE_URL
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def settings_from_env(env: Mapping[str, str]) -> Settings | str:
|
|
75
|
+
"""Read settings from the environment, or describe what is wrong with them."""
|
|
76
|
+
raw_level = env.get("NG_POSTCODE_MAX_LEVEL", "1").strip()
|
|
77
|
+
if raw_level not in {"1", "2", "3", "4", "5"}:
|
|
78
|
+
return f"NG_POSTCODE_MAX_LEVEL must be 1 to 5, got {raw_level!r}"
|
|
79
|
+
return Settings(
|
|
80
|
+
api_key=env.get("NG_POSTCODE_API_KEY", "").strip() or None,
|
|
81
|
+
max_level=int(raw_level),
|
|
82
|
+
base_url=env.get("NG_POSTCODE_BASE_URL", "").strip() or BASE_URL,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class Segments(BaseModel):
|
|
87
|
+
state: str
|
|
88
|
+
lga: str
|
|
89
|
+
district: str
|
|
90
|
+
area: str
|
|
91
|
+
unit: str
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class Validation(BaseModel):
|
|
95
|
+
valid: bool = Field(description="Whether the code is well formed. It may still be unassigned.")
|
|
96
|
+
postcode: str | None = Field(description="Canonical form, e.g. EK-01-A03-FK-01.")
|
|
97
|
+
compact: str | None = Field(description="Compact form for storage, e.g. EK01A03FK01.")
|
|
98
|
+
spaced: str | None = Field(description="Form shown to people, e.g. EK 01 A03 FK 01.")
|
|
99
|
+
segments: Segments | None
|
|
100
|
+
error: str | None = Field(description="Why the code is malformed.")
|
|
101
|
+
suggestion: str | None = Field(
|
|
102
|
+
description="A well-formed code if look-alike characters (O/0, I/1, S/5, B/8) were the "
|
|
103
|
+
"only problem. Confirm it with the user before using it."
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# Explicit output contracts. The SDK cannot derive schemas from the library's slotted
|
|
108
|
+
# dataclasses, and named fields with descriptions serve agents better anyway.
|
|
109
|
+
# tests/test_tools.py checks every field still exists in ng_postcode.api.
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class FromLibrary(BaseModel):
|
|
113
|
+
model_config = ConfigDict(from_attributes=True)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Address(FromLibrary):
|
|
117
|
+
state_name: str | None
|
|
118
|
+
lga_name: str | None
|
|
119
|
+
locality_name: str | None
|
|
120
|
+
zone: str | None = Field(description="Geopolitical zone, e.g. SOUTH WEST.")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class PostcodeDetails(FromLibrary):
|
|
124
|
+
postcode: str
|
|
125
|
+
valid: bool = Field(description="Whether the code is assigned to a building.")
|
|
126
|
+
administrative_address: Address | None = Field(description="Level 2 and up.")
|
|
127
|
+
recent_house_address: str | None = Field(description="Level 2 and up. Personal data.")
|
|
128
|
+
building_use_status: str | None = Field(description="Level 3 and up, e.g. residential.")
|
|
129
|
+
other_building_info: Any = Field(default=None, description="Level 4 and up, unstructured.")
|
|
130
|
+
point_geometry: Any = Field(default=None, description="Level 5, unstructured.")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class Completion(FromLibrary):
|
|
134
|
+
code: str
|
|
135
|
+
label: str
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class Completions(BaseModel):
|
|
139
|
+
segment: Literal["state", "lga", "district", "area", "unit"] | None = Field(
|
|
140
|
+
description="The segment being completed."
|
|
141
|
+
)
|
|
142
|
+
suggestions: list[Completion]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class NearestBuilding(FromLibrary):
|
|
146
|
+
postcode: str
|
|
147
|
+
display: str
|
|
148
|
+
distance_m: float | None
|
|
149
|
+
confidence: str | None = Field(description="high, medium or low, graded by distance.")
|
|
150
|
+
state_name: str | None = Field(description="Level 2 and up.")
|
|
151
|
+
lga_name: str | None = Field(description="Level 2 and up.")
|
|
152
|
+
locality_name: str | None = Field(description="Level 2 and up.")
|
|
153
|
+
address: str | None = Field(description="Recent house address. Level 2 and up.")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
class Location(FromLibrary):
|
|
157
|
+
found: bool = Field(description="False when no building is within the radius.")
|
|
158
|
+
unit: NearestBuilding | None
|
|
159
|
+
area: str | None = Field(description="Enclosing area code, e.g. EK-01-A03-FK.")
|
|
160
|
+
district: str | None
|
|
161
|
+
state: str | None
|
|
162
|
+
message: str | None
|
|
163
|
+
radius_m: float | None = Field(description="The radius the API actually applied.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
Api = AsyncClient | None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def create_server(settings: Settings, http: httpx.AsyncClient | None = None) -> MCPServer[Api]:
|
|
170
|
+
"""Build the server. Pass `http` to route API calls through your own client, as tests do."""
|
|
171
|
+
|
|
172
|
+
@asynccontextmanager
|
|
173
|
+
async def lifespan(_: MCPServer[Api]) -> AsyncIterator[Api]:
|
|
174
|
+
if settings.api_key is None:
|
|
175
|
+
yield None
|
|
176
|
+
return
|
|
177
|
+
client = AsyncClient(settings.api_key, base_url=settings.base_url, http=http)
|
|
178
|
+
try:
|
|
179
|
+
yield client
|
|
180
|
+
finally:
|
|
181
|
+
await client.aclose()
|
|
182
|
+
|
|
183
|
+
server: MCPServer[Api] = MCPServer(
|
|
184
|
+
name="ng-postcode",
|
|
185
|
+
title="Nigeria Postcode",
|
|
186
|
+
instructions=INSTRUCTIONS,
|
|
187
|
+
version=version("ng-postcode-mcp"),
|
|
188
|
+
lifespan=lifespan,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
@server.tool(
|
|
192
|
+
title="Validate a Nigerian postcode",
|
|
193
|
+
annotations=READ_ONLY_OFFLINE,
|
|
194
|
+
structured_output=True,
|
|
195
|
+
)
|
|
196
|
+
def validate_postcode(
|
|
197
|
+
postcode: Annotated[str, Field(description="Code in any style, e.g. 'ek 01 a03 fk 01'.")],
|
|
198
|
+
) -> Validation:
|
|
199
|
+
"""Check a postcode's structure offline and return its canonical forms and segments.
|
|
200
|
+
|
|
201
|
+
Free and instant. Does not confirm the code is assigned to a building; use
|
|
202
|
+
lookup_postcode for that.
|
|
203
|
+
"""
|
|
204
|
+
return validation(postcode)
|
|
205
|
+
|
|
206
|
+
@server.tool(
|
|
207
|
+
title="Look up a Nigerian postcode",
|
|
208
|
+
annotations=READ_ONLY_ONLINE,
|
|
209
|
+
structured_output=True,
|
|
210
|
+
)
|
|
211
|
+
async def lookup_postcode(
|
|
212
|
+
postcode: Annotated[str, Field(description="Code in any style, e.g. EK-01-A03-FK-01.")],
|
|
213
|
+
ctx: Context[Api, Any],
|
|
214
|
+
level: Annotated[
|
|
215
|
+
int,
|
|
216
|
+
Field(
|
|
217
|
+
ge=1,
|
|
218
|
+
le=5,
|
|
219
|
+
description="1: validity only (free). 2: adds the administrative and recent "
|
|
220
|
+
"house address. 3: adds building use. Levels 2+ consume NIPOST credits.",
|
|
221
|
+
),
|
|
222
|
+
] = 1,
|
|
223
|
+
) -> PostcodeDetails:
|
|
224
|
+
"""Confirm a postcode is assigned and, at higher levels, return its address details."""
|
|
225
|
+
if level > settings.max_level:
|
|
226
|
+
raise ToolError(
|
|
227
|
+
f"Level {level} is above this server's cap of {settings.max_level}. Levels 2+ "
|
|
228
|
+
"consume NIPOST credits; the user can raise NG_POSTCODE_MAX_LEVEL to allow it."
|
|
229
|
+
)
|
|
230
|
+
result = await api(ctx).send(lookup(checked(postcode), level))
|
|
231
|
+
return PostcodeDetails.model_validate(unwrap(result))
|
|
232
|
+
|
|
233
|
+
@server.tool(
|
|
234
|
+
title="Autocomplete a Nigerian postcode",
|
|
235
|
+
annotations=READ_ONLY_ONLINE,
|
|
236
|
+
structured_output=True,
|
|
237
|
+
)
|
|
238
|
+
async def autocomplete_postcode(
|
|
239
|
+
partial: Annotated[str, Field(description="The start of a code, e.g. 'EK 01 A'.")],
|
|
240
|
+
ctx: Context[Api, Any],
|
|
241
|
+
) -> Completions:
|
|
242
|
+
"""Suggest completions for the next segment of a partly typed postcode."""
|
|
243
|
+
result = await api(ctx).send(autocomplete(partial))
|
|
244
|
+
return completions(unwrap(result))
|
|
245
|
+
|
|
246
|
+
@server.tool(
|
|
247
|
+
title="Find the postcode at a location",
|
|
248
|
+
annotations=READ_ONLY_ONLINE,
|
|
249
|
+
structured_output=True,
|
|
250
|
+
)
|
|
251
|
+
async def find_postcode_at_location(
|
|
252
|
+
latitude: Annotated[float, Field(ge=-90, le=90)],
|
|
253
|
+
longitude: Annotated[float, Field(ge=-180, le=180)],
|
|
254
|
+
ctx: Context[Api, Any],
|
|
255
|
+
max_distance_m: Annotated[
|
|
256
|
+
float | None,
|
|
257
|
+
Field(ge=0, le=250, description="Search radius in metres. Defaults to 25."),
|
|
258
|
+
] = None,
|
|
259
|
+
) -> Location:
|
|
260
|
+
"""Return the postcode of the nearest building to a coordinate in Nigeria."""
|
|
261
|
+
result = await api(ctx).send(
|
|
262
|
+
reverse(Coordinate(lat=latitude, lng=longitude), max_distance_m)
|
|
263
|
+
)
|
|
264
|
+
return Location.model_validate(unwrap(result))
|
|
265
|
+
|
|
266
|
+
return server
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def completions(found: Autocomplete) -> Completions:
|
|
270
|
+
return Completions(
|
|
271
|
+
segment=None if found.segment is None else found.segment.value,
|
|
272
|
+
suggestions=[Completion.model_validate(s) for s in found.suggestions],
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def validation(text: str) -> Validation:
|
|
277
|
+
match parse(text):
|
|
278
|
+
case Postcode() as code:
|
|
279
|
+
return Validation(
|
|
280
|
+
valid=True,
|
|
281
|
+
postcode=str(code),
|
|
282
|
+
compact=code.compact,
|
|
283
|
+
spaced=code.spaced,
|
|
284
|
+
segments=Segments(
|
|
285
|
+
state=code.state,
|
|
286
|
+
lga=code.lga,
|
|
287
|
+
district=code.district,
|
|
288
|
+
area=code.area,
|
|
289
|
+
unit=code.unit,
|
|
290
|
+
),
|
|
291
|
+
error=None,
|
|
292
|
+
suggestion=None,
|
|
293
|
+
)
|
|
294
|
+
case error:
|
|
295
|
+
return Validation(
|
|
296
|
+
valid=False,
|
|
297
|
+
postcode=None,
|
|
298
|
+
compact=None,
|
|
299
|
+
spaced=None,
|
|
300
|
+
segments=None,
|
|
301
|
+
error=str(error),
|
|
302
|
+
suggestion=suggestion(text),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def suggestion(text: str) -> str | None:
|
|
307
|
+
fixed = parse_lenient(text)
|
|
308
|
+
return str(fixed.postcode) if isinstance(fixed, Corrected) else None
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def checked(text: str) -> Postcode:
|
|
312
|
+
"""The parsed code, or a ToolError the model can act on. Never auto-corrects a paid call."""
|
|
313
|
+
match parse(text):
|
|
314
|
+
case Postcode() as code:
|
|
315
|
+
return code
|
|
316
|
+
case error:
|
|
317
|
+
hint = suggestion(text)
|
|
318
|
+
maybe = f" Did you mean {hint}? Confirm with the user first." if hint else ""
|
|
319
|
+
raise ToolError(f"{text!r} is not a valid postcode: {error}.{maybe}")
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def api(ctx: Context[Api, Any]) -> AsyncClient:
|
|
323
|
+
client = ctx.request_context.lifespan_context
|
|
324
|
+
if client is None:
|
|
325
|
+
raise ToolError(f"This tool needs NG_POSTCODE_API_KEY. {HINTS['auth_required']}")
|
|
326
|
+
return client
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def unwrap(result: T | ApiError | TransportError) -> T:
|
|
330
|
+
if isinstance(result, TransportError):
|
|
331
|
+
raise ToolError(f"Could not reach the NIPOST API: {result}")
|
|
332
|
+
if isinstance(result, ApiError):
|
|
333
|
+
hint = HINTS.get(result.code) or STATUS_HINTS.get(result.status, "")
|
|
334
|
+
raise ToolError(f"NIPOST API error {result}. {hint}".strip())
|
|
335
|
+
return result
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def main() -> None:
|
|
339
|
+
settings = settings_from_env(os.environ)
|
|
340
|
+
if isinstance(settings, str):
|
|
341
|
+
sys.exit(f"ng-postcode-mcp: {settings}")
|
|
342
|
+
# httpx logs every request URL at INFO, which would copy postcodes into client logs.
|
|
343
|
+
logging.getLogger("httpx").setLevel(logging.WARNING)
|
|
344
|
+
create_server(settings).run()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Release metadata must agree, or the registry rejects the publish."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib.metadata import version
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def server_json() -> dict[str, Any]:
|
|
14
|
+
data: dict[str, Any] = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
|
|
15
|
+
return data
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_versions_and_names_agree() -> None:
|
|
19
|
+
server = server_json()
|
|
20
|
+
package = server["packages"][0]
|
|
21
|
+
assert package["identifier"] == "ng-postcode-mcp"
|
|
22
|
+
assert server["version"] == package["version"] == version("ng-postcode-mcp")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_readme_proves_registry_ownership() -> None:
|
|
26
|
+
marker = f"<!-- mcp-name: {server_json()['name']} -->"
|
|
27
|
+
assert marker in (ROOT / "README.md").read_text(encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_registry_description_fits() -> None:
|
|
31
|
+
assert len(server_json()["description"]) <= 100
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The real entry point over stdio: proves nothing but protocol reaches stdout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from mcp import Client, StdioServerParameters
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def anyio_backend() -> str:
|
|
14
|
+
return "asyncio"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.anyio
|
|
18
|
+
async def test_serves_over_stdio() -> None:
|
|
19
|
+
env = {k: v for k, v in os.environ.items() if not k.startswith("NG_POSTCODE_")}
|
|
20
|
+
params = StdioServerParameters(command=sys.executable, args=["-m", "ng_postcode_mcp"], env=env)
|
|
21
|
+
async with Client(params) as client:
|
|
22
|
+
names = {tool.name for tool in (await client.list_tools()).tools}
|
|
23
|
+
result = await client.call_tool("validate_postcode", {"postcode": "LA11W06TC10"})
|
|
24
|
+
assert "validate_postcode" in names
|
|
25
|
+
assert result.structured_content["postcode"] == "LA-11-W06-TC-10"
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Tool behaviour through a real MCP client, in process, with the NIPOST API mocked."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
import pytest
|
|
11
|
+
from mcp import Client
|
|
12
|
+
from mcp.types import CallToolResult, TextContent
|
|
13
|
+
from ng_postcode import api
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from ng_postcode_mcp import Settings, create_server, settings_from_env
|
|
17
|
+
from ng_postcode_mcp.server import (
|
|
18
|
+
Address,
|
|
19
|
+
Completion,
|
|
20
|
+
Location,
|
|
21
|
+
NearestBuilding,
|
|
22
|
+
PostcodeDetails,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
Handler = Callable[[httpx.Request], httpx.Response]
|
|
26
|
+
|
|
27
|
+
LOOKUP = {
|
|
28
|
+
"postcode": "EK-01-A03-FK-01",
|
|
29
|
+
"valid": True,
|
|
30
|
+
"administrative_address": {
|
|
31
|
+
"state_name": "EKITI",
|
|
32
|
+
"lga_name": "ADO EKITI",
|
|
33
|
+
"locality_name": "ADO EKITI",
|
|
34
|
+
"zone": "SOUTH WEST",
|
|
35
|
+
},
|
|
36
|
+
"recent_house_address": {"recent": "NTA ROAD, BACK OF FABIAN HOTEL, ADO EKITI"},
|
|
37
|
+
"building_use_status": "residential",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@pytest.fixture
|
|
42
|
+
def anyio_backend() -> str:
|
|
43
|
+
return "asyncio"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def nipost(seen: list[httpx.Request]) -> Handler:
|
|
47
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
48
|
+
seen.append(request)
|
|
49
|
+
if request.headers.get("X-API-Key") != "good":
|
|
50
|
+
error = {"code": "invalid_api_key", "message": "the provided API key is invalid"}
|
|
51
|
+
return httpx.Response(401, json={"error": error})
|
|
52
|
+
if request.url.path == "/v1/lookup":
|
|
53
|
+
return httpx.Response(200, json={"data": LOOKUP})
|
|
54
|
+
if request.url.path == "/v1/search/reverse":
|
|
55
|
+
return httpx.Response(200, json={"data": {"found": False, "radius_m": 25}})
|
|
56
|
+
if request.url.path == "/v1/search/autocomplete":
|
|
57
|
+
suggestion = {"code": "EK-01", "label": "ADO EKITI"}
|
|
58
|
+
return httpx.Response(
|
|
59
|
+
200, json={"data": {"segment": "lga", "suggestions": [suggestion]}}
|
|
60
|
+
)
|
|
61
|
+
return httpx.Response(404, json={"error": {"code": "not_found", "message": "no route"}})
|
|
62
|
+
|
|
63
|
+
return handle
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def call(
|
|
67
|
+
name: str,
|
|
68
|
+
arguments: dict[str, Any],
|
|
69
|
+
*,
|
|
70
|
+
key: str | None = "good",
|
|
71
|
+
max_level: int = 1,
|
|
72
|
+
seen: list[httpx.Request] | None = None,
|
|
73
|
+
) -> CallToolResult:
|
|
74
|
+
http = httpx.AsyncClient(
|
|
75
|
+
transport=httpx.MockTransport(nipost(seen if seen is not None else []))
|
|
76
|
+
)
|
|
77
|
+
server = create_server(Settings(api_key=key, max_level=max_level), http=http)
|
|
78
|
+
async with Client(server) as client:
|
|
79
|
+
result = await client.call_tool(name, arguments)
|
|
80
|
+
await http.aclose()
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def text(result: CallToolResult) -> str:
|
|
85
|
+
return " ".join(block.text for block in result.content if isinstance(block, TextContent))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.mark.anyio
|
|
89
|
+
async def test_lists_four_read_only_tools_with_schemas() -> None:
|
|
90
|
+
async with Client(create_server(Settings(api_key=None))) as client:
|
|
91
|
+
tools = {tool.name: tool for tool in (await client.list_tools()).tools}
|
|
92
|
+
assert set(tools) == {
|
|
93
|
+
"validate_postcode",
|
|
94
|
+
"lookup_postcode",
|
|
95
|
+
"autocomplete_postcode",
|
|
96
|
+
"find_postcode_at_location",
|
|
97
|
+
}
|
|
98
|
+
for tool in tools.values():
|
|
99
|
+
assert tool.annotations is not None
|
|
100
|
+
assert tool.annotations.read_only_hint is True
|
|
101
|
+
assert tool.description
|
|
102
|
+
assert tool.output_schema is not None
|
|
103
|
+
assert tools["validate_postcode"].annotations.open_world_hint is False # type: ignore[union-attr]
|
|
104
|
+
level = tools["lookup_postcode"].input_schema["properties"]["level"]
|
|
105
|
+
assert (level["minimum"], level["maximum"], level["default"]) == (1, 5, 1)
|
|
106
|
+
assert "ctx" not in tools["lookup_postcode"].input_schema["properties"]
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@pytest.mark.anyio
|
|
110
|
+
async def test_validate_works_offline_without_a_key() -> None:
|
|
111
|
+
result = await call("validate_postcode", {"postcode": "ek 01 a03 fk 01"}, key=None)
|
|
112
|
+
assert not result.is_error
|
|
113
|
+
assert result.structured_content["postcode"] == "EK-01-A03-FK-01"
|
|
114
|
+
assert result.structured_content["segments"]["district"] == "A03"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@pytest.mark.anyio
|
|
118
|
+
async def test_validate_suggests_but_does_not_apply_fixes() -> None:
|
|
119
|
+
result = await call("validate_postcode", {"postcode": "EK-O1-A03-FK-01"}, key=None)
|
|
120
|
+
assert not result.is_error
|
|
121
|
+
data = result.structured_content
|
|
122
|
+
assert (data["valid"], data["postcode"]) == (False, None)
|
|
123
|
+
assert (data["error"], data["suggestion"]) == ("invalid lga segment", "EK-01-A03-FK-01")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@pytest.mark.anyio
|
|
127
|
+
async def test_lookup_returns_the_address_within_the_cap() -> None:
|
|
128
|
+
seen: list[httpx.Request] = []
|
|
129
|
+
result = await call(
|
|
130
|
+
"lookup_postcode", {"postcode": "ek01a03fk01", "level": 2}, max_level=2, seen=seen
|
|
131
|
+
)
|
|
132
|
+
assert not result.is_error, text(result)
|
|
133
|
+
assert result.structured_content["administrative_address"]["zone"] == "SOUTH WEST"
|
|
134
|
+
assert dict(seen[0].url.params) == {"code": "EK-01-A03-FK-01", "level": "2"}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@pytest.mark.anyio
|
|
138
|
+
async def test_lookup_refuses_paid_levels_above_the_cap_without_calling_the_api() -> None:
|
|
139
|
+
seen: list[httpx.Request] = []
|
|
140
|
+
result = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01", "level": 3}, seen=seen)
|
|
141
|
+
assert result.is_error
|
|
142
|
+
assert "NG_POSTCODE_MAX_LEVEL" in text(result)
|
|
143
|
+
assert seen == []
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.anyio
|
|
147
|
+
async def test_lookup_never_autocorrects_before_spending() -> None:
|
|
148
|
+
seen: list[httpx.Request] = []
|
|
149
|
+
result = await call("lookup_postcode", {"postcode": "EK-O1-A03-FK-01"}, seen=seen)
|
|
150
|
+
assert result.is_error
|
|
151
|
+
assert "Did you mean EK-01-A03-FK-01? Confirm with the user first." in text(result)
|
|
152
|
+
assert seen == []
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@pytest.mark.anyio
|
|
156
|
+
async def test_online_tools_explain_a_missing_or_rejected_key() -> None:
|
|
157
|
+
missing = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}, key=None)
|
|
158
|
+
assert missing.is_error
|
|
159
|
+
assert "needs NG_POSTCODE_API_KEY" in text(missing)
|
|
160
|
+
|
|
161
|
+
secret = "nipost_test_never_echo_me"
|
|
162
|
+
rejected = await call("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}, key=secret)
|
|
163
|
+
assert rejected.is_error
|
|
164
|
+
assert "invalid_api_key (401)" in text(rejected)
|
|
165
|
+
assert "create a new key" in text(rejected)
|
|
166
|
+
assert secret not in text(rejected)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@pytest.mark.anyio
|
|
170
|
+
async def test_autocomplete_and_reverse() -> None:
|
|
171
|
+
found = await call("autocomplete_postcode", {"partial": "EK"})
|
|
172
|
+
assert not found.is_error
|
|
173
|
+
assert found.structured_content["suggestions"][0]["code"] == "EK-01"
|
|
174
|
+
|
|
175
|
+
near = await call("find_postcode_at_location", {"latitude": 7.62, "longitude": 5.22})
|
|
176
|
+
assert not near.is_error
|
|
177
|
+
assert (near.structured_content["found"], near.structured_content["radius_m"]) == (False, 25.0)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
@pytest.mark.anyio
|
|
181
|
+
async def test_rejects_out_of_range_arguments() -> None:
|
|
182
|
+
result = await call("find_postcode_at_location", {"latitude": 95, "longitude": 5.22})
|
|
183
|
+
assert result.is_error
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
@pytest.mark.parametrize(
|
|
187
|
+
("model", "source"),
|
|
188
|
+
[
|
|
189
|
+
(PostcodeDetails, api.Lookup),
|
|
190
|
+
(Address, api.AdministrativeAddress),
|
|
191
|
+
(Completion, api.Suggestion),
|
|
192
|
+
(NearestBuilding, api.NearestUnit),
|
|
193
|
+
(Location, api.Reverse),
|
|
194
|
+
],
|
|
195
|
+
)
|
|
196
|
+
def test_output_models_only_use_fields_the_library_has(
|
|
197
|
+
model: type[BaseModel], source: type
|
|
198
|
+
) -> None:
|
|
199
|
+
assert set(model.model_fields) <= {field.name for field in dataclasses.fields(source)}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_settings_from_env() -> None:
|
|
203
|
+
assert settings_from_env({}) == Settings(api_key=None)
|
|
204
|
+
assert settings_from_env({"NG_POSTCODE_API_KEY": " k ", "NG_POSTCODE_MAX_LEVEL": "3"}) == (
|
|
205
|
+
Settings(api_key="k", max_level=3)
|
|
206
|
+
)
|
|
207
|
+
assert settings_from_env({"NG_POSTCODE_MAX_LEVEL": "9"}) == (
|
|
208
|
+
"NG_POSTCODE_MAX_LEVEL must be 1 to 5, got '9'"
|
|
209
|
+
)
|