Skip to content

Commit 83d6adc

Browse files
feat(client): add validate_structured_output option to ClientSession
Closes #2626. Real-world MCP servers occasionally return structured_content that does not match their advertised outputSchema. The client currently raises a RuntimeError and drops the result, leaving the caller no escape hatch. This adds a validate_structured_output keyword on ClientSession and Client (default True, so existing behavior is preserved) that lets callers opt out of strict validation when interoperating with non-conformant servers. When validation is disabled, the client logs a debug message and returns the result unchanged. Tests cover both the default-on and opt-out modes.
1 parent e8e6484 commit 83d6adc

3 files changed

Lines changed: 77 additions & 0 deletions

File tree

src/mcp/client/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,15 @@ async def main():
9595
elicitation_callback: ElicitationFnT | None = None
9696
"""Callback for handling elicitation requests."""
9797

98+
validate_structured_output: bool = True
99+
"""Whether to validate structured tool output against the server's advertised output schema.
100+
101+
When True (the default), tool results whose structured_content does not match the tool's
102+
output_schema cause a RuntimeError. Set to False to skip validation and return the
103+
result unchanged, which is useful when interoperating with servers that ship buggy or
104+
incomplete output schemas.
105+
"""
106+
98107
_session: ClientSession | None = field(init=False, default=None)
99108
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
100109
_transport: Transport = field(init=False)
@@ -126,6 +135,7 @@ async def __aenter__(self) -> Client:
126135
message_handler=self.message_handler,
127136
client_info=self.client_info,
128137
elicitation_callback=self.elicitation_callback,
138+
validate_structured_output=self.validate_structured_output,
129139
)
130140
)
131141

src/mcp/client/session.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ def __init__(
121121
*,
122122
sampling_capabilities: types.SamplingCapability | None = None,
123123
experimental_task_handlers: ExperimentalTaskHandlers | None = None,
124+
validate_structured_output: bool = True,
124125
) -> None:
125126
super().__init__(read_stream, write_stream, read_timeout_seconds=read_timeout_seconds)
126127
self._client_info = client_info or DEFAULT_CLIENT_INFO
@@ -133,6 +134,7 @@ def __init__(
133134
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
134135
self._initialize_result: types.InitializeResult | None = None
135136
self._experimental_features: ExperimentalClientFeatures | None = None
137+
self._validate_structured_output = validate_structured_output
136138

137139
# Experimental: Task handlers (use defaults if not provided)
138140
self._task_handlers = experimental_task_handlers or ExperimentalTaskHandlers()
@@ -323,6 +325,10 @@ async def call_tool(
323325

324326
async def _validate_tool_result(self, name: str, result: types.CallToolResult) -> None:
325327
"""Validate the structured content of a tool result against its output schema."""
328+
if not self._validate_structured_output:
329+
logger.debug(f"Skipping structured output validation for tool {name}")
330+
return
331+
326332
if name not in self._tool_output_schemas:
327333
# refresh output schema cache
328334
await self.list_tools()

tests/client/test_output_schema_validation.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,3 +163,64 @@ async def on_call_tool(ctx: ServerRequestContext, params: CallToolRequestParams)
163163
assert result.is_error is False
164164

165165
assert "Tool mystery_tool not listed" in caplog.text
166+
167+
168+
@pytest.mark.anyio
169+
async def test_validate_structured_output_disabled_returns_invalid_result(caplog: pytest.LogCaptureFixture):
170+
"""When validate_structured_output is False, invalid structured_content is returned as-is."""
171+
output_schema = {
172+
"type": "object",
173+
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
174+
"required": ["name", "age"],
175+
"title": "UserOutput",
176+
}
177+
178+
invalid_content = {"name": "John", "age": "not_an_int"}
179+
server = _make_server(
180+
tools=[
181+
Tool(
182+
name="get_user",
183+
description="Get user data",
184+
input_schema={"type": "object"},
185+
output_schema=output_schema,
186+
)
187+
],
188+
structured_content=invalid_content,
189+
)
190+
191+
caplog.set_level(logging.DEBUG, logger="client")
192+
193+
async with Client(server, validate_structured_output=False) as client:
194+
result = await client.call_tool("get_user", {})
195+
assert result.structured_content == invalid_content
196+
assert result.is_error is False
197+
198+
assert "Skipping structured output validation for tool get_user" in caplog.text
199+
200+
201+
@pytest.mark.anyio
202+
async def test_validate_structured_output_default_still_raises():
203+
"""The default for validate_structured_output is True; invalid structured_content still raises."""
204+
output_schema = {
205+
"type": "object",
206+
"properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
207+
"required": ["name", "age"],
208+
"title": "UserOutput",
209+
}
210+
211+
server = _make_server(
212+
tools=[
213+
Tool(
214+
name="get_user",
215+
description="Get user data",
216+
input_schema={"type": "object"},
217+
output_schema=output_schema,
218+
)
219+
],
220+
structured_content={"name": "John", "age": "not_an_int"},
221+
)
222+
223+
async with Client(server) as client:
224+
with pytest.raises(RuntimeError) as exc_info:
225+
await client.call_tool("get_user", {})
226+
assert "Invalid structured content returned by tool get_user" in str(exc_info.value)

0 commit comments

Comments
 (0)