Skip to content

Commit 193b6ee

Browse files
committed
Add ClientSession/Client opt-out for automatic tool-result validation
Motivation: call_tool() revalidates a successful CallToolResult's structured_content against the tool's declared output_schema after every call. When the session's output-schema cache is empty -- which it always is on a short-lived session, the pattern stateless gateways and proxies use (one ClientSession per call) -- that revalidation triggers a tools/list request to discover the schema. This doubles round-trips on every call_tool and, when the server behind the session is itself an aggregator, adds that aggregator's slowest-backend tools/list latency to every single call, with no way to opt out short of subclassing ClientSession. Approach: Add a validate_tool_results: bool = True constructor parameter to both ClientSession and the high-level Client. When False, call_tool skips the automatic validate_tool_result() call entirely -- on both the direct result path and the SEP-2133 claimed-extension-result path -- so no tools/list is issued and no RuntimeError is raised for output that doesn't match a schema the caller never listed. The default stays True, so existing behavior, including the tests that rely on a fresh session auto-discovering the schema via its first validate_tool_result() call, is unchanged. This is the constructor opt-out shape from the issue's three proposed options (the alternative of skipping the refresh only on a wholly empty cache would have changed default behavior on a fresh session, which several existing tests -- test_validate_tool_result_passes_a_conforming_result and friends in tests/client/test_session_promotions.py -- deliberately lock in). Validation: - `uv run --frozen pytest tests/client/` -- 782 passed, 1 skipped, 1 xfailed - `uv run --frozen ruff format --check .` / `ruff check .` -- clean - `uv run --frozen pyright` on changed files -- 0 errors - `./scripts/test` (full coverage-gated suite) -- 5970 passed, 100.00% coverage, strict-no-cover clean - `uv run --frozen pre-commit run --files <changed>` -- markdownlint and ruff hooks pass; the pyright hook's only failure (tests/transports/stdio/test_lifecycle.py:190, os.waitid) is confirmed pre-existing on a clean main via git stash, unrelated to this change - Base branch CI (`gh run list --branch main --event push`) is green as of the last push Report: #3513 Signed-off-by: Pujitha Paladugu <10557236+pujitha24@users.noreply.github.com> Assisted-by: claude-sonnet-5 (via Claude Code)
1 parent 6affe5c commit 193b6ee

5 files changed

Lines changed: 53 additions & 2 deletions

File tree

‎docs/advanced/low-level-server.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-
117117

118118
The server never compares the two fields. This SDK's `Client` does: return `structured_content` that doesn't satisfy the `output_schema` you declared and `call_tool` raises a `RuntimeError` that starts with `Invalid structured content returned by tool search_books` and goes on to quote the `jsonschema` failure. Promising a schema is cheap; keeping it is on you. The whole ladder of return types and schemas is in **[Structured Output](../servers/structured-output.md)**.
119119

120+
That check costs a `tools/list` round-trip on a session that hasn't listed tools yet — the client needs a schema to validate against. A session built for exactly one `call_tool` (as stateless gateways and proxies often do) pays that cost every time; pass `validate_tool_results=False` to `Client`/`ClientSession` to skip the check entirely when the caller already validates elsewhere.
121+
120122
## The dialect is JSON Schema 2020-12
121123

122124
`input_schema` and `output_schema` are JSON Schema, and the [MCP specification](https://modelcontextprotocol.io/specification/latest/basic#json-schema-usage) fixes the dialect: a schema with no `$schema` key is **JSON Schema 2020-12**. The schemas `MCPServer` generates rely on that default (Pydantic writes 2020-12 and omits the key), and a hand-written dict is held to it too, so the full 2020-12 vocabulary is available:

‎src/mcp/client/client.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,13 @@ async def main():
355355
transparently by `call_tool`), and its notification bindings. For an
356356
ad-only entry use `mcp.client.advertise(identifier, settings)`."""
357357

358+
validate_tool_results: bool = True
359+
"""Whether `call_tool` revalidates a successful result against the tool's output schema.
360+
361+
The check costs a `tools/list` round-trip per call on a session that has never listed
362+
tools (e.g. a fresh session per call, as gateways and proxies often use). Set to `False`
363+
when the caller already validates structured output elsewhere."""
364+
358365
cache: CacheConfig | None = field(default_factory=CacheConfig)
359366
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).
360367
@@ -442,6 +449,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
442449
extensions=self._folded_extensions.ad,
443450
result_claims=self._folded_extensions.claims,
444451
notification_bindings=self._folded_extensions.bindings,
452+
validate_tool_results=self.validate_tool_results,
445453
)
446454

447455
async def __aenter__(self) -> Client:
@@ -818,7 +826,7 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
818826
result,
819827
ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds),
820828
)
821-
if not final.is_error:
829+
if not final.is_error and self.validate_tool_results:
822830
# Match the direct path: revalidate the output schema, but never for isError results.
823831
await self.session.validate_tool_result(name, final)
824832
return final

‎src/mcp/client/session.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,11 @@ class ClientSession:
399399
400400
Extension `result_claims` fold into tools/call parsing at `adopt()`;
401401
`notification_bindings` observe vendor notifications via bounded FIFOs.
402+
403+
`validate_tool_results=False` skips the client-side output-schema check
404+
`call_tool` otherwise runs after each successful call. On a session that has
405+
never listed tools, that check costs a `tools/list` round-trip per call; turn
406+
it off when the caller already validates elsewhere.
402407
"""
403408

404409
def __init__(
@@ -419,6 +424,7 @@ def __init__(
419424
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
420425
notification_bindings: Sequence[NotificationBinding[Any]] | None = None,
421426
dispatcher: Dispatcher[Any] | None = None,
427+
validate_tool_results: bool = True,
422428
) -> None:
423429
self._session_read_timeout_seconds = read_timeout_seconds
424430
self._client_info = client_info or DEFAULT_CLIENT_INFO
@@ -437,6 +443,7 @@ def __init__(
437443
self._logging_callback = logging_callback or _default_logging_callback
438444
self._log_level: types.LoggingLevel | None = log_level
439445
self._message_handler = message_handler or _default_message_handler
446+
self._validate_tool_results = validate_tool_results
440447
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
441448
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
442449
# `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
@@ -1098,7 +1105,7 @@ async def call_tool(
10981105
progress_callback=progress_callback,
10991106
)
11001107

1101-
if isinstance(result, types.CallToolResult) and not result.is_error:
1108+
if self._validate_tool_results and isinstance(result, types.CallToolResult) and not result.is_error:
11021109
await self.validate_tool_result(name, result)
11031110

11041111
# The input_required arm stays first; a claimed shape is terminal for the multi-round-trip driver.

‎tests/client/test_client_extensions.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,23 @@ async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
421421
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")
422422

423423

424+
async def test_validate_tool_results_false_skips_revalidation_of_the_resolvers_product() -> None:
425+
"""`validate_tool_results=False` must reach the claimed-result path too, not just the direct one:
426+
the same schema-violating product from `test_resolver_product_gets_the_direct_paths_output_schema_revalidation`
427+
comes back unraised here."""
428+
429+
async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
430+
return CallToolResult(content=[TextContent(text="unstructured")])
431+
432+
with anyio.fail_after(5):
433+
async with Client(
434+
_structured_voucher_server(), extensions=[_VoucherExtension(resolve)], validate_tool_results=False
435+
) as client:
436+
result = await client.call_tool("issue", {})
437+
438+
assert result.content == [TextContent(text="unstructured")]
439+
440+
424441
async def test_resolver_error_result_is_returned_not_raised() -> None:
425442
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""
426443

‎tests/client/test_session_promotions.py‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ async def test_validate_tool_result_keeps_the_validator_across_a_relisting_of_th
106106
assert client.session._tool_output_validators["t"] is compiled
107107

108108

109+
@pytest.mark.anyio
110+
async def test_call_tool_skips_validation_and_the_tools_list_refresh_when_opted_out() -> None:
111+
"""`validate_tool_results=False` must skip both the schema check and the `tools/list`
112+
round-trip `validate_tool_result` would otherwise spend discovering it on a fresh session.
113+
114+
The server has no `on_list_tools` handler at all, so a `tools/list` call would raise
115+
METHOD_NOT_FOUND -- the call succeeding proves the refresh never happened."""
116+
117+
async def on_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
118+
return CallToolResult(content=[], structured_content={"x": 1})
119+
120+
server = Server("test-server", on_call_tool=on_call_tool)
121+
async with Client(server, validate_tool_results=False) as client:
122+
result = await client.call_tool("t", {})
123+
assert result.structured_content == {"x": 1}
124+
125+
109126
@pytest.mark.anyio
110127
async def test_validate_tool_result_recompiles_when_the_server_changes_the_schema() -> None:
111128
"""A relisted tool must not be validated against the schema it used to declare."""

0 commit comments

Comments
 (0)