ng-postcode-mcp 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """MCP server for Nigeria's NIPOST digital postcode (NDAPS)."""
2
+
3
+ from .server import Settings, create_server, main, settings_from_env
4
+
5
+ __all__ = ["Settings", "create_server", "main", "settings_from_env"]
@@ -0,0 +1,3 @@
1
+ from .server import main
2
+
3
+ main()
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,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,9 @@
1
+ ng_postcode_mcp/__init__.py,sha256=aBPcYo9w_tMmqD03ECS8RYFyU0jgIlJ6j_qEOOkvL5k,204
2
+ ng_postcode_mcp/__main__.py,sha256=3dYKHfmWsrdExFlTFlcR5a_icR9fAkn06Yh14TQkEd8,33
3
+ ng_postcode_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ ng_postcode_mcp/server.py,sha256=x3V2-7FWbqauGUAqCgn0RDh1Fh_1vpSPtHuHwm56uio,12494
5
+ ng_postcode_mcp-0.1.0.dist-info/METADATA,sha256=_jv-0HeMRhJIgvkubKZ1X7BZ5AmUNsQTX9tWlOOIXV4,3759
6
+ ng_postcode_mcp-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ ng_postcode_mcp-0.1.0.dist-info/entry_points.txt,sha256=t7XXPc6mPj4Ri6_Gb9f7RHaS59pOO-_Qz6B3-ezMrsw,64
8
+ ng_postcode_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=5J4ejW4oekCqBi05kyohGmFDQp7wWDwN4vP5w_R7lGM,1071
9
+ ng_postcode_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ ng-postcode-mcp = ng_postcode_mcp.server:main
@@ -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.