From 843b90644fce8b460d7cea38ba2a990f2e072965 Mon Sep 17 00:00:00 2001 From: Kip Date: Mon, 2 Nov 2026 20:47:28 +0800 Subject: [PATCH 1/2] Python: serialize Anthropic MCP tool result content on replay Render parsed Anthropic MCP results as JSON-serializable text blocks instead of Content objects, and cover list, top-level mapping, and persisted-history replay with regression tests. Co-Authored-By: Claude Opus 5 --- .../agent_framework_anthropic/_chat_client.py | 58 ++++++++- .../anthropic/tests/test_anthropic_client.py | 122 ++++++++++++++++++ 2 files changed, 176 insertions(+), 4 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 2ad2387393..b9675bfa3f 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +import json import logging import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence @@ -1195,6 +1196,57 @@ def _prepare_server_tool_result_for_anthropic(self, content: Content) -> dict[st result["caller"] = dict(cast(Mapping[str, Any], caller)) return result + @staticmethod + def _prepare_mcp_tool_result_content(output: Any) -> str | list[dict[str, Any]]: + """Render MCP tool result output as Anthropic ``mcp_tool_result`` content. + + Anthropic accepts either a string or a list of text blocks here. Parsed MCP + results are stored as ``list[Content]``, so replaying them directly would put + non-serializable ``Content`` objects into the request body. A top-level text + block mapping is normalized to a one-item list; other mappings are JSON-encoded. + """ + if output is None: + return "" + if isinstance(output, str): + return output + if isinstance(output, Mapping): + mapping_output = cast("Mapping[str, Any]", output) + if mapping_output.get("type") == "text" and isinstance(mapping_output.get("text"), str): + return [{"type": "text", "text": mapping_output["text"]}] + try: + return json.dumps(dict(mapping_output), default=str) + except (TypeError, ValueError): + return str(mapping_output) + if isinstance(output, Sequence) and not isinstance(output, (bytes, bytearray)): + text_blocks: list[dict[str, Any]] = [] + for item in cast("Sequence[Any]", output): + if isinstance(item, Content): + if item.type == "text": + text_blocks.append({"type": "text", "text": item.text or ""}) + else: + logger.debug( + "Ignoring unsupported MCP tool result content type for Anthropic replay: %s", + item.type, + ) + continue + if isinstance(item, Mapping): + mapping_item = cast("Mapping[str, Any]", item) + text = mapping_item.get("text") + if mapping_item.get("type") == "text" and isinstance(text, str): + text_blocks.append({"type": "text", "text": text}) + else: + logger.debug( + "Ignoring unsupported MCP tool result content type for Anthropic replay: %s", + mapping_item.get("type", type(mapping_item).__name__), + ) + continue + logger.debug( + "Ignoring unsupported MCP tool result content type for Anthropic replay: %s", + type(item).__name__, + ) + return text_blocks or "" + return str(output) + def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: """Prepare a Message for the Anthropic client. @@ -1297,7 +1349,7 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: mcp_result: dict[str, Any] = { "type": "mcp_tool_result", "tool_use_id": content.call_id, - "content": content.output if content.output is not None else "", + "content": self._prepare_mcp_tool_result_content(content.output), } a_content.append(mcp_result) case "text_reasoning": @@ -1514,9 +1566,7 @@ def _process_stream_event( case "message_start": usage_details: list[Content] = [] if event.message.usage and ( - details := self._parse_usage_from_anthropic( - event.message.usage, raw_usage_carry=raw_usage_carry - ) + details := self._parse_usage_from_anthropic(event.message.usage, raw_usage_carry=raw_usage_carry) ): usage_details.append( Content.from_usage( diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 15e6654bfc..a1315ae1c8 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1,9 +1,11 @@ # Copyright (c) Microsoft. All rights reserved. import asyncio +import json import logging import os import re from pathlib import Path +from types import MappingProxyType from typing import Annotated, Any, cast from unittest.mock import MagicMock, patch @@ -914,6 +916,126 @@ def test_prepare_message_for_anthropic_mcp_server_tool_result_none_output( assert result["content"][0]["content"] == "" +def test_prepare_message_for_anthropic_mcp_server_tool_result_content_list( + mock_anthropic_client: MagicMock, +) -> None: + """List-based MCP results must replay as text blocks, not Content objects.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="tool", + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_call_list", + output=[ + Content.from_text("Found 3 results."), + {"type": "text", "text": "Second block."}, + Content.from_uri("https://example.com/a.png", media_type="image/png"), + ], + ) + ], + ) + + result = client._prepare_message_for_anthropic(message) + + assert result["role"] == "user" + assert len(result["content"]) == 1 + assert result["content"][0]["type"] == "mcp_tool_result" + assert result["content"][0]["content"] == [ + {"type": "text", "text": "Found 3 results."}, + {"type": "text", "text": "Second block."}, + ] + # The request body must remain JSON serializable. + json.dumps(result) + + +def test_prepare_message_for_anthropic_mcp_server_tool_result_top_level_text_mapping( + mock_anthropic_client: MagicMock, +) -> None: + """A top-level text-block mapping must replay as a text block, not a Python repr.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="tool", + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_call_top_level", + output={"type": "text", "text": "top-level"}, + ) + ], + ) + + result = client._prepare_message_for_anthropic(message) + + assert result["content"][0]["content"] == [{"type": "text", "text": "top-level"}] + json.dumps(result) + + +def test_prepare_message_for_anthropic_mcp_server_tool_result_top_level_non_text_mapping( + mock_anthropic_client: MagicMock, +) -> None: + """A non-dict Mapping must be JSON-encoded rather than stringified as a Python repr.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="tool", + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_call_json", + output=MappingProxyType({"result": "ok", "count": 2}), + ) + ], + ) + + result = client._prepare_message_for_anthropic(message) + + assert json.loads(result["content"][0]["content"]) == {"result": "ok", "count": 2} + json.dumps(result) + + +def test_parse_then_replay_anthropic_mcp_tool_result_list_content( + mock_anthropic_client: MagicMock, +) -> None: + """A parsed list-content MCP result must be replayed as serializable text blocks.""" + client = create_test_anthropic_client(mock_anthropic_client) + + mock_text_block = MagicMock() + mock_text_block.type = "text" + mock_text_block.text = "Result text" + + mock_block = MagicMock() + mock_block.type = "mcp_tool_result" + mock_block.tool_use_id = "call_123" + mock_block.content = [mock_text_block] + + parsed = client._parse_contents_from_anthropic([mock_block]) + result = client._prepare_message_for_anthropic(Message(role="tool", contents=parsed)) + + assert result["content"][0]["type"] == "mcp_tool_result" + assert result["content"][0]["content"] == [{"type": "text", "text": "Result text"}] + json.dumps(result) + + +def test_prepare_message_for_anthropic_mcp_server_tool_result_after_round_trip( + mock_anthropic_client: MagicMock, +) -> None: + """Persisted MCP results must replay after Content.from_dict rebuilds nested Content.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="tool", + contents=[ + Content.from_mcp_server_tool_result( + call_id="mcp_call_roundtrip", + output=[Content.from_text("round-trip output")], + ) + ], + ) + restored = Message.from_dict(message.to_dict()) + + result = client._prepare_message_for_anthropic(restored) + + assert result["content"][0]["type"] == "mcp_tool_result" + assert result["content"][0]["content"] == [{"type": "text", "text": "round-trip output"}] + json.dumps(result) + + def test_prepare_messages_for_anthropic_with_system( mock_anthropic_client: MagicMock, ) -> None: From 4d3ad2fc1eab59a9097127f70b6ef66f56d2ab84 Mon Sep 17 00:00:00 2001 From: Kip Date: Tue, 3 Nov 2026 21:15:06 +0800 Subject: [PATCH 2/2] Anthropic: preserve MCP tool result is_error on replay Persist the provider's is_error flag in Anthropic metadata so MCP tool results replay with the same error status after serialization. Tighten the replay output contract to a concrete union and reject unsupported shapes instead of stringifying them. Add streaming, non-streaming, persistence, and unsupported-output regression tests. Co-Authored-By: Claude Opus 5 --- .../agent_framework_anthropic/_chat_client.py | 44 ++++-- .../anthropic/tests/test_anthropic_client.py | 125 ++++++++++++++++++ 2 files changed, 160 insertions(+), 9 deletions(-) diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 6bd75e1b49..1a01392ada 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -8,7 +8,7 @@ import sys from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence from dataclasses import dataclass, field -from typing import Any, ClassVar, Final, Generic, Literal, TypedDict, cast +from typing import Any, ClassVar, Final, Generic, Literal, TypeAlias, TypedDict, cast from agent_framework import ( Annotation, @@ -31,6 +31,7 @@ add_usage_details, get_namespaced_value, tool, + with_namespaced_value, ) from agent_framework._settings import SecretString, load_settings from agent_framework._telemetry import get_user_agent, mark_feature_used @@ -98,6 +99,7 @@ UNSUPPORTED_SAMPLING_OPTIONS: Final[tuple[str, ...]] = ("temperature", "top_p", "top_k") _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE: Final[str] = "anthropic" _SERVER_TOOL_BLOCK_KEY: Final[str] = "server_tool_block" +_MCP_TOOL_RESULT_IS_ERROR_KEY: Final[str] = "mcp_tool_result_is_error" _SERVER_TOOL_USE_ID_PREFIX: Final[str] = "srvtoolu_" _SERVER_TOOL_RESULT_TYPES: Final[frozenset[str]] = frozenset({ "web_fetch_tool_result", @@ -109,6 +111,8 @@ "text_reasoning", }) +McpToolResultOutput: TypeAlias = str | Mapping[str, Any] | Sequence[Content | Mapping[str, Any]] | None + ResponseModelT = TypeVar("ResponseModelT", bound=BaseModel | None, default=None) AnthropicAsyncClient = AsyncAnthropic | AsyncAnthropicBedrock | AsyncAnthropicFoundry | AsyncAnthropicVertex @@ -1193,7 +1197,9 @@ def _prepare_server_tool_result_for_anthropic(self, content: Content) -> dict[st return result @staticmethod - def _prepare_mcp_tool_result_content(output: Any) -> str | list[dict[str, Any]]: + def _prepare_mcp_tool_result_content( + output: McpToolResultOutput, + ) -> str | list[dict[str, Any]]: """Render MCP tool result output as Anthropic ``mcp_tool_result`` content. Anthropic accepts either a string or a list of text blocks here. Parsed MCP @@ -1206,16 +1212,16 @@ def _prepare_mcp_tool_result_content(output: Any) -> str | list[dict[str, Any]]: if isinstance(output, str): return output if isinstance(output, Mapping): - mapping_output = cast("Mapping[str, Any]", output) + mapping_output = output if mapping_output.get("type") == "text" and isinstance(mapping_output.get("text"), str): return [{"type": "text", "text": mapping_output["text"]}] try: - return json.dumps(dict(mapping_output), default=str) - except (TypeError, ValueError): - return str(mapping_output) + return json.dumps(dict(mapping_output)) + except (TypeError, ValueError) as exc: + raise ContentError("MCP tool result mapping is not JSON serializable.") from exc if isinstance(output, Sequence) and not isinstance(output, (bytes, bytearray)): text_blocks: list[dict[str, Any]] = [] - for item in cast("Sequence[Any]", output): + for item in output: if isinstance(item, Content): if item.type == "text": text_blocks.append({"type": "text", "text": item.text or ""}) @@ -1226,7 +1232,7 @@ def _prepare_mcp_tool_result_content(output: Any) -> str | list[dict[str, Any]]: ) continue if isinstance(item, Mapping): - mapping_item = cast("Mapping[str, Any]", item) + mapping_item = item text = mapping_item.get("text") if mapping_item.get("type") == "text" and isinstance(text, str): text_blocks.append({"type": "text", "text": text}) @@ -1241,7 +1247,8 @@ def _prepare_mcp_tool_result_content(output: Any) -> str | list[dict[str, Any]]: type(item).__name__, ) return text_blocks or "" - return str(output) + + raise ContentError(f"Unsupported MCP tool result output type for Anthropic replay: {type(output).__name__}") def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: """Prepare a Message for the Anthropic client. @@ -1347,6 +1354,13 @@ def _prepare_message_for_anthropic(self, message: Message) -> dict[str, Any]: "tool_use_id": content.call_id, "content": self._prepare_mcp_tool_result_content(content.output), } + is_error = get_namespaced_value( + content.additional_properties, + _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE, + _MCP_TOOL_RESULT_IS_ERROR_KEY, + ) + if isinstance(is_error, bool): + mcp_result["is_error"] = is_error a_content.append(mcp_result) case "text_reasoning": if content.text is None: @@ -1826,10 +1840,22 @@ def _parse_contents_from_anthropic( request_state, streaming=streaming, ) + is_error = getattr(content_block, "is_error", None) + additional_properties = ( + with_namespaced_value( + None, + _ANTHROPIC_ADDITIONAL_PROPS_NAMESPACE, + _MCP_TOOL_RESULT_IS_ERROR_KEY, + is_error, + ) + if isinstance(is_error, bool) + else None + ) contents.append( Content.from_mcp_server_tool_result( call_id=content_block.tool_use_id, output=parsed_output, + additional_properties=additional_properties, raw_representation=content_block, ) ) diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index a1315ae1c8..a4cbff8f5f 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -1036,6 +1036,131 @@ def test_prepare_message_for_anthropic_mcp_server_tool_result_after_round_trip( json.dumps(result) +def _mcp_tool_result_block(*, is_error: bool | None, text: str = "Result text") -> MagicMock: + text_block = MagicMock() + text_block.type = "text" + text_block.text = text + + block = MagicMock() + block.type = "mcp_tool_result" + block.tool_use_id = "call_123" + block.content = [text_block] + block.is_error = is_error + return block + + +def test_parse_then_replay_anthropic_mcp_tool_result_preserves_is_error( + mock_anthropic_client: MagicMock, +) -> None: + """The provider error status must survive parsing, persistence, and replay.""" + client = create_test_anthropic_client(mock_anthropic_client) + block = _mcp_tool_result_block(is_error=True, text="failed result") + + parsed = client._parse_contents_from_anthropic([block]) + restored = Message.from_dict(Message(role="tool", contents=parsed).to_dict()) + result = client._prepare_message_for_anthropic(restored) + + assert result["content"][0]["content"] == [{"type": "text", "text": "failed result"}] + assert result["content"][0]["is_error"] is True + + +def test_process_stream_event_anthropic_mcp_tool_result_preserves_is_error( + mock_anthropic_client: MagicMock, +) -> None: + """Streaming MCP results must keep is_error through the real stream-event path.""" + client = create_test_anthropic_client(mock_anthropic_client) + block = _mcp_tool_result_block(is_error=True, text="streamed failure") + + event = MagicMock() + event.type = "content_block_start" + event.content_block = block + + update = client._process_stream_event(event) + assert update is not None + + restored = Message.from_dict(Message(role="tool", contents=update.contents).to_dict()) + result = client._prepare_message_for_anthropic(restored) + + assert result["content"][0]["content"] == [{"type": "text", "text": "streamed failure"}] + assert result["content"][0]["is_error"] is True + + +def test_parse_then_replay_anthropic_mcp_tool_result_preserves_false_after_persistence( + mock_anthropic_client: MagicMock, +) -> None: + """A false MCP error status must survive message persistence and replay.""" + client = create_test_anthropic_client(mock_anthropic_client) + parsed = client._parse_contents_from_anthropic([_mcp_tool_result_block(is_error=False)]) + restored = Message.from_dict(Message(role="tool", contents=parsed).to_dict()) + + result = client._prepare_message_for_anthropic(restored) + + assert result["content"][0]["is_error"] is False + + +def test_parse_then_replay_anthropic_mcp_tool_result_without_is_error( + mock_anthropic_client: MagicMock, +) -> None: + """An absent provider error field must remain absent in the replay payload.""" + client = create_test_anthropic_client(mock_anthropic_client) + text_block = MagicMock() + text_block.type = "text" + text_block.text = "result" + + block = MagicMock() + block.type = "mcp_tool_result" + block.tool_use_id = "call_without_error_flag" + block.content = [text_block] + # Deliberately do not set is_error; the parser must omit the metadata. + + parsed = client._parse_contents_from_anthropic([block]) + result = client._prepare_message_for_anthropic(Message(role="tool", contents=parsed)) + + assert "is_error" not in result["content"][0] + + +@pytest.mark.parametrize(("is_error", "expected"), [(False, False), (None, None)]) +def test_prepare_message_for_anthropic_mcp_server_tool_result_is_error_optional( + mock_anthropic_client: MagicMock, + is_error: bool | None, + expected: bool | None, +) -> None: + """A false status is preserved; a missing status is omitted.""" + client = create_test_anthropic_client(mock_anthropic_client) + parsed = client._parse_contents_from_anthropic([_mcp_tool_result_block(is_error=is_error)]) + + result = client._prepare_message_for_anthropic(Message(role="tool", contents=parsed)) + + if expected is None: + assert "is_error" not in result["content"][0] + else: + assert result["content"][0]["is_error"] is expected + + +@pytest.mark.parametrize( + "output", + [ + object(), + b"binary", + Content.from_text("single content"), + MappingProxyType({"value": object()}), + ], +) +def test_prepare_message_for_anthropic_rejects_unsupported_mcp_tool_result_output( + mock_anthropic_client: MagicMock, + output: Any, +) -> None: + """Unsupported MCP output shapes fail explicitly instead of being stringified.""" + client = create_test_anthropic_client(mock_anthropic_client) + message = Message( + role="tool", + contents=[Content.from_mcp_server_tool_result(call_id="mcp_call", output=output)], + ) + + with pytest.raises(ContentError): + client._prepare_message_for_anthropic(message) + + def test_prepare_messages_for_anthropic_with_system( mock_anthropic_client: MagicMock, ) -> None: