@@ -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 ,
0 commit comments