Skip to content

Commit ab28f74

Browse files
committed
Python: Add explicit MCP result content modes
1 parent 5f46d09 commit ab28f74

3 files changed

Lines changed: 128 additions & 64 deletions

File tree

‎python/packages/core/agent_framework/__init__.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@
167167
"._mcp": (
168168
"MCPStdioTool",
169169
"MCPStreamableHTTPTool",
170+
"MCPResultContentMode",
170171
"MCPTaskOptions",
171172
"MCPWebsocketTool",
172173
"SamplingApprovalCallback",
@@ -499,6 +500,7 @@
499500
"MCPSkillsSource",
500501
"MCPStdioTool",
501502
"MCPStreamableHTTPTool",
503+
"MCPResultContentMode",
502504
"MCPTaskOptions",
503505
"MCPWebsocketTool",
504506
"MemoryContextProvider",

‎python/packages/core/agent_framework/_mcp.py‎

Lines changed: 63 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,23 @@ class MCPSpecificApproval(TypedDict, total=False):
137137
_DEFAULT_SAMPLING_MAX_TOKENS = 4096
138138
_DEFAULT_SAMPLING_MAX_REQUESTS = 25
139139

140+
# Controls how an MCP CallToolResult that contains both ``content`` and
141+
# ``structuredContent`` is represented to the agent.
142+
MCPResultContentMode: TypeAlias = Literal[
143+
"structured_first",
144+
"content_first",
145+
"content_only",
146+
"structured_only",
147+
"both",
148+
]
149+
_MCP_RESULT_CONTENT_MODES: frozenset[str] = frozenset({
150+
"structured_first",
151+
"content_first",
152+
"content_only",
153+
"structured_only",
154+
"both",
155+
})
156+
140157
# A user-supplied gate invoked before each server-initiated sampling request is
141158
# forwarded to the chat client. It receives the raw ``CreateMessageRequestParams``
142159
# and returns (or awaits to) a truthy value to approve the request or a falsy
@@ -390,33 +407,6 @@ def _should_propagate_cancelled_error(ex: BaseException) -> bool:
390407
return task is not None and task.cancelling() > 0
391408

392409

393-
def _structured_content_contains_text(structured: Any, text: str) -> bool:
394-
"""Return whether *text* appears as a string value anywhere in *structured*."""
395-
if isinstance(structured, str):
396-
return structured == text
397-
if isinstance(structured, Mapping):
398-
return any(_structured_content_contains_text(value, text) for value in structured.values())
399-
if isinstance(structured, Sequence) and not isinstance(structured, (str, bytes, bytearray)):
400-
return any(_structured_content_contains_text(item, text) for item in structured)
401-
return False
402-
403-
404-
def _text_duplicates_structured_content(text: str, structured: Any, structured_json: str) -> bool:
405-
"""Return whether a text content block is an echo of ``structuredContent``.
406-
407-
MCP servers often return the same payload as both a text ``content`` block and
408-
``structuredContent`` (for example ``{"result": "<same text>"}``). Treat those as
409-
duplicates so agents are not charged twice. Complementary text (a human-readable
410-
summary that is not present in the structured payload) is kept.
411-
"""
412-
if text == structured_json:
413-
return True
414-
with contextlib.suppress(json.JSONDecodeError, TypeError):
415-
if json.loads(text) == structured:
416-
return True
417-
return _structured_content_contains_text(structured, text)
418-
419-
420410
# region: MCP Plugin
421411

422412

@@ -460,6 +450,7 @@ def __init__(
460450
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
461451
use_progressive_disclosure: bool = False,
462452
always_load: Collection[str] | None = None,
453+
result_content_mode: MCPResultContentMode = "structured_first",
463454
) -> None:
464455
"""Initialize the MCP Tool base.
465456
@@ -525,9 +516,19 @@ def __init__(
525516
always_load: MCP tool names to keep visible from the start when progressive disclosure
526517
is enabled. Names use the same safe matching rules as ``allowed_tools``; unmatched
527518
entries are ignored.
519+
result_content_mode: How to represent results that include both ``content`` and
520+
``structuredContent``. ``"structured_first"`` (the default) selects
521+
structured content when present, otherwise regular content. ``"content_first"``
522+
does the reverse; ``"content_only"`` and ``"structured_only"`` select only
523+
the named source; and ``"both"`` preserves both, with structured content first.
528524
"""
529525
if use_progressive_disclosure and not load_tools:
530526
raise ValueError("use_progressive_disclosure=True requires load_tools=True.")
527+
if result_content_mode not in _MCP_RESULT_CONTENT_MODES:
528+
raise ValueError(
529+
"result_content_mode must be one of "
530+
f"{', '.join(sorted(_MCP_RESULT_CONTENT_MODES))}; got {result_content_mode!r}."
531+
)
531532
if use_progressive_disclosure:
532533
_warn_on_feature_use(
533534
stage="experimental",
@@ -543,6 +544,7 @@ def __init__(
543544
self.additional_properties = additional_properties
544545
self.load_tools_flag = load_tools
545546
self.parse_tool_results = parse_tool_results
547+
self.result_content_mode = result_content_mode
546548
self.load_prompts_flag = load_prompts
547549
self.parse_prompt_results = parse_prompt_results
548550
# Defer constructing the default MCPTaskOptions so the experimental warning
@@ -657,11 +659,10 @@ def _parse_tool_result_from_mcp(
657659
) -> list[Content]:
658660
"""Parse an MCP CallToolResult into a list of Content items.
659661
660-
When ``structuredContent`` is present it is emitted first. Text (or embedded
661-
text) ``content`` blocks that merely echo that structured payload are skipped
662-
so servers such as MS Learn / DeepWiki do not duplicate tokens (#7866).
663-
Non-text blocks (images, audio, resources) and complementary text that is not
664-
represented in ``structuredContent`` are retained.
662+
``result_content_mode`` controls how a result that supplies both fields is
663+
represented. This avoids guessing whether text from one field duplicates a
664+
value nested in the other: such guesses can lose distinct JSON values and
665+
require repeatedly scanning untrusted, arbitrarily large payloads.
665666
666667
If the server attached a ``_meta`` payload to the tool result (e.g. for
667668
Information Flow Control labels under the ``ifc`` key), a copy of that
@@ -680,31 +681,22 @@ def _parse_tool_result_from_mcp(
680681
# each newly constructed Content; empty when the server provided no meta.
681682
additional_kwargs: dict[str, Any] = {"additional_properties": {"_meta": meta}} if meta else {}
682683

683-
structured = mcp_type.structuredContent
684-
structured_json: str | None = None
685-
result: list[Content] = []
686-
if structured is not None:
687-
structured_json = json.dumps(structured, default=str)
688-
result.append(Content.from_text(structured_json, **additional_kwargs))
689-
684+
content_result: list[Content] = []
690685
for item in mcp_type.content:
691686
match item:
692687
case types.TextContent():
693-
if structured is not None and structured_json is not None:
694-
if _text_duplicates_structured_content(item.text, structured, structured_json):
695-
continue
696-
result.append(Content.from_text(item.text, **additional_kwargs))
688+
content_result.append(Content.from_text(item.text, **additional_kwargs))
697689
case types.ImageContent() | types.AudioContent():
698690
decoded = base64.b64decode(item.data)
699-
result.append(
691+
content_result.append(
700692
Content.from_data(
701693
data=decoded,
702694
media_type=item.mimeType,
703695
**additional_kwargs,
704696
)
705697
)
706698
case types.ResourceLink():
707-
result.append(
699+
content_result.append(
708700
Content.from_uri(
709701
uri=str(item.uri),
710702
media_type=item.mimeType,
@@ -714,30 +706,38 @@ def _parse_tool_result_from_mcp(
714706
case types.EmbeddedResource():
715707
match item.resource:
716708
case types.TextResourceContents():
717-
if structured is not None and structured_json is not None:
718-
if _text_duplicates_structured_content(
719-
item.resource.text, structured, structured_json
720-
):
721-
continue
722-
result.append(Content.from_text(item.resource.text, **additional_kwargs))
709+
content_result.append(Content.from_text(item.resource.text, **additional_kwargs))
723710
case types.BlobResourceContents():
724711
blob = item.resource.blob
725712
mime = item.resource.mimeType or "application/octet-stream"
726713
if not blob.startswith("data:"):
727714
blob = f"data:{mime};base64,{blob}"
728-
result.append(
715+
content_result.append(
729716
Content.from_uri(
730717
uri=blob,
731718
media_type=mime,
732719
**additional_kwargs,
733720
)
734721
)
735722
case _:
736-
fallback = str(item)
737-
if structured is not None and structured_json is not None:
738-
if _text_duplicates_structured_content(fallback, structured, structured_json):
739-
continue
740-
result.append(Content.from_text(fallback, **additional_kwargs))
723+
content_result.append(Content.from_text(str(item), **additional_kwargs))
724+
725+
structured_result = (
726+
[Content.from_text(json.dumps(mcp_type.structuredContent, default=str), **additional_kwargs)]
727+
if mcp_type.structuredContent is not None
728+
else []
729+
)
730+
match self.result_content_mode:
731+
case "structured_first":
732+
result = structured_result or content_result
733+
case "content_first":
734+
result = content_result or structured_result
735+
case "content_only":
736+
result = content_result
737+
case "structured_only":
738+
result = structured_result
739+
case "both":
740+
result = [*structured_result, *content_result]
741741

742742
if not result:
743743
result.append(Content.from_text("null", **additional_kwargs))
@@ -2802,6 +2802,7 @@ def __init__(
28022802
additional_properties: dict[str, Any] | None = None,
28032803
task_options: MCPTaskOptions | None = None,
28042804
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
2805+
result_content_mode: MCPResultContentMode = "structured_first",
28052806
**kwargs: Any,
28062807
) -> None:
28072808
"""Initialize the MCP stdio tool.
@@ -2903,6 +2904,7 @@ def __init__(
29032904
request_timeout=request_timeout,
29042905
task_options=task_options,
29052906
additional_tool_argument_names=additional_tool_argument_names,
2907+
result_content_mode=result_content_mode,
29062908
sampling_approval_callback=sampling_approval_callback,
29072909
sampling_max_tokens=sampling_max_tokens,
29082910
sampling_max_requests=sampling_max_requests,
@@ -2991,6 +2993,7 @@ def __init__(
29912993
header_provider: Callable[[dict[str, Any]], dict[str, str]] | None = None,
29922994
task_options: MCPTaskOptions | None = None,
29932995
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
2996+
result_content_mode: MCPResultContentMode = "structured_first",
29942997
**kwargs: Any,
29952998
) -> None:
29962999
"""Initialize the MCP streamable HTTP tool.
@@ -3114,6 +3117,7 @@ def __init__(
31143117
request_timeout=request_timeout,
31153118
task_options=task_options,
31163119
additional_tool_argument_names=additional_tool_argument_names,
3120+
result_content_mode=result_content_mode,
31173121
sampling_approval_callback=sampling_approval_callback,
31183122
sampling_max_tokens=sampling_max_tokens,
31193123
sampling_max_requests=sampling_max_requests,
@@ -3291,6 +3295,7 @@ def __init__(
32913295
additional_properties: dict[str, Any] | None = None,
32923296
task_options: MCPTaskOptions | None = None,
32933297
additional_tool_argument_names: Sequence[str] | Mapping[str, Sequence[str]] | None = None,
3298+
result_content_mode: MCPResultContentMode = "structured_first",
32943299
**kwargs: Any,
32953300
) -> None:
32963301
"""Initialize the MCP WebSocket tool.
@@ -3390,6 +3395,7 @@ def __init__(
33903395
request_timeout=request_timeout,
33913396
task_options=task_options,
33923397
additional_tool_argument_names=additional_tool_argument_names,
3398+
result_content_mode=result_content_mode,
33933399
sampling_approval_callback=sampling_approval_callback,
33943400
sampling_max_tokens=sampling_max_tokens,
33953401
sampling_max_requests=sampling_max_requests,

‎python/packages/core/tests/core/test_mcp.py‎

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None:
9898
)
9999

100100

101+
@pytest.mark.parametrize(
102+
"tool",
103+
[
104+
MCPStdioTool(name="stdio", command="python", result_content_mode="both"),
105+
MCPStreamableHTTPTool(name="http", url="http://localhost:8080", result_content_mode="both"),
106+
MCPWebsocketTool(name="websocket", url="ws://localhost:8080", result_content_mode="both"),
107+
],
108+
)
109+
def test_mcp_transports_forward_result_content_mode(tool: MCPTool) -> None:
110+
"""Every transport exposes the base result-content selection policy."""
111+
assert tool.result_content_mode == "both"
112+
113+
101114
async def test_load_tools_with_tool_name_prefix_preserves_matching_configuration():
102115
"""Prefixed MCP tool names should still honor unprefixed allow/approval configuration."""
103116
tool = MCPTool( # type: ignore[abstract]
@@ -473,20 +486,18 @@ def test_parse_tool_result_from_mcp_structured_content_only():
473486

474487

475488
def test_parse_tool_result_from_mcp_structured_content_with_text():
476-
"""Complementary human-readable text is kept alongside structuredContent."""
489+
"""The default structured-first mode selects structuredContent."""
477490
mcp_result = types.CallToolResult(
478491
content=[types.TextContent(type="text", text="Summary")],
479492
structuredContent={"data": [1, 2, 3]},
480493
)
481494
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
482495

483496
assert isinstance(result, list)
484-
assert len(result) == 2
497+
assert len(result) == 1
485498
assert result[0].type == "text"
486499
assert result[0].text is not None
487500
assert json.loads(result[0].text) == {"data": [1, 2, 3]}
488-
assert result[1].type == "text"
489-
assert result[1].text == "Summary"
490501

491502

492503
def test_parse_tool_result_from_mcp_does_not_duplicate_equivalent_structured_content():
@@ -524,7 +535,7 @@ def test_parse_tool_result_from_mcp_structured_content_stamps_meta():
524535

525536

526537
def test_parse_tool_result_from_mcp_keeps_rich_content_with_structured():
527-
"""Non-text content blocks must not be dropped when structuredContent is present."""
538+
"""The ``both`` mode preserves rich content alongside structuredContent."""
528539
mcp_result = types.CallToolResult(
529540
content=[
530541
types.ImageContent(
@@ -536,15 +547,60 @@ def test_parse_tool_result_from_mcp_keeps_rich_content_with_structured():
536547
],
537548
structuredContent={"caption": "caption echoed in structured", "width": 32},
538549
)
539-
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
550+
tool = MCPTool(name="helper", result_content_mode="both") # type: ignore[abstract]
551+
result = tool._parse_tool_result_from_mcp(mcp_result)
540552

541-
assert len(result) == 2
553+
assert len(result) == 3
542554
assert result[0].type == "text"
543555
assert result[0].text is not None
544556
assert json.loads(result[0].text) == {"caption": "caption echoed in structured", "width": 32}
545557
assert result[1].type == "data"
546558
assert result[1].media_type == "image/png"
547559
assert "ZmFrZS1pbWFnZS1ieXRlcw==" in result[1].uri # type: ignore[operator]
560+
assert result[2].type == "text"
561+
assert result[2].text == "caption echoed in structured"
562+
563+
564+
@pytest.mark.parametrize(
565+
("mode", "expected_texts"),
566+
[
567+
("structured_first", ['{"structured": true}']),
568+
("content_first", ["summary"]),
569+
("content_only", ["summary"]),
570+
("structured_only", ['{"structured": true}']),
571+
("both", ['{"structured": true}', "summary"]),
572+
],
573+
)
574+
def test_parse_tool_result_from_mcp_result_content_mode(mode: str, expected_texts: list[str]):
575+
"""Each explicit result-content policy has predictable selection behavior."""
576+
tool = MCPTool(name="helper", result_content_mode=mode) # type: ignore[abstract, arg-type]
577+
mcp_result = types.CallToolResult(
578+
content=[types.TextContent(type="text", text="summary")],
579+
structuredContent={"structured": True},
580+
)
581+
582+
result = tool._parse_tool_result_from_mcp(mcp_result)
583+
584+
assert [item.text for item in result] == expected_texts
585+
586+
587+
def test_parse_tool_result_from_mcp_both_mode_preserves_distinct_json_types():
588+
"""The explicit ``both`` policy never conflates JSON values such as 1 and true."""
589+
tool = MCPTool(name="helper", result_content_mode="both") # type: ignore[abstract]
590+
mcp_result = types.CallToolResult(
591+
content=[types.TextContent(type="text", text='{"approved": 1}')],
592+
structuredContent={"approved": True},
593+
)
594+
595+
result = tool._parse_tool_result_from_mcp(mcp_result)
596+
597+
assert [item.text for item in result] == ['{"approved": true}', '{"approved": 1}']
598+
599+
600+
def test_mcp_tool_rejects_unknown_result_content_mode():
601+
"""Result selection policies are validated at construction time."""
602+
with pytest.raises(ValueError, match="result_content_mode"):
603+
MCPTool(name="helper", result_content_mode="deduplicate") # type: ignore[abstract, arg-type]
548604

549605

550606
def test_parse_tool_result_from_mcp_structured_content_none():

0 commit comments

Comments
 (0)