ng-postcode-mcp 0.4.1__tar.gz → 0.4.2__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.
@@ -7,3 +7,4 @@ dist/
7
7
  .ruff_cache/
8
8
  .pytest_cache/
9
9
  *.mcpb
10
+ node_modules/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ng-postcode-mcp
3
- Version: 0.4.1
3
+ Version: 0.4.2
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ng-postcode-mcp"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "MCP server for Nigeria's NIPOST digital postcode (NDAPS): validate, look up, reverse-geocode and resolve addresses to postcodes."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -3,7 +3,7 @@
3
3
  "name": "io.github.Adeniyikayodee/ng-postcode",
4
4
  "title": "Nigeria Postcode",
5
5
  "description": "Validate, look up and resolve addresses to Nigeria's NIPOST digital postcodes (NDAPS).",
6
- "version": "0.4.1",
6
+ "version": "0.4.2",
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.4.1",
16
+ "version": "0.4.2",
17
17
  "runtimeHint": "uvx",
18
18
  "transport": {
19
19
  "type": "stdio"
@@ -55,6 +55,35 @@
55
55
  ]
56
56
  }
57
57
  ]
58
+ },
59
+ {
60
+ "registryType": "npm",
61
+ "identifier": "ng-postcode-mcp",
62
+ "version": "0.1.0",
63
+ "runtimeHint": "npx",
64
+ "transport": {
65
+ "type": "stdio"
66
+ },
67
+ "environmentVariables": [
68
+ {
69
+ "name": "NG_POSTCODE_API_KEY",
70
+ "description": "NIPOST Postcode API key from dashboard.postcode.gov.ng. Validation works without it.",
71
+ "isRequired": false,
72
+ "isSecret": true
73
+ },
74
+ {
75
+ "name": "NG_POSTCODE_MAX_LEVEL",
76
+ "description": "Highest lookup level tools may request. Levels 2 and up consume NIPOST credits.",
77
+ "default": "1",
78
+ "choices": [
79
+ "1",
80
+ "2",
81
+ "3",
82
+ "4",
83
+ "5"
84
+ ]
85
+ }
86
+ ]
58
87
  }
59
88
  ]
60
89
  }
@@ -10,10 +10,11 @@ header; without one it uses the server's key, if the server has one.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
+ import inspect
13
14
  import logging
14
15
  import os
15
16
  import sys
16
- from collections.abc import AsyncIterator, Mapping
17
+ from collections.abc import AsyncIterator, Callable, Mapping
17
18
  from contextlib import asynccontextmanager
18
19
  from dataclasses import dataclass
19
20
  from importlib.metadata import version
@@ -39,6 +40,7 @@ from ng_postcode.client import TIMEOUT, AsyncClient, TransportError
39
40
  from pydantic import BaseModel, ConfigDict, Field
40
41
 
41
42
  T = TypeVar("T")
43
+ F = TypeVar("F", bound=Callable[..., Any])
42
44
 
43
45
  KEY_URL = "https://dashboard.postcode.gov.ng"
44
46
  KEY_HEADER = "x-nipost-api-key"
@@ -251,6 +253,7 @@ def create_server(
251
253
  annotations=READ_ONLY_OFFLINE,
252
254
  structured_output=True,
253
255
  )
256
+ @tidy
254
257
  def validate_postcode(
255
258
  postcode: Annotated[str, Field(description="Code in any style, e.g. 'ek 01 a03 fk 01'.")],
256
259
  ) -> Validation:
@@ -266,6 +269,7 @@ def create_server(
266
269
  annotations=READ_ONLY_ONLINE,
267
270
  structured_output=True,
268
271
  )
272
+ @tidy
269
273
  async def lookup_postcode(
270
274
  postcode: Annotated[str, Field(description="Code in any style, e.g. EK-01-A03-FK-01.")],
271
275
  ctx: Context[State, Any],
@@ -293,6 +297,7 @@ def create_server(
293
297
  annotations=READ_ONLY_ONLINE,
294
298
  structured_output=True,
295
299
  )
300
+ @tidy
296
301
  async def autocomplete_postcode(
297
302
  partial: Annotated[
298
303
  str, Field(min_length=1, description="The start of a code, e.g. 'EK 01 A'.")
@@ -310,6 +315,7 @@ def create_server(
310
315
  annotations=READ_ONLY_ONLINE,
311
316
  structured_output=True,
312
317
  )
318
+ @tidy
313
319
  async def find_postcode_at_location(
314
320
  latitude: Annotated[
315
321
  float, Field(ge=-90, le=90, description="Latitude in decimal degrees, e.g. 7.6211.")
@@ -337,6 +343,7 @@ def create_server(
337
343
  annotations=READ_ONLY_ONLINE,
338
344
  structured_output=True,
339
345
  )
346
+ @tidy
340
347
  async def resolve_address(
341
348
  address: Annotated[str, Field(description="The address exactly as the user gave it.")],
342
349
  ctx: Context[State, Any],
@@ -388,6 +395,13 @@ def create_server(
388
395
  return server
389
396
 
390
397
 
398
+ def tidy(tool: F) -> F:
399
+ """Give the tool the same description on every Python: before 3.13 a docstring keeps
400
+ its indentation."""
401
+ tool.__doc__ = inspect.cleandoc(tool.__doc__ or "")
402
+ return tool
403
+
404
+
391
405
  def reading(landmarks: list[Landmark] | None, queries: list[str] | None) -> ParsedAddress | None:
392
406
  """The host model's reading of the address, in the resolver's terms."""
393
407
  if not landmarks and not queries:
@@ -7,9 +7,20 @@ from importlib.metadata import version
7
7
  from pathlib import Path
8
8
  from typing import Any
9
9
 
10
+ import pytest
11
+ from mcp import Client
12
+
13
+ from ng_postcode_mcp import Settings, create_server
14
+ from ng_postcode_mcp.server import INSTRUCTIONS
15
+
10
16
  ROOT = Path(__file__).resolve().parents[1]
11
17
 
12
18
 
19
+ @pytest.fixture
20
+ def anyio_backend() -> str:
21
+ return "asyncio"
22
+
23
+
13
24
  def server_json() -> dict[str, Any]:
14
25
  data: dict[str, Any] = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
15
26
  return data
@@ -21,6 +32,15 @@ def test_versions_and_names_agree() -> None:
21
32
  assert package["identifier"] == "ng-postcode-mcp"
22
33
  assert server["version"] == package["version"] == version("ng-postcode-mcp")
23
34
 
35
+ manifest = ROOT.parent / "js/packages/ng-postcode-mcp/package.json"
36
+ if not manifest.exists(): # the Node package lives in the repository, not the sdist
37
+ return
38
+ node = json.loads(manifest.read_text(encoding="utf-8"))
39
+ listed = server["packages"][1]
40
+ assert (listed["registryType"], listed["identifier"]) == ("npm", node["name"])
41
+ assert listed["version"] == node["version"]
42
+ assert node["mcpName"] == server["name"]
43
+
24
44
 
25
45
  def test_readme_proves_registry_ownership() -> None:
26
46
  marker = f"<!-- mcp-name: {server_json()['name']} -->"
@@ -49,3 +69,15 @@ def test_the_python_bundle_pins_this_version() -> None:
49
69
  manifest: dict[str, Any] = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
50
70
  assert manifest["version"] == released
51
71
  assert f'"ng-postcode-mcp=={released}"' in (bundle / "server" / "main.py").read_text()
72
+
73
+
74
+ @pytest.mark.anyio
75
+ async def test_the_shared_contract_matches_this_server() -> None:
76
+ shared = ROOT.parent / "spec" / "mcp.json"
77
+ if not shared.exists(): # the contract lives in the repository, not the sdist
78
+ return
79
+ async with Client(create_server(Settings(api_key=None))) as client:
80
+ tools = (await client.list_tools()).tools
81
+ live = [t.model_dump(mode="json", by_alias=True, exclude_none=True) for t in tools]
82
+ recorded: dict[str, Any] = json.loads(shared.read_text(encoding="utf-8"))
83
+ assert recorded == {"instructions": INSTRUCTIONS, "tools": live}, "run scripts/mcp_spec.py"
File without changes