ng-postcode-mcp 0.1.0__tar.gz → 0.2.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,7 +1,7 @@
1
1
  Metadata-Version: 2.5
2
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.
3
+ Version: 0.2.0
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
7
7
  Author: Kayode Adeniyi
@@ -22,14 +22,15 @@ Classifier: Topic :: Scientific/Engineering :: GIS
22
22
  Classifier: Typing :: Typed
23
23
  Requires-Python: >=3.10
24
24
  Requires-Dist: mcp<3,>=2.3
25
+ Requires-Dist: ng-address-resolver<0.2,>=0.1
25
26
  Requires-Dist: ng-postcode[client]<0.2,>=0.1
26
27
  Description-Content-Type: text/markdown
27
28
 
28
29
  # ng-postcode-mcp
29
30
 
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
+ 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, look them up, autocomplete them and find them by location through the [postcode.gov.ng](https://docs.postcode.gov.ng) API, and resolve described addresses to postcodes.
31
32
 
32
- Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) library.
33
+ Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) and [`ng-address-resolver`](https://pypi.org/project/ng-address-resolver/) libraries.
33
34
 
34
35
  <!-- mcp-name: io.github.Adeniyikayodee/ng-postcode -->
35
36
 
@@ -41,9 +42,24 @@ Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) library.
41
42
  | `lookup_postcode` | Confirms a code is assigned; level 2 adds the address, level 3 building use | Yes | Level 1 free, 2+ uses credits |
42
43
  | `autocomplete_postcode` | Suggests the next segment of a partly typed code | Yes | Free tier |
43
44
  | `find_postcode_at_location` | Returns the postcode of the nearest building to a coordinate | Yes | Free tier |
45
+ | `resolve_address` | Turns a described address ("back of Fabian Hotel, off NTA Road") or a location pin into a postcode, only as precisely as the evidence allows | Yes, plus a geocoder for text | Free tier |
44
46
 
45
47
  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
48
 
49
+ ### How `resolve_address` answers
50
+
51
+ The assistant reads the address and passes its landmarks and map searches to the tool; the server makes no model calls of its own. The answer is never more precise than its evidence:
52
+
53
+ | Evidence | Answer |
54
+ | --- | --- |
55
+ | A postcode written in the address, or a location pin on a building | Building code |
56
+ | A landmark the address *is* | Building code, medium confidence |
57
+ | A building near a landmark ("behind", "opposite") | Area code, plus a question for the user |
58
+ | A street only | District code, low confidence |
59
+ | A town only, or nothing found | No code, plus a question |
60
+
61
+ Text alone rarely identifies a building, so ask users for a location pin when the exact building matters. This tool is pre-release: its NIPOST steps have not yet been run against the live API.
62
+
47
63
  ## Install
48
64
 
49
65
  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.
@@ -75,12 +91,17 @@ claude mcp add ng-postcode -e NG_POSTCODE_API_KEY=nipost_live_... -- uvx ng-post
75
91
  | `NG_POSTCODE_API_KEY` | none | NIPOST API key. Read from the environment only; never passed through tools. |
76
92
  | `NG_POSTCODE_MAX_LEVEL` | `1` | Highest lookup level tools may request. Levels 2+ consume credits, so raise it deliberately. |
77
93
  | `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
94
+ | `NG_GEOCODER_URL` | none | A Nominatim server `resolve_address` uses to place described addresses. Without it, only typed postcodes and location pins resolve. |
95
+ | `NG_GEOCODER_CONTACT` | none | A URL or email sent in the User-Agent. Required for the public Nominatim. |
96
+
97
+ 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.
78
98
 
79
99
  ## Safety
80
100
 
81
101
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
82
102
  - 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
103
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
104
+ - `resolve_address` sends the search strings to the geocoder you configure. With a third-party geocoder, that shares address text with it.
84
105
 
85
106
  ## License
86
107
 
@@ -1,8 +1,8 @@
1
1
  # ng-postcode-mcp
2
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.
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, look them up, autocomplete them and find them by location through the [postcode.gov.ng](https://docs.postcode.gov.ng) API, and resolve described addresses to postcodes.
4
4
 
5
- Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) library.
5
+ Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) and [`ng-address-resolver`](https://pypi.org/project/ng-address-resolver/) libraries.
6
6
 
7
7
  <!-- mcp-name: io.github.Adeniyikayodee/ng-postcode -->
8
8
 
@@ -14,9 +14,24 @@ Built on the [`ng-postcode`](https://pypi.org/project/ng-postcode/) library.
14
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
15
  | `autocomplete_postcode` | Suggests the next segment of a partly typed code | Yes | Free tier |
16
16
  | `find_postcode_at_location` | Returns the postcode of the nearest building to a coordinate | Yes | Free tier |
17
+ | `resolve_address` | Turns a described address ("back of Fabian Hotel, off NTA Road") or a location pin into a postcode, only as precisely as the evidence allows | Yes, plus a geocoder for text | Free tier |
17
18
 
18
19
  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
 
21
+ ### How `resolve_address` answers
22
+
23
+ The assistant reads the address and passes its landmarks and map searches to the tool; the server makes no model calls of its own. The answer is never more precise than its evidence:
24
+
25
+ | Evidence | Answer |
26
+ | --- | --- |
27
+ | A postcode written in the address, or a location pin on a building | Building code |
28
+ | A landmark the address *is* | Building code, medium confidence |
29
+ | A building near a landmark ("behind", "opposite") | Area code, plus a question for the user |
30
+ | A street only | District code, low confidence |
31
+ | A town only, or nothing found | No code, plus a question |
32
+
33
+ Text alone rarely identifies a building, so ask users for a location pin when the exact building matters. This tool is pre-release: its NIPOST steps have not yet been run against the live API.
34
+
20
35
  ## Install
21
36
 
22
37
  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.
@@ -48,12 +63,17 @@ claude mcp add ng-postcode -e NG_POSTCODE_API_KEY=nipost_live_... -- uvx ng-post
48
63
  | `NG_POSTCODE_API_KEY` | none | NIPOST API key. Read from the environment only; never passed through tools. |
49
64
  | `NG_POSTCODE_MAX_LEVEL` | `1` | Highest lookup level tools may request. Levels 2+ consume credits, so raise it deliberately. |
50
65
  | `NG_POSTCODE_BASE_URL` | `https://api.postcode.gov.ng` | Alternative API host, such as a staging stack. |
66
+ | `NG_GEOCODER_URL` | none | A Nominatim server `resolve_address` uses to place described addresses. Without it, only typed postcodes and location pins resolve. |
67
+ | `NG_GEOCODER_CONTACT` | none | A URL or email sent in the User-Agent. Required for the public Nominatim. |
68
+
69
+ 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.
51
70
 
52
71
  ## Safety
53
72
 
54
73
  - Lookups default to level 1, which is free. A model cannot spend credits unless you raise `NG_POSTCODE_MAX_LEVEL`.
55
74
  - 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
75
  - Levels 2 and up return house addresses. Treat them as personal data under the Nigeria Data Protection Act.
76
+ - `resolve_address` sends the search strings to the geocoder you configure. With a third-party geocoder, that shares address text with it.
57
77
 
58
78
  ## License
59
79
 
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
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."
7
+ version = "0.2.0"
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"
11
11
  license = "MIT"
@@ -26,7 +26,7 @@ classifiers = [
26
26
  "Topic :: Scientific/Engineering :: GIS",
27
27
  "Typing :: Typed",
28
28
  ]
29
- dependencies = ["mcp>=2.3,<3", "ng-postcode[client]>=0.1,<0.2"]
29
+ dependencies = ["mcp>=2.3,<3", "ng-address-resolver>=0.1,<0.2", "ng-postcode[client]>=0.1,<0.2"]
30
30
 
31
31
  [project.scripts]
32
32
  ng-postcode-mcp = "ng_postcode_mcp.server:main"
@@ -38,8 +38,9 @@ Issues = "https://github.com/Adeniyikayodee/ng-postcode/issues"
38
38
  [dependency-groups]
39
39
  dev = ["jsonschema>=4.20", "mypy>=1.13", "pytest>=8", "ruff>=0.8"]
40
40
 
41
- # Inside the repository, build against the local library so the two never drift.
41
+ # Inside the repository, build against the local packages so they never drift.
42
42
  [tool.uv.sources]
43
+ ng-address-resolver = { path = "../agent", editable = true }
43
44
  ng-postcode = { path = "../python", editable = true }
44
45
 
45
46
  [tool.hatch.build.targets.sdist]
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.Adeniyikayodee/ng-postcode",
4
4
  "title": "Nigeria Postcode",
5
- "description": "Validate, look up and reverse-geocode Nigeria's NIPOST digital postcodes (NDAPS).",
6
- "version": "0.1.0",
5
+ "description": "Validate, look up and resolve addresses to Nigeria's NIPOST digital postcodes (NDAPS).",
6
+ "version": "0.2.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.1.0",
16
+ "version": "0.2.0",
17
17
  "runtimeHint": "uvx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -29,7 +29,21 @@
29
29
  "name": "NG_POSTCODE_MAX_LEVEL",
30
30
  "description": "Highest lookup level tools may request. Levels 2 and up consume NIPOST credits.",
31
31
  "default": "1",
32
- "choices": ["1", "2", "3", "4", "5"]
32
+ "choices": [
33
+ "1",
34
+ "2",
35
+ "3",
36
+ "4",
37
+ "5"
38
+ ]
39
+ },
40
+ {
41
+ "name": "NG_GEOCODER_URL",
42
+ "description": "Nominatim server used by resolve_address to place described addresses. Ideally your own instance."
43
+ },
44
+ {
45
+ "name": "NG_GEOCODER_CONTACT",
46
+ "description": "URL or email identifying you to the geocoder. Required for the public Nominatim."
33
47
  }
34
48
  ]
35
49
  }
@@ -2,8 +2,8 @@
2
2
 
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
- tool arguments or results. stdout carries the protocol, so nothing else may
6
- print to it.
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.
7
7
  """
8
8
 
9
9
  from __future__ import annotations
@@ -21,6 +21,8 @@ import httpx
21
21
  from mcp.server.mcpserver import Context, MCPServer
22
22
  from mcp.server.mcpserver.exceptions import ToolError
23
23
  from mcp.types import ToolAnnotations
24
+ from ng_address import Landmark, Nominatim, ParsedAddress, Resolution, Resolver
25
+ from ng_address.geocode import PUBLIC_NOMINATIM
24
26
  from ng_postcode import Corrected, Postcode, parse, parse_lenient
25
27
  from ng_postcode.api import (
26
28
  BASE_URL,
@@ -45,6 +47,9 @@ Tools for Nigeria's 11-character building postcode, e.g. EK-01-A03-FK-01 \
45
47
  - lookup_postcode level 1 only confirms a code exists. Levels 2 and up add the \
46
48
  address and building details, consume NIPOST credits, and are capped by the \
47
49
  server's NG_POSTCODE_MAX_LEVEL.
50
+ - resolve_address turns a described address into a code. It answers only as \
51
+ precisely as its evidence allows: pass on its level and confidence, and ask the \
52
+ user its question instead of guessing. A location pin is the most reliable input.
48
53
  - Never substitute a suggested correction without confirming it with the user.
49
54
  - Addresses returned are personal data: use them only for the user's request."""
50
55
 
@@ -69,6 +74,8 @@ class Settings:
69
74
  api_key: str | None
70
75
  max_level: int = 1
71
76
  base_url: str = BASE_URL
77
+ geocoder_url: str | None = None
78
+ geocoder_contact: str | None = None
72
79
 
73
80
 
74
81
  def settings_from_env(env: Mapping[str, str]) -> Settings | str:
@@ -76,10 +83,16 @@ def settings_from_env(env: Mapping[str, str]) -> Settings | str:
76
83
  raw_level = env.get("NG_POSTCODE_MAX_LEVEL", "1").strip()
77
84
  if raw_level not in {"1", "2", "3", "4", "5"}:
78
85
  return f"NG_POSTCODE_MAX_LEVEL must be 1 to 5, got {raw_level!r}"
86
+ geocoder_url = env.get("NG_GEOCODER_URL", "").strip().rstrip("/") or None
87
+ geocoder_contact = env.get("NG_GEOCODER_CONTACT", "").strip() or None
88
+ if geocoder_url == PUBLIC_NOMINATIM and geocoder_contact is None:
89
+ return "the public Nominatim requires NG_GEOCODER_CONTACT, a URL or email identifying you"
79
90
  return Settings(
80
91
  api_key=env.get("NG_POSTCODE_API_KEY", "").strip() or None,
81
92
  max_level=int(raw_level),
82
93
  base_url=env.get("NG_POSTCODE_BASE_URL", "").strip() or BASE_URL,
94
+ geocoder_url=geocoder_url,
95
+ geocoder_contact=geocoder_contact,
83
96
  )
84
97
 
85
98
 
@@ -163,24 +176,39 @@ class Location(FromLibrary):
163
176
  radius_m: float | None = Field(description="The radius the API actually applied.")
164
177
 
165
178
 
166
- Api = AsyncClient | None
179
+ @dataclass(frozen=True, slots=True)
180
+ class State:
181
+ """What the tools share for the life of the server. Either part may be unconfigured."""
182
+
183
+ nipost: AsyncClient | None
184
+ geocoder: Nominatim | None
167
185
 
168
186
 
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."""
187
+ def create_server(
188
+ settings: Settings,
189
+ http: httpx.AsyncClient | None = None,
190
+ geocoder_http: httpx.AsyncClient | None = None,
191
+ ) -> MCPServer[State]:
192
+ """Build the server. Pass `http` and `geocoder_http` to route calls through your own
193
+ clients, as tests do."""
171
194
 
172
195
  @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)
196
+ async def lifespan(_: MCPServer[State]) -> AsyncIterator[State]:
197
+ nipost = (
198
+ AsyncClient(settings.api_key, base_url=settings.base_url, http=http)
199
+ if settings.api_key
200
+ else None
201
+ )
202
+ geocoder = geocoder_for(settings, geocoder_http)
178
203
  try:
179
- yield client
204
+ yield State(nipost, geocoder)
180
205
  finally:
181
- await client.aclose()
206
+ if nipost:
207
+ await nipost.aclose()
208
+ if geocoder:
209
+ await geocoder.aclose()
182
210
 
183
- server: MCPServer[Api] = MCPServer(
211
+ server: MCPServer[State] = MCPServer(
184
212
  name="ng-postcode",
185
213
  title="Nigeria Postcode",
186
214
  instructions=INSTRUCTIONS,
@@ -210,7 +238,7 @@ def create_server(settings: Settings, http: httpx.AsyncClient | None = None) ->
210
238
  )
211
239
  async def lookup_postcode(
212
240
  postcode: Annotated[str, Field(description="Code in any style, e.g. EK-01-A03-FK-01.")],
213
- ctx: Context[Api, Any],
241
+ ctx: Context[State, Any],
214
242
  level: Annotated[
215
243
  int,
216
244
  Field(
@@ -237,7 +265,7 @@ def create_server(settings: Settings, http: httpx.AsyncClient | None = None) ->
237
265
  )
238
266
  async def autocomplete_postcode(
239
267
  partial: Annotated[str, Field(description="The start of a code, e.g. 'EK 01 A'.")],
240
- ctx: Context[Api, Any],
268
+ ctx: Context[State, Any],
241
269
  ) -> Completions:
242
270
  """Suggest completions for the next segment of a partly typed postcode."""
243
271
  result = await api(ctx).send(autocomplete(partial))
@@ -251,7 +279,7 @@ def create_server(settings: Settings, http: httpx.AsyncClient | None = None) ->
251
279
  async def find_postcode_at_location(
252
280
  latitude: Annotated[float, Field(ge=-90, le=90)],
253
281
  longitude: Annotated[float, Field(ge=-180, le=180)],
254
- ctx: Context[Api, Any],
282
+ ctx: Context[State, Any],
255
283
  max_distance_m: Annotated[
256
284
  float | None,
257
285
  Field(ge=0, le=250, description="Search radius in metres. Defaults to 25."),
@@ -263,9 +291,85 @@ def create_server(settings: Settings, http: httpx.AsyncClient | None = None) ->
263
291
  )
264
292
  return Location.model_validate(unwrap(result))
265
293
 
294
+ @server.tool(
295
+ title="Resolve a Nigerian address to a postcode",
296
+ annotations=READ_ONLY_ONLINE,
297
+ structured_output=True,
298
+ )
299
+ async def resolve_address(
300
+ address: Annotated[str, Field(description="The address exactly as the user gave it.")],
301
+ ctx: Context[State, Any],
302
+ landmarks: Annotated[
303
+ list[Landmark] | None,
304
+ Field(
305
+ description="Landmarks named in the address, each with how the address relates "
306
+ "to it. Use 'at' only when the address is the landmark itself."
307
+ ),
308
+ ] = None,
309
+ geocode_queries: Annotated[
310
+ list[str] | None,
311
+ Field(
312
+ max_length=3,
313
+ description="Up to three map search strings you derive from the address: named "
314
+ "places and streets with the town and state, most specific first, without "
315
+ "directional words like 'back of'.",
316
+ ),
317
+ ] = None,
318
+ latitude: Annotated[
319
+ float | None, Field(ge=-90, le=90, description="A location pin the user shared.")
320
+ ] = None,
321
+ longitude: Annotated[float | None, Field(ge=-180, le=180)] = None,
322
+ ) -> Resolution:
323
+ """Turn a described Nigerian address into a postcode, only as precisely as the
324
+ evidence allows.
325
+
326
+ A full building code comes only from a postcode written in the address, a location
327
+ pin, or a landmark that is the address itself. Otherwise the result is an area or
328
+ district prefix with a `question` for the user. Read the address yourself and fill
329
+ `landmarks` and `geocode_queries`; the address text is sent to the configured
330
+ geocoder.
331
+ """
332
+ if (latitude is None) != (longitude is None):
333
+ raise ToolError("Give both latitude and longitude, or neither.")
334
+ location = (
335
+ Coordinate(lat=latitude, lng=longitude)
336
+ if latitude is not None and longitude is not None
337
+ else None
338
+ )
339
+ state = ctx.request_context.lifespan_context
340
+ resolver = Resolver(nipost=state.nipost, geocoder=state.geocoder)
341
+ return await resolver.resolve(address, location, reading(landmarks, geocode_queries))
342
+
266
343
  return server
267
344
 
268
345
 
346
+ def reading(landmarks: list[Landmark] | None, queries: list[str] | None) -> ParsedAddress | None:
347
+ """The host model's reading of the address, in the resolver's terms."""
348
+ if not landmarks and not queries:
349
+ return None
350
+ return ParsedAddress(
351
+ house_number=None,
352
+ street=None,
353
+ landmarks=landmarks or [],
354
+ locality=None,
355
+ lga=None,
356
+ state=None,
357
+ geocode_queries=queries or [],
358
+ question=None,
359
+ )
360
+
361
+
362
+ def geocoder_for(settings: Settings, http: httpx.AsyncClient | None) -> Nominatim | None:
363
+ if settings.geocoder_url is None:
364
+ return None
365
+ contact = settings.geocoder_contact or "unknown"
366
+ return Nominatim(
367
+ settings.geocoder_url,
368
+ f"ng-postcode-mcp/{version('ng-postcode-mcp')} ({contact})",
369
+ http=http,
370
+ )
371
+
372
+
269
373
  def completions(found: Autocomplete) -> Completions:
270
374
  return Completions(
271
375
  segment=None if found.segment is None else found.segment.value,
@@ -319,8 +423,8 @@ def checked(text: str) -> Postcode:
319
423
  raise ToolError(f"{text!r} is not a valid postcode: {error}.{maybe}")
320
424
 
321
425
 
322
- def api(ctx: Context[Api, Any]) -> AsyncClient:
323
- client = ctx.request_context.lifespan_context
426
+ def api(ctx: Context[State, Any]) -> AsyncClient:
427
+ client = ctx.request_context.lifespan_context.nipost
324
428
  if client is None:
325
429
  raise ToolError(f"This tool needs NG_POSTCODE_API_KEY. {HINTS['auth_required']}")
326
430
  return client
@@ -0,0 +1,144 @@
1
+ """resolve_address through a real MCP client, with NIPOST and the geocoder mocked."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ import httpx
8
+ import pytest
9
+ from mcp import Client
10
+ from mcp.types import CallToolResult
11
+
12
+ from ng_postcode_mcp import Settings, create_server, settings_from_env
13
+
14
+ PLACES = {"Fabian Hotel, Ado Ekiti, Ekiti": 30, "NTA Road, Ado Ekiti, Ekiti": 26}
15
+ BEHIND_FABIAN = {
16
+ "address": "back of Fabian Hotel, off NTA Road, Ado Ekiti",
17
+ "landmarks": [{"name": "Fabian Hotel", "relation": "behind"}],
18
+ "geocode_queries": ["Fabian Hotel, Ado Ekiti, Ekiti", "NTA Road, Ado Ekiti, Ekiti"],
19
+ }
20
+
21
+
22
+ @pytest.fixture
23
+ def anyio_backend() -> str:
24
+ return "asyncio"
25
+
26
+
27
+ def nipost(request: httpx.Request) -> httpx.Response:
28
+ if request.url.path == "/v1/lookup":
29
+ return httpx.Response(200, json={"data": {"postcode": "EK-01-A03-FK-01", "valid": True}})
30
+ unit = {"postcode": "EK-01-A03-FK-01", "display": "EK 01 A03 FK 01", "distance_m": 8.0}
31
+ data = {"found": True, "unit": unit, "area": "EK-01-A03-FK", "district": "EK-01-A03"}
32
+ return httpx.Response(200, json={"data": data})
33
+
34
+
35
+ async def call(
36
+ arguments: dict[str, Any],
37
+ *,
38
+ geocoder: bool = True,
39
+ searched: list[httpx.Request] | None = None,
40
+ ) -> CallToolResult:
41
+ def geocode(request: httpx.Request) -> httpx.Response:
42
+ if searched is not None:
43
+ searched.append(request)
44
+ query = request.url.params["q"]
45
+ rank = PLACES.get(query)
46
+ hit = {"lat": "7.62", "lon": "5.19", "place_rank": rank, "display_name": query}
47
+ return httpx.Response(200, json=[hit] if rank else [])
48
+
49
+ settings = Settings(
50
+ api_key="good",
51
+ geocoder_url="https://geo.example" if geocoder else None,
52
+ geocoder_contact="ops@example.com",
53
+ )
54
+ nipost_http = httpx.AsyncClient(transport=httpx.MockTransport(nipost))
55
+ geocoder_http = httpx.AsyncClient(transport=httpx.MockTransport(geocode))
56
+ server = create_server(settings, http=nipost_http, geocoder_http=geocoder_http)
57
+ async with Client(server) as client:
58
+ result = await client.call_tool("resolve_address", arguments)
59
+ await nipost_http.aclose()
60
+ await geocoder_http.aclose()
61
+ return result
62
+
63
+
64
+ @pytest.mark.anyio
65
+ async def test_the_host_models_reading_drives_the_answer() -> None:
66
+ searched: list[httpx.Request] = []
67
+ result = await call(BEHIND_FABIAN, searched=searched)
68
+ assert not result.is_error
69
+ answer = result.structured_content
70
+ assert (answer["status"], answer["level"], answer["code"]) == (
71
+ "partial",
72
+ "area",
73
+ "EK-01-A03-FK",
74
+ )
75
+ assert "behind Fabian Hotel" in answer["question"]
76
+ assert [r.url.params["q"] for r in searched] == ["Fabian Hotel, Ado Ekiti, Ekiti"]
77
+ assert searched[0].headers["User-Agent"].startswith("ng-postcode-mcp/")
78
+ assert "ops@example.com" in searched[0].headers["User-Agent"]
79
+
80
+
81
+ @pytest.mark.anyio
82
+ async def test_a_typed_postcode_needs_no_geocoder() -> None:
83
+ searched: list[httpx.Request] = []
84
+ result = await call({"address": "deliver to ek01a03fk01"}, searched=searched)
85
+ answer = result.structured_content
86
+ assert (answer["status"], answer["code"], answer["confidence"]) == (
87
+ "resolved",
88
+ "EK-01-A03-FK-01",
89
+ "high",
90
+ )
91
+ assert searched == []
92
+
93
+
94
+ @pytest.mark.anyio
95
+ async def test_a_location_pin_gives_the_building() -> None:
96
+ result = await call({"address": "my house", "latitude": 7.62, "longitude": 5.19})
97
+ answer = result.structured_content
98
+ assert (answer["level"], answer["method"], answer["code"]) == (
99
+ "building",
100
+ "location",
101
+ "EK-01-A03-FK-01",
102
+ )
103
+
104
+
105
+ @pytest.mark.anyio
106
+ async def test_without_a_geocoder_it_explains_and_asks() -> None:
107
+ result = await call(BEHIND_FABIAN, geocoder=False)
108
+ assert not result.is_error
109
+ answer = result.structured_content
110
+ assert (answer["status"], answer["code"]) == ("unresolved", None)
111
+ assert any("NG_GEOCODER_URL" in line for line in answer["evidence"])
112
+ assert answer["question"]
113
+
114
+
115
+ @pytest.mark.anyio
116
+ async def test_half_a_coordinate_is_an_error_the_model_can_fix() -> None:
117
+ result = await call({"address": "my house", "latitude": 7.62})
118
+ assert result.is_error
119
+
120
+
121
+ @pytest.mark.anyio
122
+ async def test_schemas_guide_the_host_model() -> None:
123
+ async with Client(create_server(Settings(api_key=None))) as client:
124
+ tools = {tool.name: tool for tool in (await client.list_tools()).tools}
125
+ tool = tools["resolve_address"]
126
+ assert tool.input_schema["required"] == ["address"]
127
+ relations = tool.input_schema["$defs"]["Landmark"]["properties"]["relation"]["enum"]
128
+ assert {"at", "behind", "opposite"} <= set(relations)
129
+ assert tool.input_schema["properties"]["geocode_queries"]["anyOf"][0]["maxItems"] == 3
130
+ assert tool.output_schema is not None
131
+ assert {"status", "code", "level", "confidence", "question", "evidence"} <= set(
132
+ tool.output_schema["properties"]
133
+ )
134
+
135
+
136
+ def test_geocoder_settings() -> None:
137
+ public = {"NG_GEOCODER_URL": "https://nominatim.openstreetmap.org/"}
138
+ assert settings_from_env(public) == (
139
+ "the public Nominatim requires NG_GEOCODER_CONTACT, a URL or email identifying you"
140
+ )
141
+ configured = settings_from_env(public | {"NG_GEOCODER_CONTACT": "https://example.com"})
142
+ assert isinstance(configured, Settings)
143
+ assert configured.geocoder_url == "https://nominatim.openstreetmap.org"
144
+ assert settings_from_env({}) == Settings(api_key=None)
@@ -86,7 +86,7 @@ def text(result: CallToolResult) -> str:
86
86
 
87
87
 
88
88
  @pytest.mark.anyio
89
- async def test_lists_four_read_only_tools_with_schemas() -> None:
89
+ async def test_lists_five_read_only_tools_with_schemas() -> None:
90
90
  async with Client(create_server(Settings(api_key=None))) as client:
91
91
  tools = {tool.name: tool for tool in (await client.list_tools()).tools}
92
92
  assert set(tools) == {
@@ -94,6 +94,7 @@ async def test_lists_four_read_only_tools_with_schemas() -> None:
94
94
  "lookup_postcode",
95
95
  "autocomplete_postcode",
96
96
  "find_postcode_at_location",
97
+ "resolve_address",
97
98
  }
98
99
  for tool in tools.values():
99
100
  assert tool.annotations is not None
File without changes