From 9db8686e465e78d54186867928b14eecd691d737 Mon Sep 17 00:00:00 2001 From: Mitulagr Date: Mon, 17 Aug 2026 11:10:18 +0900 Subject: [PATCH] feat(client): add validate_output flag to call_tool to skip output-schema revalidation --- src/mcp/client/client.py | 7 ++++- src/mcp/client/session.py | 10 ++++++- tests/client/test_output_schema_validation.py | 27 +++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/client.py b/src/mcp/client/client.py index ed7c40f123..c28ff474fd 100644 --- a/src/mcp/client/client.py +++ b/src/mcp/client/client.py @@ -758,6 +758,7 @@ async def call_tool( input_responses: InputResponses | None = None, request_state: str | None = None, meta: RequestParamsMeta | None = None, + validate_output: bool = True, ) -> CallToolResult: """Call a tool on the server. @@ -784,6 +785,9 @@ async def call_tool( resuming from a persisted `InputRequiredResult`). request_state: Opaque state to seed the first call with. meta: Additional metadata for the request. + validate_output: When `True` (default), the tool's output schema is + validated against the returned structured content. When `False`, + the result is returned without schema validation. Returns: The tool result. @@ -807,6 +811,7 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp allow_input_required=True, # Input rounds resolve before a claimed result, so a claim may end any round. allow_claimed=True, + validate_output=validate_output, ) result = await self._drive_input_required(await retry(input_responses, request_state), retry) @@ -818,7 +823,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 validate_output and not final.is_error: # Match the direct path: revalidate the output schema, but never for isError results. await self.session.validate_tool_result(name, final) return final diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index 895339ca18..dc11d859ac 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -964,6 +964,7 @@ async def call_tool( meta: RequestParamsMeta | None = None, allow_input_required: Literal[False] = False, allow_claimed: Literal[False] = False, + validate_output: bool = True, ) -> types.CallToolResult: ... @overload @@ -979,6 +980,7 @@ async def call_tool( meta: RequestParamsMeta | None = None, allow_input_required: bool, allow_claimed: Literal[False] = False, + validate_output: bool = True, ) -> types.CallToolResult | types.InputRequiredResult: ... @overload @@ -994,6 +996,7 @@ async def call_tool( meta: RequestParamsMeta | None = None, allow_input_required: Literal[False] = False, allow_claimed: bool, + validate_output: bool = True, ) -> types.CallToolResult | types.Result: ... @overload @@ -1009,6 +1012,7 @@ async def call_tool( meta: RequestParamsMeta | None = None, allow_input_required: bool, allow_claimed: bool, + validate_output: bool = True, ) -> types.CallToolResult | types.InputRequiredResult | types.Result: ... async def call_tool( @@ -1023,6 +1027,7 @@ async def call_tool( meta: RequestParamsMeta | None = None, allow_input_required: bool = False, allow_claimed: bool = False, + validate_output: bool = True, ) -> types.CallToolResult | types.InputRequiredResult | types.Result: """Send a tools/call request with optional progress callback support. @@ -1039,6 +1044,9 @@ async def call_tool( so the caller can resolve the requests and retry. allow_claimed: When `False` (default), a claimed extension result raises `UnexpectedClaimedResult`; when `True`, the parsed claim model is returned. + validate_output: When `True` (default), the client's cached tool output schema + is validated against the returned structured content. When `False`, the + result is returned without schema validation. Raises: RuntimeError: If the server returns an `InputRequiredResult` and @@ -1060,7 +1068,7 @@ async def call_tool( progress_callback=progress_callback, ) - if isinstance(result, types.CallToolResult) and not result.is_error: + if validate_output 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. diff --git a/tests/client/test_output_schema_validation.py b/tests/client/test_output_schema_validation.py index 60f6fadc0f..4623a48db6 100644 --- a/tests/client/test_output_schema_validation.py +++ b/tests/client/test_output_schema_validation.py @@ -163,3 +163,30 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) assert result.is_error is False assert "Tool mystery_tool not listed" in caplog.text + + +@pytest.mark.anyio +async def test_call_tool_validate_output_false_skips_validation(): + """Test that validate_output=False bypasses client-side output-schema revalidation.""" + output_schema = { + "type": "object", + "properties": {"result": {"type": "integer", "title": "Result"}}, + "required": ["result"], + "title": "calculate_Output", + } + + server = _make_server( + tools=[ + Tool( + name="calculate", + description="Calculate something", + input_schema={"type": "object"}, + output_schema=output_schema, + ) + ], + structured_content={"result": "not_a_number"}, # Invalid: should be int + ) + + async with Client(server) as client: + result = await client.call_tool("calculate", {}, validate_output=False) + assert result.structured_content == {"result": "not_a_number"}