Skip to content

Commit 9026b41

Browse files
committed
Validate an explicit null structuredContent against the output schema
The client treated a `structuredContent` of JSON null the same as an absent field and raised "has an output schema but did not return structured content". Since 2026-07-28 null is a legal structured result, so only an absent field is reported as missing now; an explicit null is validated against the tool's output schema like any other value. Fixes #3345
1 parent 449070c commit 9026b41

2 files changed

Lines changed: 66 additions & 1 deletion

File tree

‎src/mcp/client/session.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1136,7 +1136,7 @@ async def validate_tool_result(self, name: str, result: types.CallToolResult) ->
11361136
from jsonschema import exceptions as jsonschema_exceptions
11371137
from referencing.exceptions import Unresolvable
11381138

1139-
if result.structured_content is None:
1139+
if result.structured_content is None and "structured_content" not in result.model_fields_set:
11401140
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
11411141
validator = self._output_schema_validator(name, output_schema)
11421142
# `best_match` picks the same error the previous `jsonschema.validate()` call raised,

‎tests/client/test_session.py‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import anyio.streams.memory
1010
import mcp_types as types
1111
import pytest
12+
from inline_snapshot import snapshot
1213
from mcp_types import (
1314
CONNECTION_CLOSED,
1415
INTERNAL_ERROR,
@@ -1828,6 +1829,70 @@ async def test_a_2026_result_type_tag_from_a_legacy_server_never_reaches_the_res
18281829
assert result.result_type == "complete"
18291830

18301831

1832+
# --- null structuredContent ---
1833+
# The results are scripted: a server built on this SDK leaves a null `structuredContent` off the wire.
1834+
1835+
1836+
def _listing_with_output_schema(output_schema: dict[str, Any]) -> dict[str, Any]:
1837+
tool = {"name": "t", "inputSchema": {"type": "object"}, "outputSchema": output_schema}
1838+
return {"resultType": "complete", "tools": [tool], "ttlMs": 0, "cacheScope": "private"}
1839+
1840+
1841+
@pytest.mark.anyio
1842+
async def test_call_tool_accepts_a_null_structured_content_the_output_schema_permits() -> None:
1843+
"""Spec (2026-07-28): `structuredContent` may be any JSON value, null included, so a null
1844+
the output schema permits is a valid result rather than missing structured content."""
1845+
dispatcher = _ScriptedDispatcher(
1846+
_discover_result_dict(),
1847+
_listing_with_output_schema({"type": ["object", "null"]}),
1848+
{"resultType": "complete", "content": [], "structuredContent": None},
1849+
)
1850+
with anyio.fail_after(5):
1851+
async with ClientSession(dispatcher=dispatcher) as session:
1852+
await session.discover()
1853+
await session.list_tools()
1854+
result = await session.call_tool("t", {})
1855+
assert isinstance(result, CallToolResult)
1856+
assert result.structured_content is None
1857+
1858+
1859+
@pytest.mark.anyio
1860+
async def test_call_tool_rejects_a_null_structured_content_the_output_schema_forbids() -> None:
1861+
"""A null `structuredContent` is validated like any other value: against a schema that does
1862+
not permit null it fails as a schema mismatch, not as missing structured content."""
1863+
dispatcher = _ScriptedDispatcher(
1864+
_discover_result_dict(),
1865+
_listing_with_output_schema({"type": "object"}),
1866+
{"resultType": "complete", "content": [], "structuredContent": None},
1867+
)
1868+
with anyio.fail_after(5):
1869+
async with ClientSession(dispatcher=dispatcher) as session:
1870+
await session.discover()
1871+
await session.list_tools()
1872+
with pytest.raises(RuntimeError) as exc_info:
1873+
await session.call_tool("t", {})
1874+
# Stable SDK prefix only: the message tail is jsonschema text that shifts with the dependency.
1875+
assert str(exc_info.value).startswith("Invalid structured content returned by tool t")
1876+
1877+
1878+
@pytest.mark.anyio
1879+
async def test_call_tool_reports_an_absent_structured_content_as_missing_even_when_null_is_permitted() -> None:
1880+
"""SDK-defined: only a result with no `structuredContent` field at all is reported as
1881+
missing structured content, and it is so even when the output schema would accept null."""
1882+
dispatcher = _ScriptedDispatcher(
1883+
_discover_result_dict(),
1884+
_listing_with_output_schema({"type": ["object", "null"]}),
1885+
{"resultType": "complete", "content": []},
1886+
)
1887+
with anyio.fail_after(5):
1888+
async with ClientSession(dispatcher=dispatcher) as session:
1889+
await session.discover()
1890+
await session.list_tools()
1891+
with pytest.raises(RuntimeError) as exc_info:
1892+
await session.call_tool("t", {})
1893+
assert str(exc_info.value) == snapshot("Tool t has an output schema but did not return structured content")
1894+
1895+
18311896
@pytest.mark.anyio
18321897
async def test_session_call_tool_returns_input_required_result_when_opted_in() -> None:
18331898
"""`ClientSession.call_tool(..., allow_input_required=True)` surfaces the

0 commit comments

Comments
 (0)