diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 5db805c699..1a01392ada 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -3,11 +3,12 @@ from __future__ import annotations import asyncio +import json import logging 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, @@ -30,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 @@ -97,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", @@ -108,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 @@ -1191,6 +1196,60 @@ 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: 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 + 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 = 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)) + 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 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 = 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 "" + + 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. @@ -1293,8 +1352,15 @@ 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), } + 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: @@ -1510,9 +1576,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( @@ -1776,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 15e6654bfc..a4cbff8f5f 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,251 @@ 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 _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: