Skip to content
Open
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
7 changes: 6 additions & 1 deletion src/mcp/client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.
Expand All @@ -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)
Expand All @@ -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
Expand Down
10 changes: 9 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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(
Expand All @@ -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.

Expand All @@ -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
Expand All @@ -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.
Expand Down
27 changes: 27 additions & 0 deletions tests/client/test_output_schema_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Loading