From 61a67202cac46ee354d3adc1db4d3a957f800044 Mon Sep 17 00:00:00 2001 From: mkbadeniyi Date: Mon, 12 Oct 2026 13:24:11 +0100 Subject: [PATCH] feat(mcp): note when a test key finds nothing --- .../src/contract.generated.json | 39 +++++++++++++++++++ js/packages/ng-postcode-mcp/src/server.ts | 18 +++++++-- .../ng-postcode-mcp/test/server.test.ts | 23 +++++++++++ mcp/src/ng_postcode_mcp/server.py | 32 +++++++++++---- mcp/tests/test_tools.py | 30 +++++++++++++- spec/mcp.json | 39 +++++++++++++++++++ 6 files changed, 169 insertions(+), 12 deletions(-) diff --git a/js/packages/ng-postcode-mcp/src/contract.generated.json b/js/packages/ng-postcode-mcp/src/contract.generated.json index 6a10f83..a14c0bd 100644 --- a/js/packages/ng-postcode-mcp/src/contract.generated.json +++ b/js/packages/ng-postcode-mcp/src/contract.generated.json @@ -317,6 +317,19 @@ "default": null, "description": "Level 5, unstructured.", "title": "Point Geometry" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the answer may be incomplete, such as a test key.", + "title": "Note" } }, "required": [ @@ -501,6 +514,19 @@ "default": null, "description": "Level 5, unstructured.", "title": "Point Geometry" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the answer may be incomplete, such as a test key.", + "title": "Note" } }, "required": [ @@ -837,6 +863,19 @@ "default": null, "description": "How deep the match goes, e.g. unit.", "title": "Depth" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the answer may be incomplete, such as a test key.", + "title": "Note" } }, "required": [ diff --git a/js/packages/ng-postcode-mcp/src/server.ts b/js/packages/ng-postcode-mcp/src/server.ts index dcf1ae2..0ffce56 100644 --- a/js/packages/ng-postcode-mcp/src/server.ts +++ b/js/packages/ng-postcode-mcp/src/server.ts @@ -82,14 +82,23 @@ export function createServer(settings: Settings): McpServer { ); }; + // A test key that finds nothing says so: NIPOST may hold the building. + const testKey = /^nipost_(pk_)?test_/.test(settings.apiKey ?? ""); + const noted = (answer: T, missing: boolean) => ({ + ...answer, + note: missing && testKey ? "Asked with a test key, which may not see assigned codes." : null, + }); + const maxPaidCalls = settings.maxPaidCalls ?? 25; let paid = 0; const handlers: Record = { validate_postcode: ({ postcode }) => validation(String(postcode)), - lookup_postcode: async ({ postcode }) => - unwrap(await api().send(lookup(checked(String(postcode)), 1))), + lookup_postcode: async ({ postcode }) => { + const found = unwrap(await api().send(lookup(checked(String(postcode)), 1))); + return noted(found, !found.valid); + }, lookup_postcode_details: async ({ postcode, level = 2 }) => { if (Number(level) > settings.maxLevel) { @@ -107,7 +116,8 @@ export function createServer(settings: Settings): McpServer { paid += 1; const result = await client.send(lookup(code, Number(level))); if (!reachedBilling(result)) paid -= 1; - return unwrap(result); + const found = unwrap(result); + return noted(found, !found.valid); }, autocomplete_postcode: async ({ partial }) => { @@ -120,7 +130,7 @@ export function createServer(settings: Settings): McpServer { const at = { lat: Number(latitude), lng: Number(longitude) }; const metres = max_distance_m == null ? undefined : Number(max_distance_m); const { coordinate: _, ...found } = unwrap(await api().send(reverse(at, metres))); - return capped(found, settings.maxLevel); + return noted(capped(found, settings.maxLevel), !found.found); }, }; diff --git a/js/packages/ng-postcode-mcp/test/server.test.ts b/js/packages/ng-postcode-mcp/test/server.test.ts index 480ef1b..4ede1e0 100644 --- a/js/packages/ng-postcode-mcp/test/server.test.ts +++ b/js/packages/ng-postcode-mcp/test/server.test.ts @@ -161,6 +161,29 @@ test("autocompletes, and withholds address fields under the level cap", async () expect(outside.isError).toBe(true); }); +test("a test key that finds nothing says so", async () => { + const answer = (async (url: URL, init: RequestInit) => { + const found = (init.headers as Record)["X-API-Key"]?.endsWith("found"); + const data = url.pathname === "/v1/lookup" ? { postcode: "x", valid: found } : { found }; + return Response.json({ data }); + }) as typeof fetch; + const note = async (name: string, args: Record, apiKey: string) => { + const result = await call(await connect({ apiKey, fetch: answer }), name, args); + return (result.structuredContent as { note: string | null }).note; + }; + const calls: Array<[string, Record]> = [ + ["lookup_postcode", { postcode: "EK-01-A03-FK-01" }], + ["find_postcode_at_location", { latitude: 7.62, longitude: 5.22 }], + ]; + for (const [name, args] of calls) { + expect(await note(name, args, "nipost_pk_test_1")).toBe( + "Asked with a test key, which may not see assigned codes.", + ); + expect(await note(name, args, "nipost_pk_test_found")).toBeNull(); + expect(await note(name, args, "nipost_live_1")).toBeNull(); + } +}); + test("the reported version is the package version", () => { const manifest = new URL("../package.json", import.meta.url); expect(VERSION).toBe(JSON.parse(readFileSync(manifest, "utf8")).version); diff --git a/mcp/src/ng_postcode_mcp/server.py b/mcp/src/ng_postcode_mcp/server.py index 7b94ca4..1750f44 100644 --- a/mcp/src/ng_postcode_mcp/server.py +++ b/mcp/src/ng_postcode_mcp/server.py @@ -237,6 +237,9 @@ class PostcodeDetails(FromLibrary): building_use_status: str | None = Field(description="Level 3 and up, e.g. residential.") other_building_info: Any = Field(default=None, description="Level 4 and up, unstructured.") point_geometry: Any = Field(default=None, description="Level 5, unstructured.") + note: str | None = Field( + default=None, description="Why the answer may be incomplete, such as a test key." + ) class Completion(FromLibrary): @@ -273,6 +276,9 @@ class Location(FromLibrary): message: str | None radius_m: float | None = Field(description="The radius the API actually applied.") depth: str | None = Field(default=None, description="How deep the match goes, e.g. unit.") + note: str | None = Field( + default=None, description="Why the answer may be incomplete, such as a test key." + ) @dataclass(frozen=True, slots=True) @@ -369,8 +375,9 @@ async def lookup_postcode( For the address and building details, use lookup_postcode_details. """ - result = await api(ctx).send(lookup(checked(postcode), level)) - return PostcodeDetails.model_validate(unwrap(result)) + client = api(ctx) + result = await client.send(lookup(checked(postcode), level)) + return noted(PostcodeDetails.model_validate(unwrap(result)), client) async def lookup_postcode_details( postcode: Annotated[ @@ -413,7 +420,7 @@ async def lookup_postcode_details( result = await client.send(lookup(code, level)) if mine and not reached_billing(result): state.paid[0] -= 1 - return PostcodeDetails.model_validate(unwrap(result)) + return noted(PostcodeDetails.model_validate(unwrap(result)), client) # Not offered at all under the default cap, so a model cannot spend by accident. if settings.max_level >= 2: @@ -476,10 +483,10 @@ async def find_postcode_at_location( Names and the house address are withheld unless NG_POSTCODE_MAX_LEVEL is 2 or more. """ - result = await api(ctx).send( - reverse(Coordinate(lat=latitude, lng=longitude), max_distance_m) - ) - return capped(Location.model_validate(unwrap(result)), settings.max_level) + client = api(ctx) + result = await client.send(reverse(Coordinate(lat=latitude, lng=longitude), max_distance_m)) + found = capped(Location.model_validate(unwrap(result)), settings.max_level) + return noted(found, client) @server.tool( title="Resolve a Nigerian address to a postcode", @@ -595,6 +602,17 @@ def geocoder_for(settings: Settings, http: httpx.AsyncClient | None) -> Nominati ) +Answer = TypeVar("Answer", PostcodeDetails, Location) + +TEST_KEY = "Asked with a test key, which may not see assigned codes." + + +def noted(answer: Answer, client: AsyncClient) -> Answer: + """The answer, with a note when a test key found nothing: NIPOST may hold the building.""" + missing = not (answer.valid if isinstance(answer, PostcodeDetails) else answer.found) + return answer.model_copy(update={"note": TEST_KEY}) if missing and client.test_key else answer + + def capped(location: Location, max_level: int) -> Location: """The location with only the unit's level 1 fields, unless the cap allows more. Any field the unit gains later is withheld until it is listed as level 1.""" diff --git a/mcp/tests/test_tools.py b/mcp/tests/test_tools.py index c79524a..52bb29c 100644 --- a/mcp/tests/test_tools.py +++ b/mcp/tests/test_tools.py @@ -304,7 +304,35 @@ async def test_rejects_out_of_range_arguments() -> None: def test_output_models_only_use_fields_the_library_has( model: type[BaseModel], source: type ) -> None: - assert set(model.model_fields) <= {field.name for field in dataclasses.fields(source)} + # The note is the server's own. + fields = set(model.model_fields) - {"note"} + assert fields <= {field.name for field in dataclasses.fields(source)} + + +@pytest.mark.anyio +async def test_a_test_key_that_finds_nothing_says_so() -> None: + def handle(request: httpx.Request) -> httpx.Response: + found = request.headers["X-API-Key"].endswith("found") + if request.url.path == "/v1/lookup": + return httpx.Response(200, json={"data": {"postcode": "x", "valid": found}}) + return httpx.Response(200, json={"data": {"found": found}}) + + async def note(tool: str, arguments: dict[str, Any], key: str) -> Any: + http = httpx.AsyncClient(transport=httpx.MockTransport(handle)) + async with Client(create_server(Settings(api_key=key), http=http)) as client: + result = await client.call_tool(tool, arguments) + assert result.structured_content is not None + return result.structured_content["note"] + + calls: list[tuple[str, dict[str, Any]]] = [ + ("lookup_postcode", {"postcode": "EK-01-A03-FK-01"}), + ("find_postcode_at_location", {"latitude": 7.62, "longitude": 5.22}), + ] + for tool, arguments in calls: + said = await note(tool, arguments, "nipost_pk_test_1") + assert said == "Asked with a test key, which may not see assigned codes." + assert await note(tool, arguments, "nipost_pk_test_found") is None + assert await note(tool, arguments, "nipost_live_1") is None def test_transport_settings() -> None: diff --git a/spec/mcp.json b/spec/mcp.json index 6a10f83..a14c0bd 100644 --- a/spec/mcp.json +++ b/spec/mcp.json @@ -317,6 +317,19 @@ "default": null, "description": "Level 5, unstructured.", "title": "Point Geometry" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the answer may be incomplete, such as a test key.", + "title": "Note" } }, "required": [ @@ -501,6 +514,19 @@ "default": null, "description": "Level 5, unstructured.", "title": "Point Geometry" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the answer may be incomplete, such as a test key.", + "title": "Note" } }, "required": [ @@ -837,6 +863,19 @@ "default": null, "description": "How deep the match goes, e.g. unit.", "title": "Depth" + }, + "note": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the answer may be incomplete, such as a test key.", + "title": "Note" } }, "required": [