Skip to content

Commit 7857eb7

Browse files
committed
feat: add validate_output parameter to call_tool decorator
- Add validate_output parameter (default: True) to control output schema validation - Optimize tool definition fetching to only retrieve when validation is needed - Update documentation to reflect new parameter behavior - Add comprehensive tests for validate_input and validate_output parameters - Maintain backward compatibility with default behavior
1 parent bac2789 commit 7857eb7

File tree

3 files changed

+304
-6
lines changed

3 files changed

+304
-6
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -489,19 +489,20 @@ async def _get_cached_tool_definition(self, tool_name: str) -> types.Tool | None
489489

490490
return tool
491491

492-
def call_tool(self, *, validate_input: bool = True):
492+
def call_tool(self, *, validate_input: bool = True, validate_output: bool = True):
493493
"""Register a tool call handler.
494494
495495
Args:
496496
validate_input: If True, validates input against inputSchema. Default is True.
497+
validate_output: If True, validates output against outputSchema. Default is True.
497498
498499
The handler validates input against inputSchema (if validate_input=True), calls the tool function,
499500
and builds a CallToolResult with the results:
500501
- Unstructured content (iterable of ContentBlock): returned in content
501502
- Structured content (dict): returned in structuredContent, serialized JSON text returned in content
502503
- Both: returned in content and structuredContent
503504
504-
If outputSchema is defined, validates structuredContent or errors if missing.
505+
If validate_output is True and outputSchema is defined, validates structuredContent or errors if missing.
505506
"""
506507

507508
def decorator(
@@ -522,8 +523,12 @@ async def handler(req: types.CallToolRequest):
522523
try:
523524
tool_name = req.params.name
524525
arguments = req.params.arguments or {}
525-
tool = await self._get_cached_tool_definition(tool_name)
526526

527+
if validate_input or validate_output:
528+
tool = await self._get_cached_tool_definition(tool_name)
529+
else:
530+
tool = None
531+
527532
# input validation
528533
if validate_input and tool:
529534
try:
@@ -557,7 +562,7 @@ async def handler(req: types.CallToolRequest):
557562
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")
558563

559564
# output validation
560-
if tool and tool.outputSchema is not None:
565+
if validate_output and tool and tool.outputSchema is not None:
561566
if maybe_structured_content is None:
562567
return self._make_error_result(
563568
"Output validation error: outputSchema defined but no structured output returned"

tests/server/test_lowlevel_input_validation.py

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,17 @@ async def run_tool_test(
2121
tools: list[Tool],
2222
call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[list[TextContent]]],
2323
test_callback: Callable[[ClientSession], Awaitable[CallToolResult]],
24+
validate_input: bool = True,
25+
validate_output: bool = True,
2426
) -> CallToolResult | None:
2527
"""Helper to run a tool test with minimal boilerplate.
2628
2729
Args:
2830
tools: List of tools to register
2931
call_tool_handler: Handler function for tool calls
3032
test_callback: Async function that performs the test using the client session
33+
validate_input: Whether to enable input validation (default: True)
34+
validate_output: Whether to enable output validation (default: True)
3135
3236
Returns:
3337
The result of the tool call
@@ -39,7 +43,7 @@ async def run_tool_test(
3943
async def list_tools():
4044
return tools
4145

42-
@server.call_tool()
46+
@server.call_tool(validate_input=validate_input, validate_output=validate_output)
4347
async def call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
4448
return await call_tool_handler(name, arguments)
4549

@@ -309,3 +313,144 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
309313
assert any(
310314
"Tool 'unknown_tool' not listed, no validation will be performed" in record.message for record in caplog.records
311315
)
316+
317+
318+
@pytest.mark.anyio
319+
async def test_validate_input_false_with_invalid_input():
320+
"""Test that when validate_input=False, invalid input is not validated."""
321+
tools = [
322+
Tool(
323+
name="add",
324+
description="Add two numbers",
325+
inputSchema={
326+
"type": "object",
327+
"properties": {
328+
"a": {"type": "number"},
329+
"b": {"type": "number"},
330+
},
331+
"required": ["a", "b"],
332+
},
333+
)
334+
]
335+
336+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
337+
if name == "add":
338+
# Even with invalid input (string instead of number), this should execute
339+
# because validation is disabled
340+
a = arguments.get("a", 0)
341+
b = arguments.get("b", 0)
342+
return [TextContent(type="text", text=f"Result: {a} + {b}")]
343+
else: # pragma: no cover
344+
raise ValueError(f"Unknown tool: {name}")
345+
346+
async def test_callback(client_session: ClientSession) -> CallToolResult:
347+
# Call with invalid input (string instead of number)
348+
# With validate_input=False, this should succeed
349+
return await client_session.call_tool("add", {"a": "five", "b": "three"})
350+
351+
result = await run_tool_test(tools, call_tool_handler, test_callback, validate_input=False)
352+
353+
# Verify results - should succeed because validation is disabled
354+
assert result is not None
355+
assert not result.isError
356+
assert len(result.content) == 1
357+
assert result.content[0].type == "text"
358+
assert isinstance(result.content[0], TextContent)
359+
assert result.content[0].text == "Result: five + three"
360+
361+
362+
@pytest.mark.anyio
363+
async def test_validate_input_true_with_invalid_input():
364+
"""Test that when validate_input=True (default), invalid input is validated."""
365+
tools = [
366+
Tool(
367+
name="add",
368+
description="Add two numbers",
369+
inputSchema={
370+
"type": "object",
371+
"properties": {
372+
"a": {"type": "number"},
373+
"b": {"type": "number"},
374+
},
375+
"required": ["a", "b"],
376+
},
377+
)
378+
]
379+
380+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
381+
# This should not be reached because validation will fail
382+
return [TextContent(type="text", text="This should not be reached")]
383+
384+
async def test_callback(client_session: ClientSession) -> CallToolResult:
385+
# Call with invalid input (string instead of number)
386+
# With validate_input=True (default), this should fail validation
387+
return await client_session.call_tool("add", {"a": "five", "b": "three"})
388+
389+
result = await run_tool_test(tools, call_tool_handler, test_callback)
390+
391+
# Verify error - input validation is enabled by default
392+
assert result is not None
393+
assert result.isError
394+
assert len(result.content) == 1
395+
assert result.content[0].type == "text"
396+
assert "Input validation error" in result.content[0].text
397+
398+
399+
@pytest.mark.anyio
400+
async def test_validate_both_false():
401+
"""Test that when both validate_input and validate_output are False, no validation occurs."""
402+
tools = [
403+
Tool(
404+
name="process",
405+
description="Process data",
406+
inputSchema={
407+
"type": "object",
408+
"properties": {
409+
"value": {"type": "number"},
410+
},
411+
"required": ["value"],
412+
},
413+
outputSchema={
414+
"type": "object",
415+
"properties": {
416+
"result": {"type": "number"},
417+
},
418+
"required": ["result"],
419+
},
420+
)
421+
]
422+
423+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
424+
if name == "process":
425+
# Invalid input (string) and invalid output (string), but no validation
426+
value = arguments.get("value", 0)
427+
return {"result": f"processed_{value}"}
428+
else: # pragma: no cover
429+
raise ValueError(f"Unknown tool: {name}")
430+
431+
async def test_callback(client_session: ClientSession) -> CallToolResult:
432+
# Call with invalid input, and handler returns invalid output
433+
# With both validations disabled, server should not return error
434+
try:
435+
return await client_session.call_tool("process", {"value": "invalid"})
436+
except RuntimeError as e:
437+
# Client validation will fail, but server validation was disabled
438+
assert "Invalid structured content" in str(e)
439+
return CallToolResult(
440+
content=[TextContent(type="text", text="Server returned result")],
441+
structuredContent={"result": "processed_invalid"},
442+
isError=False
443+
)
444+
445+
result = await run_tool_test(
446+
tools,
447+
call_tool_handler,
448+
test_callback,
449+
validate_input=False,
450+
validate_output=False
451+
)
452+
453+
# Verify server didn't return an error
454+
assert result is not None
455+
assert not result.isError
456+
assert result.structuredContent == {"result": "processed_invalid"}

tests/server/test_lowlevel_output_validation.py

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ async def run_tool_test(
2121
tools: list[Tool],
2222
call_tool_handler: Callable[[str, dict[str, Any]], Awaitable[Any]],
2323
test_callback: Callable[[ClientSession], Awaitable[CallToolResult]],
24+
validate_output: bool = True,
2425
) -> CallToolResult | None:
2526
"""Helper to run a tool test with minimal boilerplate.
2627
2728
Args:
2829
tools: List of tools to register
2930
call_tool_handler: Handler function for tool calls
3031
test_callback: Async function that performs the test using the client session
32+
validate_output: Whether to enable output validation (default: True)
3133
3234
Returns:
3335
The result of the tool call
@@ -40,7 +42,7 @@ async def run_tool_test(
4042
async def list_tools():
4143
return tools
4244

43-
@server.call_tool()
45+
@server.call_tool(validate_output=validate_output)
4446
async def call_tool(name: str, arguments: dict[str, Any]):
4547
return await call_tool_handler(name, arguments)
4648

@@ -474,3 +476,149 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
474476
assert result.content[0].type == "text"
475477
assert "Output validation error:" in result.content[0].text
476478
assert "'five' is not of type 'integer'" in result.content[0].text
479+
480+
481+
@pytest.mark.anyio
482+
async def test_validate_output_false_returns_invalid_schema():
483+
"""Test that when validate_output=False, server returns invalid output without error."""
484+
tools = [
485+
Tool(
486+
name="tool_with_schema",
487+
description="Tool with output schema",
488+
inputSchema={
489+
"type": "object",
490+
"properties": {},
491+
},
492+
outputSchema={
493+
"type": "object",
494+
"properties": {
495+
"required_field": {"type": "string"},
496+
},
497+
"required": ["required_field"],
498+
},
499+
)
500+
]
501+
502+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
503+
if name == "tool_with_schema":
504+
# Missing required field, but server validation is disabled
505+
return {"other_field": "value"}
506+
else: # pragma: no cover
507+
raise ValueError(f"Unknown tool: {name}")
508+
509+
async def test_callback(client_session: ClientSession) -> CallToolResult:
510+
# Note: Even though server validation is disabled, client validation will still fail
511+
# This test verifies that the server doesn't return an error response
512+
try:
513+
return await client_session.call_tool("tool_with_schema", {})
514+
except RuntimeError as e:
515+
# Client validation failed, but that's expected
516+
# The important thing is that the server didn't return an error response
517+
# We can verify this by checking the error message
518+
assert "Invalid structured content" in str(e)
519+
# Return a mock result to indicate server didn't error
520+
return CallToolResult(
521+
content=[TextContent(type="text", text="Server returned result")],
522+
structuredContent={"other_field": "value"},
523+
isError=False
524+
)
525+
526+
result = await run_tool_test(tools, call_tool_handler, test_callback, validate_output=False)
527+
528+
# Verify server didn't return an error - it returned the invalid output
529+
assert result is not None
530+
assert not result.isError
531+
assert result.structuredContent == {"other_field": "value"}
532+
533+
534+
@pytest.mark.anyio
535+
async def test_validate_output_false_returns_no_structured_output():
536+
"""Test that when validate_output=False, server returns without structured output without error."""
537+
tools = [
538+
Tool(
539+
name="tool_with_schema",
540+
description="Tool with output schema",
541+
inputSchema={
542+
"type": "object",
543+
"properties": {},
544+
},
545+
outputSchema={
546+
"type": "object",
547+
"properties": {
548+
"result": {"type": "string"},
549+
},
550+
"required": ["result"],
551+
},
552+
)
553+
]
554+
555+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> list[TextContent]:
556+
if name == "tool_with_schema":
557+
# Returns only content, no structured output, but server validation is disabled
558+
return [TextContent(type="text", text="No structured output")]
559+
else: # pragma: no cover
560+
raise ValueError(f"Unknown tool: {name}")
561+
562+
async def test_callback(client_session: ClientSession) -> CallToolResult:
563+
# Note: Even though server validation is disabled, client validation will still fail
564+
# This test verifies that the server doesn't return an error response
565+
try:
566+
return await client_session.call_tool("tool_with_schema", {})
567+
except RuntimeError as e:
568+
# Client validation failed, but that's expected
569+
# The important thing is that the server didn't return an error response
570+
assert "has an output schema but did not return structured content" in str(e)
571+
# Return a mock result to indicate server didn't error
572+
return CallToolResult(
573+
content=[TextContent(type="text", text="No structured output")],
574+
structuredContent=None,
575+
isError=False
576+
)
577+
578+
result = await run_tool_test(tools, call_tool_handler, test_callback, validate_output=False)
579+
580+
# Verify server didn't return an error - it returned content without structured output
581+
assert result is not None
582+
assert not result.isError
583+
assert len(result.content) == 1
584+
assert result.content[0].text == "No structured output"
585+
assert result.structuredContent is None
586+
587+
588+
@pytest.mark.anyio
589+
async def test_validate_output_true_with_invalid_schema():
590+
"""Test that when validate_output=True (default), invalid output schema is validated."""
591+
tools = [
592+
Tool(
593+
name="tool_with_schema",
594+
description="Tool with output schema",
595+
inputSchema={
596+
"type": "object",
597+
"properties": {},
598+
},
599+
outputSchema={
600+
"type": "object",
601+
"properties": {
602+
"required_field": {"type": "string"},
603+
},
604+
"required": ["required_field"],
605+
},
606+
)
607+
]
608+
609+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> dict[str, Any]:
610+
if name == "tool_with_schema":
611+
# Missing required field, validation is enabled (default)
612+
return {"other_field": "value"}
613+
else: # pragma: no cover
614+
raise ValueError(f"Unknown tool: {name}")
615+
616+
async def test_callback(client_session: ClientSession) -> CallToolResult:
617+
return await client_session.call_tool("tool_with_schema", {})
618+
619+
result = await run_tool_test(tools, call_tool_handler, test_callback)
620+
621+
# Verify error - output validation is enabled by default
622+
assert result is not None
623+
assert result.isError
624+
assert "Output validation error:" in result.content[0].text

0 commit comments

Comments
 (0)