|  | 
| 3 | 3 | 
 | 
| 4 | 4 | import pytest | 
| 5 | 5 | from inline_snapshot import snapshot | 
| 6 |  | -from mcp.types import Tool as MCPTool | 
|  | 6 | +from mcp.types import CallToolResult, TextContent, Tool as MCPTool | 
| 7 | 7 | from pydantic import BaseModel, TypeAdapter | 
| 8 | 8 | 
 | 
| 9 | 9 | from agents import Agent, FunctionTool, RunContextWrapper | 
| @@ -351,3 +351,327 @@ async def test_util_adds_properties(): | 
| 351 | 351 |  assert tool.params_json_schema == snapshot( | 
| 352 | 352 |  {"type": "object", "description": "Test tool", "properties": {}} | 
| 353 | 353 |  ) | 
|  | 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