Skip to content

Commit 6d7383c

Browse files
committed
fix(client): validate JSON-null structuredContent against outputSchema
SEP-2106 allows structuredContent to be JSON null. The client presence check used `is None`, which also matches an omitted field, so a tool that advertised a null-capable outputSchema and returned null was rejected as missing structured content. Use model_fields_set so omitted still fails closed, explicit null is schema-validated, and falsy JSON values (0, false, "") stay checked. Fixes #3345
1 parent 57394b0 commit 6d7383c

2 files changed

Lines changed: 46 additions & 1 deletion

File tree

src/mcp/client/session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1141,7 +1141,9 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
11411141
if output_schema is not None:
11421142
from jsonschema import exceptions as jsonschema_exceptions
11431143

1144-
if result.structured_content is None:
1144+
# SEP-2106 allows JSON null. Pydantic maps both omitted and explicit
1145+
# null to None; model_fields_set is the presence check (not falsy).
1146+
if result.structured_content is None and "structured_content" not in result.model_fields_set:
11451147
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
11461148
validator = self._output_schema_validator(name, output_schema)
11471149
# `best_match` picks the same error the previous `jsonschema.validate()` call raised,

tests/client/test_session_promotions.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,49 @@ async def test_validate_tool_result_raises_on_schema_mismatch() -> None:
6666
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content={"x": "no"}))
6767

6868

69+
@pytest.mark.anyio
70+
async def test_validate_tool_result_accepts_explicit_json_null_when_the_schema_allows_it() -> None:
71+
"""SEP-2106: JSON null is a legal structuredContent value. A parsed result that
72+
includes `"structuredContent": null` must be validated against the schema, not
73+
rejected as a missing field. Pydantic stores both omitted and JSON null as None;
74+
`model_fields_set` is how the client tells them apart.
75+
"""
76+
server = _make_server({"type": "null"})
77+
parsed = CallToolResult.model_validate({"content": [], "structuredContent": None})
78+
async with Client(server) as client:
79+
await client.session.validate_tool_result("t", parsed)
80+
81+
82+
@pytest.mark.anyio
83+
async def test_validate_tool_result_rejects_explicit_json_null_when_the_schema_does_not_allow_it() -> None:
84+
"""An explicit JSON null that fails the advertised object schema is a schema mismatch,
85+
not a missing structuredContent field.
86+
"""
87+
server = _make_server({"type": "object", "properties": {"x": {"type": "integer"}}, "required": ["x"]})
88+
parsed = CallToolResult.model_validate({"content": [], "structuredContent": None})
89+
async with Client(server) as client:
90+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
91+
await client.session.validate_tool_result("t", parsed)
92+
93+
94+
@pytest.mark.anyio
95+
async def test_validate_tool_result_still_rejects_omitted_structured_content() -> None:
96+
server = _make_server({"type": "null"})
97+
async with Client(server) as client:
98+
with pytest.raises(RuntimeError, match="Tool t has an output schema but did not return structured content"):
99+
await client.session.validate_tool_result("t", CallToolResult(content=[]))
100+
101+
102+
@pytest.mark.anyio
103+
async def test_validate_tool_result_validates_falsy_non_null_structured_content() -> None:
104+
"""0 / false / "" are present JSON values and must be schema-checked, not treated as missing."""
105+
server = _make_server({"type": "number"})
106+
async with Client(server) as client:
107+
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content=0))
108+
with pytest.raises(RuntimeError, match="Invalid structured content returned by tool t"):
109+
await client.session.validate_tool_result("t", CallToolResult(content=[], structured_content=False))
110+
111+
69112
@pytest.mark.anyio
70113
async def test_validate_tool_result_raises_on_an_unusable_output_schema() -> None:
71114
"""A schema that isn't valid JSON Schema is reported as such, on every call."""

0 commit comments

Comments
 (0)