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
15 changes: 10 additions & 5 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,20 @@ license = { file = "LICENSE" }
# declare >=3.10, so 3.11 is a conservative floor.
requires-python = ">=3.11"
dependencies = [
# 0.6.0 is the floor: its v2 tools/list handler reads the calling session
# off the request context, and it pairs with toolregistry 0.17+ below.
"toolregistry-server[mcp]>=0.6.0",
# 0.7.0 is the floor: it pairs with toolregistry 0.19 below, whose
# schema change alters what every served tool advertises.
"toolregistry-server[mcp]>=0.7.0",
# Imported directly (ToolRegistry, ToolTag), so it is declared directly
# rather than leaned on transitively. 0.16.0 taught is_content_block_list
# about audio/resource_link/resource; 0.17 froze ToolMetadata, so the
# policy-tag merge has to mutate the tag sets in place rather than
# rebind them, and 0.18 registers call_deferred beside discover_tools.
"toolregistry>=0.18.0",
# 0.19 is the floor because it emits `anyOf` for a heterogeneous union
# instead of dropping branches (Oaklight/ToolRegistry#269). Below it,
# `lat_spec` reached clients as an untyped blob and we repaired the
# schema on the way out; that repair is gone, so an older registry
# would silently under-advertise the parameter. See #165.
"toolregistry>=0.19.0",
# Both SDK generations are in range and register handlers differently:
# 1.27.2 has the per-method decorators and no `add_request_handler`, 2.1.1
# has `add_request_handler` and no decorators. `resources.py` registers
Expand Down Expand Up @@ -76,7 +81,7 @@ dependencies = [

[project.optional-dependencies]
openapi = [
"toolregistry-server[openapi]>=0.6.0",
"toolregistry-server[openapi]>=0.7.0",
]
hpc = [
"academy-py>=0.3.1",
Expand Down
18 changes: 16 additions & 2 deletions src/uxarray_mcp/annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,11 +186,25 @@ async def on_list_tools(*args: Any, **kwargs: Any) -> Any:
# nothing on the floor and silently annotated no tool at all.
payload = getattr(result, "root", result)
for tool in getattr(payload, "tools", []):
if getattr(tool, "annotations", None) is not None:
continue
hints = annotations_for(tool.name)
if hints:
# Ours wins. toolregistry-server 0.7.0 began inferring
# annotations of its own, and deferring to what was already
# there would let an inference overrule a rating we made
# deliberately.
tool.annotations = ToolAnnotations(**hints)
elif getattr(tool, "annotations", None) is not None:
# Unclassified here, annotated by upstream. 0.7.0 marks
# `manage_session` and `resume_workflow` readOnlyHint=True;
# the first can reset state and clear artifacts, the second
# runs pending workflow steps. Neither is read-only, and a
# client reading that hint may auto-approve them.
#
# Absence is the honest answer for anything we have not
# rated: an inferred hint is a claim nobody checked, and a
# wrong safe-to-run claim is the one failure direction that
# costs a user something.
tool.annotations = None
return result

if hasattr(entry, "handler"):
Expand Down
42 changes: 30 additions & 12 deletions src/uxarray_mcp/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -673,15 +673,27 @@ def _trim_wire_schema(tool: Any) -> None:
_type_untyped_unions(params)


#: JSON Schema for parameters whose Python annotation toolregistry cannot
#: express. ``tuple | float | list[Any] | None`` comes out as ``{"default":
#: null}`` -- no ``type`` at all -- and ``float | list[float] | None`` as
#: ``{"type": "number"}``, which drops the list. A client reading the first
#: has nothing to go on and sends the literal text ``"[-60, 60]"``, which
#: the server's own validator then refuses as neither number nor list. The
#: parameter was unusable from every MCP client, while direct Python calls
#: JSON Schema for parameters whose Python annotation toolregistry renders
#: badly.
#:
#: Originally this filled in a union that arrived with no ``type`` at all:
#: ``tuple | float | list[Any] | None`` came out as ``{"default": null}``,
#: so a client had nothing to follow and sent the literal text
#: ``"[-60, 60]"``, which the server's own validator then refused. The
#: parameter was unusable from every MCP client while direct Python calls
#: and every test passed. Found on the first remote zonal mean through a
#: real client.
#:
#: toolregistry 0.19 fixed that (Oaklight/ToolRegistry#269) and now emits
#: ``anyOf``. It is still not what we want to serve: ``tuple`` and ``list``
#: both render as ``array``, so the union carries the same branch twice,
#: and the items are untyped -- ``{"type":"array","items":{}}`` beside a
#: second identical entry. A duplicated branch is noise in a payload we
#: measure, and untyped items say less than we know.
#:
#: So this no longer fills a gap; it replaces a worse rendering with the
#: canonical one. Kept keyed by parameter name, and still a no-op for
#: anything not listed.
_UNTYPED_UNION_SCHEMAS: dict[str, dict[str, Any]] = {
"lat_spec": {
"anyOf": [
Expand All @@ -693,10 +705,16 @@ def _trim_wire_schema(tool: Any) -> None:


def _type_untyped_unions(schema: Any) -> None:
"""Give a JSON type to union parameters upstream rendered without one.
"""Serve the canonical schema for a union upstream renders poorly.

Only fills a property that has neither ``type`` nor ``anyOf``; a future
toolregistry that renders the union itself makes this a no-op.
Replaces whatever is there, rather than only filling an absence: since
toolregistry 0.19 there *is* an ``anyOf``, but it repeats the ``array``
branch (``tuple`` and ``list`` both become ``array``) and leaves its
items untyped. Deferring to that would serve a duplicated branch and
drop the item type we know.

``default`` is preserved -- it is upstream's to decide and carries the
parameter's optionality.
"""
if not isinstance(schema, dict):
return
Expand All @@ -707,8 +725,8 @@ def _type_untyped_unions(schema: Any) -> None:
fix = _UNTYPED_UNION_SCHEMAS.get(name)
if fix is None or not isinstance(spec, dict):
continue
if "type" in spec or "anyOf" in spec:
continue
spec.pop("type", None)
spec.pop("anyOf", None)
spec.update(fix)


Expand Down
16 changes: 14 additions & 2 deletions tests/test_mcp_protocol.py
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,22 @@ async def test_a_missing_file_is_reported_as_a_tool_error(self):
assert "not found" in text.lower() or "no such file" in text.lower()

async def test_calling_an_unknown_tool_is_handled(self):
"""A name that does not exist is a protocol error, not a result.

toolregistry-server 0.7.0 changed this: it used to come back as a
``CallToolResult`` with ``isError``, and now raises JSON-RPC
``-32602`` (invalid params). The new behaviour is the correct one
-- ``isError`` is for a tool that ran and failed, while an unknown
name means nothing ran -- so this asserts the new contract rather
than pinning the old one.
"""
from mcp.shared.exceptions import MCPError

async with connect() as (session, _init):
result = await session.call_tool("no_such_tool_exists", {})
with pytest.raises(MCPError) as err:
await session.call_tool("no_such_tool_exists", {})

assert wire(result)["isError"] is True
assert "no_such_tool_exists" in str(err.value)


class TestResources:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_tool_annotations.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,54 @@ async def test_a_result_wrapped_in_a_server_envelope_is_annotated(self):

hints = await self._annotated_names(ListToolsRequest, envelope=True)
assert hints["readOnlyHint"] is True


class TestUpstreamInferenceDoesNotOverruleUs:
"""An inferred hint must not outrank a deliberate one.

toolregistry-server 0.7.0 began attaching annotations of its own, and
marked ``manage_session`` and ``resume_workflow`` ``readOnlyHint=True``.
The first resets session state and clears artifacts; the second runs
pending workflow steps. A client reading that hint may auto-approve
them without asking.

Two rules follow, and these pin both: where we have rated a tool, our
rating is what goes out; where we have not, nothing goes out, because
absence is honest and an unchecked inference is not.
"""

@staticmethod
async def _served():
from mcp_harness import connect

async with connect(profile="deferred-full") as (session, _init):
return (await session.list_tools()).tools

async def test_no_unrated_tool_is_served_an_inferred_annotation(self):
from uxarray_mcp.annotations import annotations_for

surprises = [
t.name
for t in await self._served()
if annotations_for(t.name) is None and t.annotations is not None
]
assert not surprises, (
"annotations appeared on tools nobody rated, so something "
f"upstream guessed: {sorted(surprises)}"
)

async def test_a_state_changing_tool_is_never_served_as_read_only(self):
"""The specific claim 0.7.0 made, asserted by name.

These two are the ones it got wrong; naming them means a future
release making the same guess fails here rather than in a client.
"""
served = {t.name: t for t in await self._served()}
for name in ("manage_session", "resume_workflow"):
tool = served.get(name)
if tool is None: # pragma: no cover - profile shape changed
continue
hints = tool.annotations
assert (
hints is None or getattr(hints, "read_only_hint", None) is not True
), f"{name} changes state but is advertised read-only"
Loading
Loading