Skip to content

Commit 14a6eaa

Browse files
committed
Allow CallToolResult to be returned directly from tool calls
1 parent 61399b3 commit 14a6eaa

File tree

2 files changed

+154
-15
lines changed

2 files changed

+154
-15
lines changed

src/mcp/server/lowlevel/server.py

Lines changed: 19 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -480,7 +480,7 @@ def call_tool(self, *, validate_input: bool = True):
480480
def decorator(
481481
func: Callable[
482482
...,
483-
Awaitable[UnstructuredContent | StructuredContent | CombinationContent],
483+
Awaitable[UnstructuredContent | StructuredContent | CombinationContent | types.CallToolResult],
484484
],
485485
):
486486
logger.debug("Registering handler for CallToolRequest")
@@ -502,42 +502,46 @@ async def handler(req: types.CallToolRequest):
502502
results = await func(tool_name, arguments)
503503

504504
# output normalization
505+
casted_results: types.CallToolResult
505506
unstructured_content: UnstructuredContent
506507
maybe_structured_content: StructuredContent | None
507-
if isinstance(results, tuple) and len(results) == 2:
508+
if isinstance(results, types.CallToolResult):
509+
casted_results = results
510+
elif isinstance(results, tuple) and len(results) == 2:
508511
# tool returned both structured and unstructured content
509512
unstructured_content, maybe_structured_content = cast(CombinationContent, results)
513+
casted_results = types.CallToolResult(
514+
content=list(unstructured_content),
515+
structuredContent=maybe_structured_content,
516+
)
510517
elif isinstance(results, dict):
511518
# tool returned structured content only
512-
maybe_structured_content = cast(StructuredContent, results)
513-
unstructured_content = [types.TextContent(type="text", text=json.dumps(results, indent=2))]
519+
casted_results = types.CallToolResult(
520+
content=[types.TextContent(type="text", text=json.dumps(results, indent=2))],
521+
structuredContent=cast(StructuredContent, results),
522+
)
514523
elif hasattr(results, "__iter__"):
515524
# tool returned unstructured content only
516-
unstructured_content = cast(UnstructuredContent, results)
517-
maybe_structured_content = None
525+
casted_results = types.CallToolResult(
526+
content=list(cast(UnstructuredContent, results)),
527+
)
518528
else:
519529
return self._make_error_result(f"Unexpected return type from tool: {type(results).__name__}")
520530

521531
# output validation
522532
if tool and tool.outputSchema is not None:
523-
if maybe_structured_content is None:
533+
if casted_results.structuredContent is None:
524534
return self._make_error_result(
525535
"Output validation error: outputSchema defined but no structured output returned"
526536
)
527537
else:
528538
try:
529-
jsonschema.validate(instance=maybe_structured_content, schema=tool.outputSchema)
539+
jsonschema.validate(instance=casted_results.structuredContent, schema=tool.outputSchema)
530540
except jsonschema.ValidationError as e:
531541
return self._make_error_result(f"Output validation error: {e.message}")
532542

533543
# result
534-
return types.ServerResult(
535-
types.CallToolResult(
536-
content=list(unstructured_content),
537-
structuredContent=maybe_structured_content,
538-
isError=False,
539-
)
540-
)
544+
return types.ServerResult(casted_results)
541545
except Exception as e:
542546
return self._make_error_result(str(e))
543547

tests/server/test_lowlevel_output_validation.py

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -391,6 +391,141 @@ async def test_callback(client_session: ClientSession) -> CallToolResult:
391391
assert result.structuredContent == {"sentiment": "positive", "confidence": 0.95}
392392

393393

394+
@pytest.mark.anyio
395+
async def test_tool_call_result_without_output_schema():
396+
"""Test returning ToolCallResult when no outputSchema is defined."""
397+
tools = [
398+
Tool(
399+
name="get_info",
400+
description="Get structured information",
401+
inputSchema={
402+
"type": "object",
403+
"properties": {},
404+
},
405+
# No outputSchema defined
406+
)
407+
]
408+
409+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
410+
if name == "get_info":
411+
return CallToolResult(
412+
content=[TextContent(type="text", text="Results calculated")],
413+
structuredContent={"status": "ok", "data": {"value": 42}},
414+
_meta={"some": "metadata"},
415+
)
416+
else:
417+
raise ValueError(f"Unknown tool: {name}")
418+
419+
async def test_callback(client_session: ClientSession) -> CallToolResult:
420+
return await client_session.call_tool("get_info", {})
421+
422+
result = await run_tool_test(tools, call_tool_handler, test_callback)
423+
424+
# Verify results
425+
assert result is not None
426+
assert not result.isError
427+
assert len(result.content) == 1
428+
assert result.content[0].type == "text"
429+
assert result.content[0].text == "Results calculated"
430+
assert isinstance(result.content[0], TextContent)
431+
assert result.structuredContent == {"status": "ok", "data": {"value": 42}}
432+
assert result.meta == {"some": "metadata"}
433+
434+
435+
@pytest.mark.anyio
436+
async def test_valid_tool_call_result_with_output_schema():
437+
"""Test returning ToolCallResult when no outputSchema is defined."""
438+
tools = [
439+
Tool(
440+
name="get_info",
441+
description="Get structured information",
442+
inputSchema={
443+
"type": "object",
444+
"properties": {},
445+
},
446+
outputSchema={
447+
"type": "object",
448+
"properties": {
449+
"name": {"type": "string"},
450+
"number": {"type": "integer"},
451+
},
452+
"required": ["name", "number"],
453+
},
454+
)
455+
]
456+
457+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
458+
if name == "get_info":
459+
return CallToolResult(
460+
content=[TextContent(type="text", text="Results calculated")],
461+
structuredContent={"name": "Brandon", "number": 29},
462+
_meta={"some": "metadata"},
463+
)
464+
else:
465+
raise ValueError(f"Unknown tool: {name}")
466+
467+
async def test_callback(client_session: ClientSession) -> CallToolResult:
468+
return await client_session.call_tool("get_info", {})
469+
470+
result = await run_tool_test(tools, call_tool_handler, test_callback)
471+
472+
# Verify results
473+
assert result is not None
474+
assert not result.isError
475+
assert len(result.content) == 1
476+
assert result.content[0].type == "text"
477+
assert result.content[0].text == "Results calculated"
478+
assert isinstance(result.content[0], TextContent)
479+
assert result.structuredContent == {"name": "Brandon", "number": 29}
480+
assert result.meta == {"some": "metadata"}
481+
482+
483+
@pytest.mark.anyio
484+
async def test_invalid_tool_call_result_with_output_schema():
485+
"""Test returning ToolCallResult when no outputSchema is defined."""
486+
tools = [
487+
Tool(
488+
name="get_info",
489+
description="Get structured information",
490+
inputSchema={
491+
"type": "object",
492+
"properties": {},
493+
},
494+
outputSchema={
495+
"type": "object",
496+
"properties": {
497+
"name": {"type": "string"},
498+
"number": {"type": "integer"},
499+
},
500+
"required": ["name", "number"],
501+
},
502+
)
503+
]
504+
505+
async def call_tool_handler(name: str, arguments: dict[str, Any]) -> CallToolResult:
506+
if name == "get_info":
507+
return CallToolResult(
508+
content=[TextContent(type="text", text="Results calculated")],
509+
structuredContent={"name": "Brandon"},
510+
_meta={"some": "metadata"},
511+
)
512+
else:
513+
raise ValueError(f"Unknown tool: {name}")
514+
515+
async def test_callback(client_session: ClientSession) -> CallToolResult:
516+
return await client_session.call_tool("get_info", {})
517+
518+
result = await run_tool_test(tools, call_tool_handler, test_callback)
519+
520+
assert result is not None
521+
assert result.isError
522+
assert len(result.content) == 1
523+
assert result.content[0].type == "text"
524+
assert isinstance(result.content[0], TextContent)
525+
assert "Output validation error:" in result.content[0].text
526+
assert "'number' is a required property" in result.content[0].text
527+
528+
394529
@pytest.mark.anyio
395530
async def test_output_schema_type_validation():
396531
"""Test outputSchema validates types correctly."""

0 commit comments

Comments
 (0)