Skip to content

Commit c7c37d8

Browse files
committed
refactor: replace ValidationOptions with validate_structured_outputs boolean parameter
1 parent 9a8592e commit c7c37d8

File tree

3 files changed

+268
-7
lines changed

3 files changed

+268
-7
lines changed

src/mcp/client/session.py

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
logger = logging.getLogger("client")
1919

2020

21+
2122
class SamplingFnT(Protocol):
2223
async def __call__(
2324
self,
@@ -118,6 +119,7 @@ def __init__(
118119
logging_callback: LoggingFnT | None = None,
119120
message_handler: MessageHandlerFnT | None = None,
120121
client_info: types.Implementation | None = None,
122+
validate_structured_outputs: bool = True,
121123
) -> None:
122124
super().__init__(
123125
read_stream,
@@ -133,6 +135,7 @@ def __init__(
133135
self._logging_callback = logging_callback or _default_logging_callback
134136
self._message_handler = message_handler or _default_message_handler
135137
self._tool_output_schemas: dict[str, dict[str, Any] | None] = {}
138+
self._validate_structured_outputs = validate_structured_outputs
136139

137140
async def initialize(self) -> types.InitializeResult:
138141
sampling = types.SamplingCapability() if self._sampling_callback is not _default_sampling_callback else None
@@ -324,13 +327,27 @@ async def _validate_tool_result(self, name: str, result: types.CallToolResult) -
324327

325328
if output_schema is not None:
326329
if result.structuredContent is None:
327-
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
328-
try:
329-
validate(result.structuredContent, output_schema)
330-
except ValidationError as e:
331-
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}")
332-
except SchemaError as e:
333-
raise RuntimeError(f"Invalid schema for tool {name}: {e}")
330+
if self._validate_structured_outputs:
331+
raise RuntimeError(f"Tool {name} has an output schema but did not return structured content")
332+
else:
333+
logger.warning(
334+
f"Tool {name} has an output schema but did not return structured content. "
335+
f"Continuing without structured content validation."
336+
)
337+
else:
338+
try:
339+
validate(result.structuredContent, output_schema)
340+
except ValidationError as e:
341+
if self._validate_structured_outputs:
342+
raise RuntimeError(f"Invalid structured content returned by tool {name}: {e}") from e
343+
else:
344+
logger.warning(
345+
f"Invalid structured content returned by tool {name}: {e}. "
346+
f"Continuing without validation."
347+
)
348+
except SchemaError as e:
349+
# Schema errors are always raised - they indicate a problem with the schema itself
350+
raise RuntimeError(f"Invalid schema for tool {name}: {e}") from e
334351

335352
async def list_prompts(self, cursor: str | None = None) -> types.ListPromptsResult:
336353
"""Send a prompts/list request."""

src/mcp/shared/memory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ async def create_connected_server_and_client_session(
6161
client_info: types.Implementation | None = None,
6262
raise_exceptions: bool = False,
6363
elicitation_callback: ElicitationFnT | None = None,
64+
validate_structured_outputs: bool = True,
6465
) -> AsyncGenerator[ClientSession, None]:
6566
"""Creates a ClientSession that is connected to a running MCP server."""
6667
async with create_client_server_memory_streams() as (
@@ -92,6 +93,7 @@ async def create_connected_server_and_client_session(
9293
message_handler=message_handler,
9394
client_info=client_info,
9495
elicitation_callback=elicitation_callback,
96+
validate_structured_outputs=validate_structured_outputs,
9597
) as client_session:
9698
await client_session.initialize()
9799
yield client_session
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
"""Tests for client-side validation options."""
2+
3+
import logging
4+
from unittest.mock import AsyncMock, MagicMock
5+
6+
import pytest
7+
8+
from mcp.client.session import ClientSession
9+
from mcp.types import CallToolResult, TextContent
10+
11+
12+
class TestValidationOptions:
13+
"""Test validation options for MCP client sessions."""
14+
15+
@pytest.mark.anyio
16+
async def test_strict_validation_default(self):
17+
"""Test that strict validation is enabled by default."""
18+
# Create a mock client session
19+
read_stream = MagicMock()
20+
write_stream = MagicMock()
21+
22+
client = ClientSession(read_stream, write_stream)
23+
24+
# Set up tool with output schema
25+
client._tool_output_schemas = {
26+
"test_tool": {
27+
"type": "object",
28+
"properties": {"result": {"type": "integer"}},
29+
"required": ["result"],
30+
}
31+
}
32+
33+
# Mock send_request to return a result without structured content
34+
mock_result = CallToolResult(
35+
content=[TextContent(type="text", text="This is unstructured text content")],
36+
structuredContent=None,
37+
isError=False,
38+
)
39+
40+
client.send_request = AsyncMock(return_value=mock_result)
41+
42+
# Should raise by default when structured content is missing
43+
with pytest.raises(RuntimeError) as exc_info:
44+
await client.call_tool("test_tool", {})
45+
assert "has an output schema but did not return structured content" in str(exc_info.value)
46+
47+
@pytest.mark.anyio
48+
async def test_lenient_validation_missing_content(self, caplog):
49+
"""Test lenient validation when structured content is missing."""
50+
# Set logging level to capture warnings
51+
caplog.set_level(logging.WARNING)
52+
53+
# Create client with lenient validation
54+
read_stream = MagicMock()
55+
write_stream = MagicMock()
56+
57+
client = ClientSession(read_stream, write_stream, validate_structured_outputs=False)
58+
59+
# Set up tool with output schema
60+
client._tool_output_schemas = {
61+
"test_tool": {
62+
"type": "object",
63+
"properties": {"result": {"type": "integer"}},
64+
"required": ["result"],
65+
}
66+
}
67+
68+
# Mock send_request to return a result without structured content
69+
mock_result = CallToolResult(
70+
content=[TextContent(type="text", text="This is unstructured text content")],
71+
structuredContent=None,
72+
isError=False,
73+
)
74+
75+
client.send_request = AsyncMock(return_value=mock_result)
76+
77+
# Should not raise with lenient validation
78+
result = await client.call_tool("test_tool", {})
79+
80+
# Should have logged a warning
81+
assert "has an output schema but did not return structured content" in caplog.text
82+
assert "Continuing without structured content validation" in caplog.text
83+
84+
# Result should still be returned
85+
assert result.isError is False
86+
assert result.structuredContent is None
87+
88+
@pytest.mark.anyio
89+
async def test_lenient_validation_invalid_content(self, caplog):
90+
"""Test lenient validation when structured content is invalid."""
91+
# Set logging level to capture warnings
92+
caplog.set_level(logging.WARNING)
93+
94+
# Create client with lenient validation
95+
96+
97+
read_stream = MagicMock()
98+
write_stream = MagicMock()
99+
100+
client = ClientSession(read_stream, write_stream, validate_structured_outputs=False)
101+
102+
# Set up tool with output schema
103+
client._tool_output_schemas = {
104+
"test_tool": {
105+
"type": "object",
106+
"properties": {"result": {"type": "integer"}},
107+
"required": ["result"],
108+
}
109+
}
110+
111+
# Mock send_request to return a result with invalid structured content
112+
mock_result = CallToolResult(
113+
content=[TextContent(type="text", text='{"result": "not_an_integer"}')],
114+
structuredContent={"result": "not_an_integer"}, # Invalid: string instead of integer
115+
isError=False,
116+
)
117+
118+
client.send_request = AsyncMock(return_value=mock_result)
119+
120+
# Should not raise with lenient validation
121+
result = await client.call_tool("test_tool", {})
122+
123+
# Should have logged a warning
124+
assert "Invalid structured content returned by tool test_tool" in caplog.text
125+
assert "Continuing without validation" in caplog.text
126+
127+
# Result should still be returned with the invalid content
128+
assert result.isError is False
129+
assert result.structuredContent == {"result": "not_an_integer"}
130+
131+
@pytest.mark.anyio
132+
async def test_strict_validation_with_valid_content(self):
133+
"""Test that valid structured content passes validation."""
134+
read_stream = MagicMock()
135+
write_stream = MagicMock()
136+
137+
client = ClientSession(read_stream, write_stream)
138+
139+
# Set up tool with output schema
140+
client._tool_output_schemas = {
141+
"test_tool": {
142+
"type": "object",
143+
"properties": {"result": {"type": "integer"}},
144+
"required": ["result"],
145+
}
146+
}
147+
148+
# Mock send_request to return a result with valid structured content
149+
mock_result = CallToolResult(
150+
content=[TextContent(type="text", text='{"result": 42}')], structuredContent={"result": 42}, isError=False
151+
)
152+
153+
client.send_request = AsyncMock(return_value=mock_result)
154+
155+
# Should not raise with valid content
156+
result = await client.call_tool("test_tool", {})
157+
assert result.isError is False
158+
assert result.structuredContent == {"result": 42}
159+
160+
@pytest.mark.anyio
161+
async def test_schema_errors_always_raised(self):
162+
"""Test that schema errors are always raised regardless of validation mode."""
163+
# Create client with lenient validation
164+
165+
166+
read_stream = MagicMock()
167+
write_stream = MagicMock()
168+
169+
client = ClientSession(read_stream, write_stream, validate_structured_outputs=False)
170+
171+
# Set up tool with invalid output schema
172+
client._tool_output_schemas = {
173+
"test_tool": "not a valid schema" # type: ignore # Invalid schema for testing
174+
}
175+
176+
# Mock send_request to return a result with structured content
177+
mock_result = CallToolResult(
178+
content=[TextContent(type="text", text='{"result": 42}')], structuredContent={"result": 42}, isError=False
179+
)
180+
181+
client.send_request = AsyncMock(return_value=mock_result)
182+
183+
# Should still raise for schema errors even in lenient mode
184+
with pytest.raises(RuntimeError) as exc_info:
185+
await client.call_tool("test_tool", {})
186+
assert "Invalid schema for tool test_tool" in str(exc_info.value)
187+
188+
@pytest.mark.anyio
189+
async def test_error_results_not_validated(self):
190+
"""Test that error results are not validated."""
191+
read_stream = MagicMock()
192+
write_stream = MagicMock()
193+
194+
client = ClientSession(read_stream, write_stream)
195+
196+
# Set up tool with output schema
197+
client._tool_output_schemas = {
198+
"test_tool": {
199+
"type": "object",
200+
"properties": {"result": {"type": "integer"}},
201+
"required": ["result"],
202+
}
203+
}
204+
205+
# Mock send_request to return an error result
206+
mock_result = CallToolResult(
207+
content=[TextContent(type="text", text="Tool execution failed")],
208+
structuredContent=None,
209+
isError=True, # Error result
210+
)
211+
212+
client.send_request = AsyncMock(return_value=mock_result)
213+
214+
# Should not validate error results
215+
result = await client.call_tool("test_tool", {})
216+
assert result.isError is True
217+
# No exception should be raised
218+
219+
@pytest.mark.anyio
220+
async def test_tool_without_output_schema(self):
221+
"""Test that tools without output schema don't trigger validation."""
222+
read_stream = MagicMock()
223+
write_stream = MagicMock()
224+
225+
client = ClientSession(read_stream, write_stream)
226+
227+
# Tool has no output schema
228+
client._tool_output_schemas = {"test_tool": None}
229+
230+
# Mock send_request to return a result without structured content
231+
mock_result = CallToolResult(
232+
content=[TextContent(type="text", text="This is unstructured text content")],
233+
structuredContent=None,
234+
isError=False,
235+
)
236+
237+
client.send_request = AsyncMock(return_value=mock_result)
238+
239+
# Should not raise when there's no output schema
240+
result = await client.call_tool("test_tool", {})
241+
assert result.isError is False
242+
assert result.structuredContent is None

0 commit comments

Comments
 (0)