Skip to content

Commit 9db8686

Browse files
committed
feat(client): add validate_output flag to call_tool to skip output-schema revalidation
1 parent a4f4ccd commit 9db8686

3 files changed

Lines changed: 42 additions & 2 deletions

File tree

src/mcp/client/client.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,7 @@ async def call_tool(
758758
input_responses: InputResponses | None = None,
759759
request_state: str | None = None,
760760
meta: RequestParamsMeta | None = None,
761+
validate_output: bool = True,
761762
) -> CallToolResult:
762763
"""Call a tool on the server.
763764
@@ -784,6 +785,9 @@ async def call_tool(
784785
resuming from a persisted `InputRequiredResult`).
785786
request_state: Opaque state to seed the first call with.
786787
meta: Additional metadata for the request.
788+
validate_output: When `True` (default), the tool's output schema is
789+
validated against the returned structured content. When `False`,
790+
the result is returned without schema validation.
787791
788792
Returns:
789793
The tool result.
@@ -807,6 +811,7 @@ async def retry(r: InputResponses | None, s: str | None) -> CallToolResult | Inp
807811
allow_input_required=True,
808812
# Input rounds resolve before a claimed result, so a claim may end any round.
809813
allow_claimed=True,
814+
validate_output=validate_output,
810815
)
811816

812817
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
818823
result,
819824
ClaimContext(session=self.session, tool_name=name, read_timeout_seconds=read_timeout_seconds),
820825
)
821-
if not final.is_error:
826+
if validate_output and not final.is_error:
822827
# Match the direct path: revalidate the output schema, but never for isError results.
823828
await self.session.validate_tool_result(name, final)
824829
return final

src/mcp/client/session.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -964,6 +964,7 @@ async def call_tool(
964964
meta: RequestParamsMeta | None = None,
965965
allow_input_required: Literal[False] = False,
966966
allow_claimed: Literal[False] = False,
967+
validate_output: bool = True,
967968
) -> types.CallToolResult: ...
968969

969970
@overload
@@ -979,6 +980,7 @@ async def call_tool(
979980
meta: RequestParamsMeta | None = None,
980981
allow_input_required: bool,
981982
allow_claimed: Literal[False] = False,
983+
validate_output: bool = True,
982984
) -> types.CallToolResult | types.InputRequiredResult: ...
983985

984986
@overload
@@ -994,6 +996,7 @@ async def call_tool(
994996
meta: RequestParamsMeta | None = None,
995997
allow_input_required: Literal[False] = False,
996998
allow_claimed: bool,
999+
validate_output: bool = True,
9971000
) -> types.CallToolResult | types.Result: ...
9981001

9991002
@overload
@@ -1009,6 +1012,7 @@ async def call_tool(
10091012
meta: RequestParamsMeta | None = None,
10101013
allow_input_required: bool,
10111014
allow_claimed: bool,
1015+
validate_output: bool = True,
10121016
) -> types.CallToolResult | types.InputRequiredResult | types.Result: ...
10131017

10141018
async def call_tool(
@@ -1023,6 +1027,7 @@ async def call_tool(
10231027
meta: RequestParamsMeta | None = None,
10241028
allow_input_required: bool = False,
10251029
allow_claimed: bool = False,
1030+
validate_output: bool = True,
10261031
) -> types.CallToolResult | types.InputRequiredResult | types.Result:
10271032
"""Send a tools/call request with optional progress callback support.
10281033
@@ -1039,6 +1044,9 @@ async def call_tool(
10391044
so the caller can resolve the requests and retry.
10401045
allow_claimed: When `False` (default), a claimed extension result raises
10411046
`UnexpectedClaimedResult`; when `True`, the parsed claim model is returned.
1047+
validate_output: When `True` (default), the client's cached tool output schema
1048+
is validated against the returned structured content. When `False`, the
1049+
result is returned without schema validation.
10421050
10431051
Raises:
10441052
RuntimeError: If the server returns an `InputRequiredResult` and
@@ -1060,7 +1068,7 @@ async def call_tool(
10601068
progress_callback=progress_callback,
10611069
)
10621070

1063-
if isinstance(result, types.CallToolResult) and not result.is_error:
1071+
if validate_output and isinstance(result, types.CallToolResult) and not result.is_error:
10641072
await self.validate_tool_result(name, result)
10651073

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

tests/client/test_output_schema_validation.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,30 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams)
163163
assert result.is_error is False
164164

165165
assert "Tool mystery_tool not listed" in caplog.text
166+
167+
168+
@pytest.mark.anyio
169+
async def test_call_tool_validate_output_false_skips_validation():
170+
"""Test that validate_output=False bypasses client-side output-schema revalidation."""
171+
output_schema = {
172+
"type": "object",
173+
"properties": {"result": {"type": "integer", "title": "Result"}},
174+
"required": ["result"],
175+
"title": "calculate_Output",
176+
}
177+
178+
server = _make_server(
179+
tools=[
180+
Tool(
181+
name="calculate",
182+
description="Calculate something",
183+
input_schema={"type": "object"},
184+
output_schema=output_schema,
185+
)
186+
],
187+
structured_content={"result": "not_a_number"}, # Invalid: should be int
188+
)
189+
190+
async with Client(server) as client:
191+
result = await client.call_tool("calculate", {}, validate_output=False)
192+
assert result.structured_content == {"result": "not_a_number"}

0 commit comments

Comments
 (0)