Skip to content
Draft
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
4 changes: 3 additions & 1 deletion src/mcp/client/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1141,7 +1141,9 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
if output_schema is not None:
from jsonschema import exceptions as jsonschema_exceptions

if result.structured_content is None:
# SEP-2106 allows JSON null. Pydantic maps both omitted and explicit
# null to None; model_fields_set is the presence check (not falsy).
if result.structured_content is None and "structured_content" not in result.model_fields_set:
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
validator = self._output_schema_validator(name, output_schema)
# `best_match` picks the same error the previous `jsonschema.validate()` call raised,
Expand Down
43 changes: 43 additions & 0 deletions tests/client/test_session_promotions.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,49 @@ async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))


@pytest.mark.anyio
async def test_validate_tool_result_accepts_explicit_json_null_when_the_schema_allows_it() -> None:
"""SEP-2106: JSON null is a legal structuredContent value. A parsed result that
includes `"structuredContent": null` must be validated against the schema, not
rejected as a missing field. Pydantic stores both omitted and JSON null as None;
`model_fields_set` is how the client tells them apart.
"""
server = _make_server({"type": "null"})
parsed = CallToolResult.model_validate({"content": [], "structuredContent": None})
async with Client(server) as client:
await client.session.validate_tool_result("t", parsed)


@pytest.mark.anyio
async def test_validate_tool_result_rejects_explicit_json_null_when_the_schema_does_not_allow_it() -> None:
"""An explicit JSON null that fails the advertised object schema is a schema mismatch,
not a missing structuredContent field.
"""
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
parsed = CallToolResult.model_validate({"content": [], "structuredContent": None})
async with Client(server) as client:
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", parsed)


@pytest.mark.anyio
async def test_validate_tool_result_still_rejects_omitted_structured_content() -> None:
server = _make_server({"type": "null"})
async with Client(server) as client:
with pytest.raises(RuntimeError, match="Tool t has an output schema but did not return structured content"):
await client.session.validate_tool_result("t", CallToolResult(content=[]))


@pytest.mark.anyio
async def test_validate_tool_result_validates_falsy_non_null_structured_content() -> None:
"""0 / false / "" are present JSON values and must be schema-checked, not treated as missing."""
server = _make_server({"type": "number"})
async with Client(server) as client:
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content=0))
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content=False))


@pytest.mark.anyio
async def test_validate_tool_result_raises_on_an_unusable_output_schema() -> None:
"""A schema that isn't valid JSON Schema is reported as such, on every call."""
Expand Down
Loading