ng-postcode-mcp 0.4.0__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.
@@ -6,3 +6,5 @@ dist/
6
6
  .mypy_cache/
7
7
  .ruff_cache/
8
8
  .pytest_cache/
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.0
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
@@ -95,6 +95,10 @@ Most clients take this entry in their MCP settings:
95
95
  | VS Code | The same server object in `.vscode/mcp.json`, under a top-level `"servers"` key instead of `"mcpServers"` |
96
96
  | Others | Any client that launches stdio servers: use the command, arguments and environment above |
97
97
 
98
+ ### As a bundle
99
+
100
+ [`bundle/`](bundle) holds an MCPB manifest for clients that install `.mcpb` files, such as Claude Desktop. It runs the published package with uv and asks for the API key in the client's own settings. Build it with `npx @anthropic-ai/mcpb pack mcp/bundle ng-postcode.mcpb` from the repository root. [`bundle-python/`](bundle-python) is the same bundle for hosts that only run Python bundles, such as Smithery; it starts the server with `uvx`, so uv must be installed. `scripts/smithery_bundle.py` builds the copy Smithery accepts, which also carries each tool's input schema.
101
+
98
102
  ## Configuration
99
103
 
100
104
  | Variable | Default | Purpose |
@@ -67,6 +67,10 @@ Most clients take this entry in their MCP settings:
67
67
  | VS Code | The same server object in `.vscode/mcp.json`, under a top-level `"servers"` key instead of `"mcpServers"` |
68
68
  | Others | Any client that launches stdio servers: use the command, arguments and environment above |
69
69
 
70
+ ### As a bundle
71
+
72
+ [`bundle/`](bundle) holds an MCPB manifest for clients that install `.mcpb` files, such as Claude Desktop. It runs the published package with uv and asks for the API key in the client's own settings. Build it with `npx @anthropic-ai/mcpb pack mcp/bundle ng-postcode.mcpb` from the repository root. [`bundle-python/`](bundle-python) is the same bundle for hosts that only run Python bundles, such as Smithery; it starts the server with `uvx`, so uv must be installed. `scripts/smithery_bundle.py` builds the copy Smithery accepts, which also carries each tool's input schema.
73
+
70
74
  ## Configuration
71
75
 
72
76
  | Variable | Default | Purpose |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "ng-postcode-mcp"
7
- version = "0.4.0"
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.0",
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.0",
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,9 +315,14 @@ 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
- latitude: Annotated[float, Field(ge=-90, le=90)],
315
- longitude: Annotated[float, Field(ge=-180, le=180)],
320
+ latitude: Annotated[
321
+ float, Field(ge=-90, le=90, description="Latitude in decimal degrees, e.g. 7.6211.")
322
+ ],
323
+ longitude: Annotated[
324
+ float, Field(ge=-180, le=180, description="Longitude in decimal degrees, e.g. 5.2214.")
325
+ ],
316
326
  ctx: Context[State, Any],
317
327
  max_distance_m: Annotated[
318
328
  float | None,
@@ -333,6 +343,7 @@ def create_server(
333
343
  annotations=READ_ONLY_ONLINE,
334
344
  structured_output=True,
335
345
  )
346
+ @tidy
336
347
  async def resolve_address(
337
348
  address: Annotated[str, Field(description="The address exactly as the user gave it.")],
338
349
  ctx: Context[State, Any],
@@ -353,9 +364,13 @@ def create_server(
353
364
  ),
354
365
  ] = None,
355
366
  latitude: Annotated[
356
- float | None, Field(ge=-90, le=90, description="A location pin the user shared.")
367
+ float | None,
368
+ Field(ge=-90, le=90, description="Latitude of a location pin the user shared."),
369
+ ] = None,
370
+ longitude: Annotated[
371
+ float | None,
372
+ Field(ge=-180, le=180, description="Longitude of that pin. Give it with latitude."),
357
373
  ] = None,
358
- longitude: Annotated[float | None, Field(ge=-180, le=180)] = None,
359
374
  ) -> Resolution:
360
375
  """Turn a described Nigerian address into a postcode, only as precisely as the
361
376
  evidence allows.
@@ -380,6 +395,13 @@ def create_server(
380
395
  return server
381
396
 
382
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
+
383
405
  def reading(landmarks: list[Landmark] | None, queries: list[str] | None) -> ParsedAddress | None:
384
406
  """The host model's reading of the address, in the resolver's terms."""
385
407
  if not landmarks and not queries:
@@ -0,0 +1,83 @@
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
+ 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
+
16
+ ROOT = Path(__file__).resolve().parents[1]
17
+
18
+
19
+ @pytest.fixture
20
+ def anyio_backend() -> str:
21
+ return "asyncio"
22
+
23
+
24
+ def server_json() -> dict[str, Any]:
25
+ data: dict[str, Any] = json.loads((ROOT / "server.json").read_text(encoding="utf-8"))
26
+ return data
27
+
28
+
29
+ def test_versions_and_names_agree() -> None:
30
+ server = server_json()
31
+ package = server["packages"][0]
32
+ assert package["identifier"] == "ng-postcode-mcp"
33
+ assert server["version"] == package["version"] == version("ng-postcode-mcp")
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
+
44
+
45
+ def test_readme_proves_registry_ownership() -> None:
46
+ marker = f"<!-- mcp-name: {server_json()['name']} -->"
47
+ assert marker in (ROOT / "README.md").read_text(encoding="utf-8")
48
+
49
+
50
+ def test_registry_description_fits() -> None:
51
+ assert len(server_json()["description"]) <= 100
52
+
53
+
54
+ def test_the_bundle_pins_this_version() -> None:
55
+ bundle = ROOT / "bundle"
56
+ if not bundle.exists(): # the bundle lives in the repository, not the sdist
57
+ return
58
+ released = version("ng-postcode-mcp")
59
+ manifest: dict[str, Any] = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
60
+ assert manifest["version"] == released
61
+ assert f'"ng-postcode-mcp=={released}"' in (bundle / "pyproject.toml").read_text()
62
+
63
+
64
+ def test_the_python_bundle_pins_this_version() -> None:
65
+ bundle = ROOT / "bundle-python"
66
+ if not bundle.exists(): # the bundle lives in the repository, not the sdist
67
+ return
68
+ released = version("ng-postcode-mcp")
69
+ manifest: dict[str, Any] = json.loads((bundle / "manifest.json").read_text(encoding="utf-8"))
70
+ assert manifest["version"] == released
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"
@@ -1,31 +0,0 @@
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
File without changes