Skip to content
Closed
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
2 changes: 2 additions & 0 deletions docs/advanced/low-level-server.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,8 @@ The `_meta` block is the server's identity stamp: the SDK adds it to every 2026-

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)**.

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.

## The dialect is JSON Schema 2020-12

`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:
Expand Down
10 changes: 9 additions & 1 deletion src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,13 @@ async def main():
transparently by `call_tool`), and its notification bindings. For an
ad-only entry use `mcp.client.advertise(identifier, settings)`."""

validate_tool_results: bool = True
"""Whether `call_tool` revalidates a successful result against the tool's output schema.

The check costs a `tools/list` round-trip per call on a session that has never listed
tools (e.g. a fresh session per call, as gateways and proxies often use). Set to `False`
when the caller already validates structured output elsewhere."""

cache: CacheConfig | None = field(default_factory=CacheConfig)
"""Client-side response caching for the SEP-2549 cacheable methods (2026-07-28).

Expand Down Expand Up @@ -442,6 +449,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
extensions=self._folded_extensions.ad,
result_claims=self._folded_extensions.claims,
notification_bindings=self._folded_extensions.bindings,
validate_tool_results=self.validate_tool_results,
)

async def __aenter__(self) -> Client:
Expand Down Expand Up @@ -818,7 +826,7 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
result,
ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds),
)
if not final.is_error:
if not final.is_error and self.validate_tool_results:
# Match the direct path: revalidate the output schema, but never for isError results.
await self.session.validate_tool_result(name, final)
return final
Expand Down
9 changes: 8 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,11 @@ class ClientSession:

Extension `result_claims` fold into tools/call parsing at `adopt()`;
`notification_bindings` observe vendor notifications via bounded FIFOs.

`validate_tool_results=False` skips the client-side output-schema check
`call_tool` otherwise runs after each successful call. On a session that has
never listed tools, that check costs a `tools/list` round-trip per call; turn
it off when the caller already validates elsewhere.
"""

def __init__(
Expand All @@ -419,6 +424,7 @@ def __init__(
result_claims: Mapping[str, Sequence[ResultClaim[Any]]] | None = None,
notification_bindings: Sequence[NotificationBinding[Any]] | None = None,
dispatcher: Dispatcher[Any] | None = None,
validate_tool_results: bool = True,
) -> None:
self._session_read_timeout_seconds = read_timeout_seconds
self._client_info = client_info or DEFAULT_CLIENT_INFO
Expand All @@ -437,6 +443,7 @@ def __init__(
self._logging_callback = logging_callback or _default_logging_callback
self._log_level: types.LoggingLevel | None = log_level
self._message_handler = message_handler or _default_message_handler
self._validate_tool_results = validate_tool_results
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
# Compiled output-schema validators, derived from `_tool_output_schemas` and owned by
# `_absorb_tool_listing`, which evicts a tool's entry whenever its schema changes.
Expand Down Expand Up @@ -1098,7 +1105,7 @@ async def call_tool(
progress_callback=progress_callback,
)

if isinstance(result, types.CallToolResult) and not result.is_error:
if self._validate_tool_results and isinstance(result, types.CallToolResult) and not result.is_error:
await self.validate_tool_result(name, result)

# The input_required arm stays first; a claimed shape is terminal for the multi-round-trip driver.
Expand Down
17 changes: 17 additions & 0 deletions tests/client/test_client_extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -421,6 +421,23 @@ async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
assert str(exc_info.value) == snapshot("Tool issue has an output schema but did not return structured content")


async def test_validate_tool_results_false_skips_revalidation_of_the_resolvers_product() -> None:
"""`validate_tool_results=False` must reach the claimed-result path too, not just the direct one:
the same schema-violating product from `test_resolver_product_gets_the_direct_paths_output_schema_revalidation`
comes back unraised here."""

async def resolve(claimed: VoucherResult, ctx: ClaimContext) -> CallToolResult:
return CallToolResult(content=[TextContent(text="unstructured")])

with anyio.fail_after(5):
async with Client(
_structured_voucher_server(), extensions=[_VoucherExtension(resolve)], validate_tool_results=False
) as client:
result = await client.call_tool("issue", {})

assert result.content == [TextContent(text="unstructured")]


async def test_resolver_error_result_is_returned_not_raised() -> None:
"""An `isError` resolver product skips output-schema revalidation and comes back as-is."""

Expand Down
17 changes: 17 additions & 0 deletions tests/client/test_session_promotions.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,23 @@ async def test_validate_tool_result_keeps_the_validator_across_a_relisting_of_th
assert client.session._tool_output_validators["t"] is compiled


@pytest.mark.anyio
async def test_call_tool_skips_validation_and_the_tools_list_refresh_when_opted_out() -> None:
"""`validate_tool_results=False` must skip both the schema check and the `tools/list`
round-trip `validate_tool_result` would otherwise spend discovering it on a fresh session.

The server has no `on_list_tools` handler at all, so a `tools/list` call would raise
METHOD_NOT_FOUND -- the call succeeding proves the refresh never happened."""

async def on_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[], structured_content={"x": 1})

server = Server("test-server", on_call_tool=on_call_tool)
async with Client(server, validate_tool_results=False) as client:
result = await client.call_tool("t", {})
assert result.structured_content == {"x": 1}


@pytest.mark.anyio
async def test_validate_tool_result_recompiles_when_the_server_changes_the_schema() -> None:
"""A relisted tool must not be validated against the schema it used to declare."""
Expand Down
Loading