Skip to content

Commit fb68e77

Browse files
Fix(mcp): Unreachable structured content branch in invoke_mcp_tool (#1250)
### Summary This PR handles the MCP tool output where structured content could never be returned exclusively when `use_structured_content=True`. The conditional logic checked for content length first, making the structured content branch unreachable when both content types were present. **Before (broken logic):** ```python if len(result.content) == 1: tool_output = result.content[0].model_dump_json() elif server.use_structured_content and result.structuredContent: # Never reached! tool_output = json.dumps(result.structuredContent) ``` **After (fixed logic):** ```python if server.use_structured_content and result.structuredContent: tool_output = json.dumps(result.structuredContent) else: # Fall back to regular text content processing if len(result.content) == 1: tool_output = result.content[0].model_dump_json() ``` **Example usage:** ```python # MCP server with structured content enabled server = MCPServer(use_structured_content=True) # When invoke_mcp_tool processes a tool that returns both text and structured content: # - Text content: "Found 2 users" # - Structured content: {"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]} result = await MCPUtil.invoke_mcp_tool(server, tool, context, input_json) # Before fix: Would return '{"type":"text","text":"Found 2 users","annotations":null,"meta":null}' # After fix: Returns '{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}' ``` ### Test plan I've added thorough test coverage to make sure this fix works properly: - Created tests for 9 different scenarios to verify structured content takes priority when it should, and falls back correctly when it shouldn't - Added specific tests to ensure the core priority logic works as expected and that we didn't break any existing functionality - Confirmed that the tests catch the original bug (unreachable code) and validate that our fix actually solves the problem ### Issue number Fixes #1236 ### Checks - [x] I've added new tests (if relevant) - Added comprehensive parameterized tests and individual test functions for all structured content scenarios - [x] I've added/updated the relevant documentation - Code comments updated to explain the fix and logic flow - [x] I've run `make lint` and `make format` - Code follows project formatting standards - [x] I've made sure tests pass - All existing tests continue to pass, and new structured content tests validate the fix --------- Co-authored-by: SyedMohamedHyder <[email protected]>
1 parent 5e31be9 commit fb68e77

File tree

2 files changed

+338
-16
lines changed

2 files changed

+338
-16
lines changed

src/agents/mcp/util.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -194,23 +194,21 @@ async def invoke_mcp_tool(
194194
else:
195195
logger.debug(f"MCP tool {tool.name} returned {result}")
196196

197-
# The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single
198-
# string. We'll try to convert.
199-
if len(result.content) == 1:
200-
tool_output = result.content[0].model_dump_json()
201-
# Append structured content if it exists and we're using it.
202-
if server.use_structured_content and result.structuredContent:
203-
tool_output = f"{tool_output}\n{json.dumps(result.structuredContent)}"
204-
elif len(result.content) > 1:
205-
tool_results = [item.model_dump(mode="json") for item in result.content]
206-
if server.use_structured_content and result.structuredContent:
207-
tool_results.append(result.structuredContent)
208-
tool_output = json.dumps(tool_results)
209-
elif server.use_structured_content and result.structuredContent:
197+
# If structured content is requested and available, use it exclusively
198+
if server.use_structured_content and result.structuredContent:
210199
tool_output = json.dumps(result.structuredContent)
211200
else:
212-
# Empty content is a valid result (e.g., "no results found")
213-
tool_output = "[]"
201+
# Fall back to regular text content processing
202+
# The MCP tool result is a list of content items, whereas OpenAI tool
203+
# outputs are a single string. We'll try to convert.
204+
if len(result.content) == 1:
205+
tool_output = result.content[0].model_dump_json()
206+
elif len(result.content) > 1:
207+
tool_results = [item.model_dump(mode="json") for item in result.content]
208+
tool_output = json.dumps(tool_results)
209+
else:
210+
# Empty content is a valid result (e.g., "no results found")
211+
tool_output = "[]"
214212

215213
current_span = get_current_span()
216214
if current_span:

tests/mcp/test_mcp_util.py

Lines changed: 325 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import pytest
55
from inline_snapshot import snapshot
6-
from mcp.types import Tool as MCPTool
6+
from mcp.types import CallToolResult, TextContent, Tool as MCPTool
77
from pydantic import BaseModel, TypeAdapter
88

99
from agents import Agent, FunctionTool, RunContextWrapper
@@ -351,3 +351,327 @@ async def test_util_adds_properties():
351351
assert tool.params_json_schema == snapshot(
352352
{"type": "object", "description": "Test tool", "properties": {}}
353353
)
354+
355+
356+
class StructuredContentTestServer(FakeMCPServer):
357+
"""Test server that allows setting both content and structured content for testing."""
358+
359+
def __init__(self, use_structured_content: bool = False, **kwargs):
360+
super().__init__(**kwargs)
361+
self.use_structured_content = use_structured_content
362+
self._test_content: list[Any] = []
363+
self._test_structured_content: dict[str, Any] | None = None
364+
365+
def set_test_result(self, content: list[Any], structured_content: dict[str, Any] | None = None):
366+
"""Set the content and structured content that will be returned by call_tool."""
367+
self._test_content = content
368+
self._test_structured_content = structured_content
369+
370+
async def call_tool(self, tool_name: str, arguments: dict[str, Any] | None) -> CallToolResult:
371+
"""Return test result with specified content and structured content."""
372+
self.tool_calls.append(tool_name)
373+
374+
return CallToolResult(
375+
content=self._test_content, structuredContent=self._test_structured_content
376+
)
377+
378+
379+
@pytest.mark.parametrize(
380+
"use_structured_content,content,structured_content,expected_output",
381+
[
382+
# Scenario 1: use_structured_content=True with structured content available
383+
# Should return only structured content
384+
(
385+
True,
386+
[TextContent(text="text content", type="text")],
387+
{"data": "structured_value", "type": "structured"},
388+
'{"data": "structured_value", "type": "structured"}',
389+
),
390+
# Scenario 2: use_structured_content=False with structured content available
391+
# Should return text content only (structured content ignored)
392+
(
393+
False,
394+
[TextContent(text="text content", type="text")],
395+
{"data": "structured_value", "type": "structured"},
396+
'{"type":"text","text":"text content","annotations":null,"meta":null}',
397+
),
398+
# Scenario 3: use_structured_content=True but no structured content
399+
# Should fall back to text content
400+
(
401+
True,
402+
[TextContent(text="fallback text", type="text")],
403+
None,
404+
'{"type":"text","text":"fallback text","annotations":null,"meta":null}',
405+
),
406+
# Scenario 4: use_structured_content=True with empty structured content (falsy)
407+
# Should fall back to text content
408+
(
409+
True,
410+
[TextContent(text="fallback text", type="text")],
411+
{},
412+
'{"type":"text","text":"fallback text","annotations":null,"meta":null}',
413+
),
414+
# Scenario 5: use_structured_content=True, structured content available, empty text content
415+
# Should return structured content
416+
(True, [], {"message": "only structured"}, '{"message": "only structured"}'),
417+
# Scenario 6: use_structured_content=False, multiple text content items
418+
# Should return JSON array of text content
419+
(
420+
False,
421+
[TextContent(text="first", type="text"), TextContent(text="second", type="text")],
422+
{"ignored": "structured"},
423+
'[{"type": "text", "text": "first", "annotations": null, "meta": null}, '
424+
'{"type": "text", "text": "second", "annotations": null, "meta": null}]',
425+
),
426+
# Scenario 7: use_structured_content=True, multiple text content, with structured content
427+
# Should return only structured content (text content ignored)
428+
(
429+
True,
430+
[
431+
TextContent(text="ignored first", type="text"),
432+
TextContent(text="ignored second", type="text"),
433+
],
434+
{"priority": "structured"},
435+
'{"priority": "structured"}',
436+
),
437+
# Scenario 8: use_structured_content=False, empty content
438+
# Should return empty array
439+
(False, [], None, "[]"),
440+
# Scenario 9: use_structured_content=True, empty content, no structured content
441+
# Should return empty array
442+
(True, [], None, "[]"),
443+
],
444+
)
445+
@pytest.mark.asyncio
446+
async def test_structured_content_handling(
447+
use_structured_content: bool,
448+
content: list[Any],
449+
structured_content: dict[str, Any] | None,
450+
expected_output: str,
451+
):
452+
"""Test that structured content handling works correctly with various scenarios.
453+
454+
This test verifies the fix for the MCP tool output logic where:
455+
- When use_structured_content=True and structured content exists, it's used exclusively
456+
- When use_structured_content=False or no structured content, falls back to text content
457+
- The old unreachable code path has been fixed
458+
"""
459+
460+
server = StructuredContentTestServer(use_structured_content=use_structured_content)
461+
server.add_tool("test_tool", {})
462+
server.set_test_result(content, structured_content)
463+
464+
ctx = RunContextWrapper(context=None)
465+
tool = MCPTool(name="test_tool", inputSchema={})
466+
467+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
468+
assert result == expected_output
469+
470+
471+
@pytest.mark.asyncio
472+
async def test_structured_content_priority_over_text():
473+
"""Test that when use_structured_content=True, structured content takes priority.
474+
475+
This verifies the core fix: structured content should be used exclusively when available
476+
and requested, not concatenated with text content.
477+
"""
478+
479+
server = StructuredContentTestServer(use_structured_content=True)
480+
server.add_tool("priority_test", {})
481+
482+
# Set both text and structured content
483+
text_content = [TextContent(text="This should be ignored", type="text")]
484+
structured_content = {"important": "This should be returned", "value": 42}
485+
server.set_test_result(text_content, structured_content)
486+
487+
ctx = RunContextWrapper(context=None)
488+
tool = MCPTool(name="priority_test", inputSchema={})
489+
490+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
491+
492+
# Should return only structured content
493+
import json
494+
495+
parsed_result = json.loads(result)
496+
assert parsed_result == structured_content
497+
assert "This should be ignored" not in result
498+
499+
500+
@pytest.mark.asyncio
501+
async def test_structured_content_fallback_behavior():
502+
"""Test fallback behavior when structured content is requested but not available.
503+
504+
This verifies that the logic properly falls back to text content processing
505+
when use_structured_content=True but no structured content is provided.
506+
"""
507+
508+
server = StructuredContentTestServer(use_structured_content=True)
509+
server.add_tool("fallback_test", {})
510+
511+
# Set only text content, no structured content
512+
text_content = [TextContent(text="Fallback content", type="text")]
513+
server.set_test_result(text_content, None)
514+
515+
ctx = RunContextWrapper(context=None)
516+
tool = MCPTool(name="fallback_test", inputSchema={})
517+
518+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
519+
520+
# Should fall back to text content
521+
import json
522+
523+
parsed_result = json.loads(result)
524+
assert parsed_result["text"] == "Fallback content"
525+
assert parsed_result["type"] == "text"
526+
527+
528+
@pytest.mark.asyncio
529+
async def test_backwards_compatibility_unchanged():
530+
"""Test that default behavior (use_structured_content=False) remains unchanged.
531+
532+
This ensures the fix doesn't break existing behavior for servers that don't use
533+
structured content or have it disabled.
534+
"""
535+
536+
server = StructuredContentTestServer(use_structured_content=False)
537+
server.add_tool("compat_test", {})
538+
539+
# Set both text and structured content
540+
text_content = [TextContent(text="Traditional text output", type="text")]
541+
structured_content = {"modern": "structured output"}
542+
server.set_test_result(text_content, structured_content)
543+
544+
ctx = RunContextWrapper(context=None)
545+
tool = MCPTool(name="compat_test", inputSchema={})
546+
547+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
548+
549+
# Should return only text content (structured content ignored)
550+
import json
551+
552+
parsed_result = json.loads(result)
553+
assert parsed_result["text"] == "Traditional text output"
554+
assert "modern" not in result
555+
556+
557+
@pytest.mark.asyncio
558+
async def test_empty_structured_content_fallback():
559+
"""Test that empty structured content (falsy values) falls back to text content.
560+
561+
This tests the condition: if server.use_structured_content and result.structuredContent
562+
where empty dict {} should be falsy and trigger fallback.
563+
"""
564+
565+
server = StructuredContentTestServer(use_structured_content=True)
566+
server.add_tool("empty_structured_test", {})
567+
568+
# Set text content and empty structured content
569+
text_content = [TextContent(text="Should use this text", type="text")]
570+
empty_structured: dict[str, Any] = {} # This should be falsy
571+
server.set_test_result(text_content, empty_structured)
572+
573+
ctx = RunContextWrapper(context=None)
574+
tool = MCPTool(name="empty_structured_test", inputSchema={})
575+
576+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
577+
578+
# Should fall back to text content because empty dict is falsy
579+
import json
580+
581+
parsed_result = json.loads(result)
582+
assert parsed_result["text"] == "Should use this text"
583+
assert parsed_result["type"] == "text"
584+
585+
586+
@pytest.mark.asyncio
587+
async def test_complex_structured_content():
588+
"""Test handling of complex structured content with nested objects and arrays."""
589+
590+
server = StructuredContentTestServer(use_structured_content=True)
591+
server.add_tool("complex_test", {})
592+
593+
# Set complex structured content
594+
complex_structured = {
595+
"results": [
596+
{"id": 1, "name": "Item 1", "metadata": {"tags": ["a", "b"]}},
597+
{"id": 2, "name": "Item 2", "metadata": {"tags": ["c", "d"]}},
598+
],
599+
"pagination": {"page": 1, "total": 2},
600+
"status": "success",
601+
}
602+
603+
server.set_test_result([], complex_structured)
604+
605+
ctx = RunContextWrapper(context=None)
606+
tool = MCPTool(name="complex_test", inputSchema={})
607+
608+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
609+
610+
# Should return the complex structured content as-is
611+
import json
612+
613+
parsed_result = json.loads(result)
614+
assert parsed_result == complex_structured
615+
assert len(parsed_result["results"]) == 2
616+
assert parsed_result["pagination"]["total"] == 2
617+
618+
619+
@pytest.mark.asyncio
620+
async def test_multiple_content_items_with_structured():
621+
"""Test that multiple text content items are ignored when structured content is available.
622+
623+
This verifies that the new logic prioritizes structured content over multiple text items,
624+
which was one of the scenarios that had unclear behavior in the old implementation.
625+
"""
626+
627+
server = StructuredContentTestServer(use_structured_content=True)
628+
server.add_tool("multi_content_test", {})
629+
630+
# Set multiple text content items and structured content
631+
text_content = [
632+
TextContent(text="First text item", type="text"),
633+
TextContent(text="Second text item", type="text"),
634+
TextContent(text="Third text item", type="text"),
635+
]
636+
structured_content = {"chosen": "structured over multiple text items"}
637+
server.set_test_result(text_content, structured_content)
638+
639+
ctx = RunContextWrapper(context=None)
640+
tool = MCPTool(name="multi_content_test", inputSchema={})
641+
642+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
643+
644+
# Should return only structured content, ignoring all text items
645+
import json
646+
647+
parsed_result = json.loads(result)
648+
assert parsed_result == structured_content
649+
assert "First text item" not in result
650+
assert "Second text item" not in result
651+
assert "Third text item" not in result
652+
653+
654+
@pytest.mark.asyncio
655+
async def test_multiple_content_items_without_structured():
656+
"""Test that multiple text content items are properly handled when no structured content."""
657+
658+
server = StructuredContentTestServer(use_structured_content=True)
659+
server.add_tool("multi_text_test", {})
660+
661+
# Set multiple text content items without structured content
662+
text_content = [TextContent(text="First", type="text"), TextContent(text="Second", type="text")]
663+
server.set_test_result(text_content, None)
664+
665+
ctx = RunContextWrapper(context=None)
666+
tool = MCPTool(name="multi_text_test", inputSchema={})
667+
668+
result = await MCPUtil.invoke_mcp_tool(server, tool, ctx, "{}")
669+
670+
# Should return JSON array of text content items
671+
import json
672+
673+
parsed_result = json.loads(result)
674+
assert isinstance(parsed_result, list)
675+
assert len(parsed_result) == 2
676+
assert parsed_result[0]["text"] == "First"
677+
assert parsed_result[1]["text"] == "Second"

0 commit comments

Comments
 (0)