Skip to content

Commit dc6635e

Browse files
committed
Add ClientSession.register_tool_schema for dynamic tool discovery.
Lets clients register output schemas for tools that never appear in list_tools(), so call_tool can validate structuredContent without touching private caches. Mirrored on Client; complete listings still prune unlisted registrations. Fixes #3145
1 parent 6e30452 commit dc6635e

4 files changed

Lines changed: 195 additions & 4 deletions

File tree

docs/client/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ That is why `main` narrows with `isinstance(block, TextContent)` before touching
106106

107107
When both are present they say the same thing twice on purpose: `content` is for a model, `structured_content` is for code. Where the structured half comes from, and how to control it, is the **[Structured Output](../servers/structured-output.md)** page.
108108

109+
The client validates `structured_content` against schemas learned from `list_tools()`. If a server exposes tools through a search or catalog API instead of listing them all, call `register_tool_schema(name, output_schema)` (on `Client` or `ClientSession`) before `call_tool` so those results are validated the same way. A later complete `list_tools()` that omits that name drops the registration — re-register if you still need it.
110+
109111
### `is_error`: whether the tool failed
110112

111113
A tool that raises does **not** raise in your client. It comes back as an ordinary result with `is_error=True`.

src/mcp/client/client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -943,6 +943,14 @@ async def list_tools(
943943
),
944944
)
945945

946+
def register_tool_schema(self, name: str, output_schema: dict[str, Any] | None = None) -> None:
947+
"""Register a tool's output schema for result validation.
948+
949+
Delegates to `ClientSession.register_tool_schema`. Use when tools are discovered
950+
dynamically and will not appear in `list_tools()` responses.
951+
"""
952+
self.session.register_tool_schema(name, output_schema)
953+
946954
@deprecated("The roots capability is deprecated as of 2026-07-28 (SEP-2577).", category=MCPDeprecationWarning)
947955
async def send_roots_list_changed(self) -> None:
948956
"""Send a notification that the roots list has changed."""

src/mcp/client/session.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -405,8 +405,8 @@ def __init__(
405405
self._log_level: types.LoggingLevel | None = log_level
406406
self._message_handler = message_handler or _default_message_handler
407407
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
408-
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
409-
# `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
408+
# Compiled output-schema validators, derived from `_tool_output_schemas`. Evicted by
409+
# `_absorb_tool_listing` and `register_tool_schema` whenever a tool's schema changes.
410410
self._tool_output_validators: dict[str, Validator] = {}
411411
self._x_mcp_header_maps: dict[str, dict[tuple[str, ...], str]] = {}
412412
self._initialize_result: types.InitializeResult | None = None
@@ -1077,6 +1077,30 @@ def _resolve_param_headers(self, name: str, arguments: Mapping[str, Any]) -> dic
10771077
return {}
10781078
return mcp_param_headers(header_map, arguments)
10791079

1080+
def register_tool_schema(self, name: str, output_schema: dict[str, Any] | None = None) -> None:
1081+
"""Register a tool's output schema for result validation.
1082+
1083+
Use this when tools are discovered dynamically (for example via a catalog search API)
1084+
and will not appear in `list_tools()` responses. Writes into the same cache that
1085+
`list_tools()` / `_absorb_tool_listing` populate, and evicts any compiled validator
1086+
when the registered schema differs from the previously cached one.
1087+
1088+
A later complete (uncursored, single-page) `list_tools()` that omits `name` drops the
1089+
registration, the same prune path used for listing-absorbed schemas. Re-register after
1090+
such a listing if the tool is still in use. If the listing includes `name`, the listed
1091+
`outputSchema` replaces this registration.
1092+
1093+
Args:
1094+
name: Tool name as passed to `call_tool`.
1095+
output_schema: JSON Schema for `structuredContent`, or `None` when the tool has no
1096+
output schema (suppresses the "not listed" warning without validating).
1097+
"""
1098+
if name in self._tool_output_validators and not _same_schema(
1099+
self._tool_output_schemas.get(name), output_schema
1100+
):
1101+
del self._tool_output_validators[name]
1102+
self._tool_output_schemas[name] = output_schema
1103+
10801104
async def validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
10811105
"""Revalidate a `CallToolResult` against the tool's declared output schema.
10821106
@@ -1114,8 +1138,9 @@ def _output_schema_validator(self, name: str, output_schema: dict[str, Any]) ->
11141138
11151139
Compiling is ~60x the cost of validating, so a one-shot `jsonschema.validate()` per
11161140
result dominates `call_tool`; the compiled validator is cached instead. It stays valid
1117-
because `_absorb_tool_listing` evicts a tool's validator whenever it absorbs a different
1118-
schema for that tool, so a cached entry always matches `output_schema`.
1141+
because `_absorb_tool_listing` and `register_tool_schema` evict a tool's validator
1142+
whenever they store a different schema for that tool, so a cached entry always matches
1143+
`output_schema`.
11191144
11201145
Raises:
11211146
RuntimeError: The schema is not a valid JSON Schema. Raised on every call, since a
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
"""`ClientSession.register_tool_schema` for tools absent from `list_tools`."""
2+
3+
import logging
4+
5+
import pytest
6+
from mcp_types import (
7+
CallToolRequestParams,
8+
CallToolResult,
9+
ListToolsResult,
10+
PaginatedRequestParams,
11+
Tool,
12+
)
13+
14+
from mcp.client.client import Client
15+
from mcp.server import Server, ServerRequestContext
16+
17+
_SCORE_SCHEMA: dict[str, object] = {
18+
"type": "object",
19+
"properties": {"score": {"type": "integer"}},
20+
"required": ["score"],
21+
}
22+
_SCORE_AS_STRING_SCHEMA: dict[str, object] = {
23+
"type": "object",
24+
"properties": {"score": {"type": "string"}},
25+
"required": ["score"],
26+
}
27+
28+
29+
def _dynamic_tool_server(*, structured_content: dict[str, object]) -> Server:
30+
"""`list_tools` advertises only a search meta-tool; `analyze` is callable but unlisted."""
31+
32+
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
33+
return ListToolsResult(tools=[Tool(name="search", input_schema={"type": "object"})])
34+
35+
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
36+
assert params.name == "analyze"
37+
return CallToolResult(content=[], structured_content=structured_content)
38+
39+
return Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
40+
41+
42+
@pytest.mark.anyio
43+
async def test_register_tool_schema_lets_call_tool_validate_an_unlisted_tool() -> None:
44+
"""SDK-defined: a schema registered for a tool absent from list_tools is used by call_tool."""
45+
server = _dynamic_tool_server(structured_content={"score": 1})
46+
async with Client(server) as client:
47+
client.register_tool_schema("analyze", _SCORE_SCHEMA)
48+
result = await client.call_tool("analyze", {})
49+
assert result.structured_content == {"score": 1}
50+
51+
52+
@pytest.mark.anyio
53+
async def test_register_tool_schema_makes_call_tool_reject_nonconforming_structured_content() -> None:
54+
"""Without registration, an unlisted tool skips validation; with it, mismatches raise."""
55+
server = _dynamic_tool_server(structured_content={"score": "no"})
56+
async with Client(server) as client:
57+
# Unregistered: validation is skipped (tool never appears in list_tools).
58+
skipped = await client.call_tool("analyze", {})
59+
assert skipped.structured_content == {"score": "no"}
60+
61+
client.register_tool_schema("analyze", _SCORE_SCHEMA)
62+
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
63+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool analyze"):
64+
await client.call_tool("analyze", {})
65+
66+
67+
@pytest.mark.anyio
68+
async def test_register_tool_schema_with_none_suppresses_the_unlisted_warning(
69+
caplog: pytest.LogCaptureFixture,
70+
) -> None:
71+
"""SDK-defined: registering None marks the tool known without validating structuredContent."""
72+
server = _dynamic_tool_server(structured_content={"anything": True})
73+
async with Client(server) as client:
74+
client.register_tool_schema("analyze", None)
75+
with caplog.at_level(logging.WARNING, logger="client"):
76+
result = await client.call_tool("analyze", {})
77+
assert result.structured_content == {"anything": True}
78+
assert "not listed by server" not in caplog.text
79+
80+
81+
@pytest.mark.anyio
82+
async def test_register_tool_schema_evicts_the_compiled_validator_when_the_schema_changes() -> None:
83+
"""SDK-defined: a changed registration must not reuse a validator compiled for the old schema."""
84+
server = _dynamic_tool_server(structured_content={"score": 1})
85+
async with Client(server) as client:
86+
client.register_tool_schema("analyze", _SCORE_SCHEMA)
87+
await client.session.validate_tool_result(
88+
"analyze", CallToolResult(content=[], structured_content={"score": 1})
89+
)
90+
compiled = client.session._tool_output_validators["analyze"]
91+
92+
client.register_tool_schema("analyze", _SCORE_AS_STRING_SCHEMA)
93+
assert "analyze" not in client.session._tool_output_validators
94+
95+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool analyze"):
96+
await client.session.validate_tool_result(
97+
"analyze", CallToolResult(content=[], structured_content={"score": 1})
98+
)
99+
assert client.session._tool_output_validators["analyze"] is not compiled
100+
101+
102+
@pytest.mark.anyio
103+
async def test_register_tool_schema_keeps_the_validator_when_the_schema_is_unchanged() -> None:
104+
"""SDK-defined: re-registering an equal schema keeps the compiled validator."""
105+
server = _dynamic_tool_server(structured_content={"score": 1})
106+
async with Client(server) as client:
107+
client.register_tool_schema("analyze", _SCORE_SCHEMA)
108+
result = CallToolResult(content=[], structured_content={"score": 1})
109+
await client.session.validate_tool_result("analyze", result)
110+
compiled = client.session._tool_output_validators["analyze"]
111+
112+
client.register_tool_schema("analyze", dict(_SCORE_SCHEMA))
113+
await client.session.validate_tool_result("analyze", result)
114+
assert client.session._tool_output_validators["analyze"] is compiled
115+
116+
117+
@pytest.mark.anyio
118+
async def test_a_complete_list_tools_prunes_a_manually_registered_schema() -> None:
119+
"""SDK-defined: a complete listing is still the full tool universe for prune — a registered
120+
tool omitted from that listing is dropped, same as listing-absorbed schemas."""
121+
server = _dynamic_tool_server(structured_content={"score": 1})
122+
async with Client(server) as client:
123+
client.register_tool_schema("analyze", _SCORE_SCHEMA)
124+
assert "analyze" in client.session._tool_output_schemas
125+
126+
await client.session.list_tools()
127+
assert "analyze" not in client.session._tool_output_schemas
128+
assert set(client.session._tool_output_schemas) == {"search"}
129+
130+
131+
@pytest.mark.anyio
132+
async def test_list_tools_that_includes_a_registered_name_replaces_the_registered_schema() -> None:
133+
"""SDK-defined: when the same name later appears in a listing, the listed schema wins."""
134+
135+
async def on_list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
136+
return ListToolsResult(
137+
tools=[
138+
Tool(name="analyze", input_schema={"type": "object"}, output_schema=_SCORE_AS_STRING_SCHEMA),
139+
]
140+
)
141+
142+
async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
143+
raise NotImplementedError
144+
145+
server = Server("test-server", on_list_tools=on_list_tools, on_call_tool=on_call_tool)
146+
async with Client(server) as client:
147+
client.register_tool_schema("analyze", _SCORE_SCHEMA)
148+
await client.session.validate_tool_result(
149+
"analyze", CallToolResult(content=[], structured_content={"score": 1})
150+
)
151+
152+
await client.session.list_tools()
153+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool analyze"):
154+
await client.session.validate_tool_result(
155+
"analyze", CallToolResult(content=[], structured_content={"score": 1})
156+
)

0 commit comments

Comments
 (0)