Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions js/packages/ng-postcode-mcp/src/contract.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
18 changes: 14 additions & 4 deletions js/packages/ng-postcode-mcp/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T extends object>(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<string, Handler> = {
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) {
Expand All @@ -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 }) => {
Expand All @@ -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);
},
};

Expand Down
23 changes: 23 additions & 0 deletions js/packages/ng-postcode-mcp/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>)["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<string, unknown>, 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<string, unknown>]> = [
["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);
Expand Down
32 changes: 25 additions & 7 deletions mcp/src/ng_postcode_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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[
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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."""
Expand Down
30 changes: 29 additions & 1 deletion mcp/tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
39 changes: 39 additions & 0 deletions spec/mcp.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down Expand Up @@ -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": [
Expand Down
Loading