Skip to content

Commit 762c536

Browse files
use_structured_content given preference when available
1 parent 7b84678 commit 762c536

File tree

2 files changed

+357
-16
lines changed

2 files changed

+357
-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+
198+
# If structured content is requested and available, use it exclusively
199+
if server.use_structured_content and result.structuredContent:
210200
tool_output = json.dumps(result.structuredContent)
211201
else:
212-
# Empty content is a valid result (e.g., "no results found")
213-
tool_output = "[]"
202+
# The MCP tool result is a list of content items, whereas OpenAI tool outputs are a single
203+
# 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: 344 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 Tool as MCPTool, CallToolResult, TextContent
77
from pydantic import BaseModel, TypeAdapter
88

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

0 commit comments

Comments
 (0)