From 6d7383cbd04ca534f24352e45208a94d7ce7f7d2 Mon Sep 17 00:00:00 2001 From: epistemedeus <124947147+epistemedeus@users.noreply.github.com> Date: Thu, 20 Aug 2026 17:22:06 +0000 Subject: [PATCH] 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 --- src/mcp/client/session.py | 4 ++- tests/client/test_session_promotions.py | 43 +++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/session.py b/src/mcp/client/session.py index f18cc0ef10..7e4cc3891f 100644 --- a/src/mcp/client/session.py +++ b/src/mcp/client/session.py @@ -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, diff --git a/tests/client/test_session_promotions.py b/tests/client/test_session_promotions.py index 6d6b6bc8dc..e70b2766f9 100644 --- a/tests/client/test_session_promotions.py +++ b/tests/client/test_session_promotions.py @@ -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."""