Python: [BREAKING] Reduce core dependencies and simplify optional integrations (#4904)

* improved dependencies and some fixes

* fix for mypy

* improve mcp
This commit is contained in:
Eduard van Valkenburg
2026-03-25 19:03:43 +01:00
committed by GitHub
Unverified
parent 49d69b3bf5
commit c012aac5f2
14 changed files with 985 additions and 440 deletions
+1 -1
View File
@@ -67,7 +67,7 @@ def span_exporter(monkeypatch, enable_instrumentation: bool, enable_sensitive_da
if enable_instrumentation or enable_sensitive_data:
from opentelemetry.sdk.trace import TracerProvider
tracer_provider = TracerProvider(resource=observability_settings._resource)
tracer_provider = TracerProvider(resource=observability.create_resource())
trace.set_tracer_provider(tracer_provider)
monkeypatch.setattr(observability, "OBSERVABILITY_SETTINGS", observability_settings, raising=False) # type: ignore
+336 -37
View File
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
# type: ignore[reportPrivateUsage]
import json
import logging
import os
from contextlib import _AsyncGeneratorContextManager # type: ignore
@@ -23,13 +24,9 @@ from agent_framework import (
)
from agent_framework._mcp import (
MCPTool,
_build_prefixed_mcp_name,
_get_input_model_from_mcp_prompt,
_normalize_mcp_name,
_parse_content_from_mcp,
_parse_message_from_mcp,
_parse_tool_result_from_mcp,
_prepare_content_for_mcp,
_prepare_message_for_mcp,
logger,
)
from agent_framework._middleware import FunctionMiddlewarePipeline
@@ -50,6 +47,9 @@ def _mcp_result_to_text(result: str | list[Content]) -> str:
return text or str(result)
_HELPER_MCP_TOOL = MCPTool(name="helper")
# Helper function tests
def test_normalize_mcp_name():
"""Test MCP name normalization."""
@@ -61,6 +61,10 @@ def test_normalize_mcp_name():
assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes"
def test_build_prefixed_mcp_name_ignores_empty_normalized_prefix() -> None:
assert _build_prefixed_mcp_name("search", "---") == "search"
def test_mcp_transport_subclasses_accept_tool_name_prefix() -> None:
assert MCPStdioTool(name="stdio", command="python", tool_name_prefix="stdio").tool_name_prefix == "stdio"
assert (
@@ -139,7 +143,7 @@ async def test_load_prompts_with_tool_name_prefix() -> None:
def test_mcp_prompt_message_to_ai_content():
"""Test conversion from MCP prompt message to AI content."""
mcp_message = types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello, world!"))
ai_content = _parse_message_from_mcp(mcp_message)
ai_content = _HELPER_MCP_TOOL._parse_message_from_mcp(mcp_message)
assert isinstance(ai_content, Message)
assert ai_content.role == "user"
@@ -149,6 +153,55 @@ def test_mcp_prompt_message_to_ai_content():
assert ai_content.raw_representation == mcp_message
def test_mcp_tool_str_and_parse_prompt_result_rich_content() -> None:
tool = MCPTool(name="helper", description="Helper MCP tool")
prompt_result = types.GetPromptResult(
messages=[
types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hello")),
types.PromptMessage(
role="assistant",
content=types.ImageContent(type="image", data="eHl6", mimeType="image/png"),
),
types.PromptMessage(
role="assistant",
content=types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"),
),
types.PromptMessage(
role="assistant",
content=types.EmbeddedResource(
type="resource",
resource=types.TextResourceContents(
uri=AnyUrl("file://prompt.txt"),
mimeType="text/plain",
text="Embedded prompt",
),
),
),
types.PromptMessage(
role="assistant",
content=types.EmbeddedResource(
type="resource",
resource=types.BlobResourceContents(
uri=AnyUrl("file://prompt.bin"),
mimeType="application/pdf",
blob="ZGF0YQ==",
),
),
),
]
)
result = tool._parse_prompt_result_from_mcp(prompt_result)
parsed = json.loads(result)
assert str(tool) == "MCPTool(name=helper, description=Helper MCP tool)"
assert parsed[0] == "Hello"
assert json.loads(parsed[1]) == {"type": "image", "data": "eHl6", "mimeType": "image/png"}
assert json.loads(parsed[2]) == {"type": "audio", "data": "YXVkaW8=", "mimeType": "audio/wav"}
assert parsed[3] == "Embedded prompt"
assert json.loads(parsed[4]) == {"type": "blob", "data": "ZGF0YQ==", "mimeType": "application/pdf"}
def test_parse_tool_result_from_mcp():
"""Test conversion from MCP tool result with images preserves original order."""
mcp_result = types.CallToolResult(
@@ -159,7 +212,7 @@ def test_parse_tool_result_from_mcp():
types.ImageContent(type="image", data="YWJj", mimeType="image/webp"),
]
)
result = _parse_tool_result_from_mcp(mcp_result)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
# Results with images return a list of Content objects in original order
assert isinstance(result, list)
@@ -180,7 +233,7 @@ def test_parse_tool_result_from_mcp():
def test_parse_tool_result_from_mcp_single_text():
"""Test conversion from MCP tool result with a single text item."""
mcp_result = types.CallToolResult(content=[types.TextContent(type="text", text="Simple result")])
result = _parse_tool_result_from_mcp(mcp_result)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
# Single text item returns list with one text Content
assert isinstance(result, list)
@@ -196,7 +249,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string():
_meta={"isError": True, "errorCode": "TOOL_ERROR"},
)
result = _parse_tool_result_from_mcp(mcp_result)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
assert result[0].text == "Error occurred"
@@ -205,7 +258,7 @@ def test_parse_tool_result_from_mcp_meta_not_in_string():
def test_parse_tool_result_from_mcp_empty_content():
"""Test that empty MCP content normalizes to JSON null text content."""
mcp_result = types.CallToolResult(content=[])
result = _parse_tool_result_from_mcp(mcp_result)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
assert result[0].type == "text"
@@ -222,7 +275,7 @@ def test_parse_tool_result_from_mcp_audio_content():
types.AudioContent(type="audio", data="YXVkaW8=", mimeType="audio/wav"),
]
)
result = _parse_tool_result_from_mcp(mcp_result)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
@@ -245,7 +298,7 @@ def test_parse_tool_result_from_mcp_blob_plain_base64():
),
]
)
result = _parse_tool_result_from_mcp(mcp_result)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert isinstance(result, list)
assert len(result) == 1
@@ -254,10 +307,39 @@ def test_parse_tool_result_from_mcp_blob_plain_base64():
assert "dGVzdCBkYXRh" in result[0].uri
def test_parse_tool_result_from_mcp_resource_link_text_resource_and_unknown():
"""Test additional MCP tool result variants."""
mcp_result = types.CallToolResult(
content=[
types.ResourceLink(
type="resource_link",
uri=AnyUrl("https://example.com/resource"),
name="resource",
mimeType="application/json",
),
types.EmbeddedResource(
type="resource",
resource=types.TextResourceContents(
uri=AnyUrl("file://prompt.txt"),
mimeType="text/plain",
text="Embedded result",
),
),
]
)
result = _HELPER_MCP_TOOL._parse_tool_result_from_mcp(mcp_result)
assert result[0].type == "uri"
assert result[0].uri == "https://example.com/resource"
assert result[1].type == "text"
assert result[1].text == "Embedded result"
def test_mcp_content_types_to_ai_content_text():
"""Test conversion of MCP text content to AI content."""
mcp_content = types.TextContent(type="text", text="Sample text")
ai_content = _parse_content_from_mcp(mcp_content)[0]
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
assert ai_content.type == "text"
assert ai_content.text == "Sample text"
@@ -268,7 +350,7 @@ def test_mcp_content_types_to_ai_content_image():
"""Test conversion of MCP image content to AI content."""
# MCP can send data as base64 string or as bytes
mcp_content = types.ImageContent(type="image", data="YWJj", mimeType="image/jpeg") # base64 for b"abc"
ai_content = _parse_content_from_mcp(mcp_content)[0]
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
assert ai_content.type == "data"
assert ai_content.uri == "data:image/jpeg;base64,YWJj"
@@ -280,7 +362,7 @@ def test_mcp_content_types_to_ai_content_audio():
"""Test conversion of MCP audio content to AI content."""
# Use properly padded base64
mcp_content = types.AudioContent(type="audio", data="ZGVm", mimeType="audio/wav") # base64 for b"def"
ai_content = _parse_content_from_mcp(mcp_content)[0]
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
assert ai_content.type == "data"
assert ai_content.uri == "data:audio/wav;base64,ZGVm"
@@ -296,7 +378,7 @@ def test_mcp_content_types_to_ai_content_resource_link():
name="test_resource",
mimeType="application/json",
)
ai_content = _parse_content_from_mcp(mcp_content)[0]
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
assert ai_content.type == "uri"
assert ai_content.uri == "https://example.com/resource"
@@ -312,7 +394,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_text():
text="Embedded text content",
)
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _parse_content_from_mcp(mcp_content)[0]
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
assert ai_content.type == "text"
assert ai_content.text == "Embedded text content"
@@ -328,7 +410,7 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
blob="data:application/octet-stream;base64,dGVzdCBkYXRh",
)
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
ai_content = _parse_content_from_mcp(mcp_content)[0]
ai_content = _HELPER_MCP_TOOL._parse_content_from_mcp(mcp_content)[0]
assert ai_content.type == "data"
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
@@ -336,10 +418,33 @@ def test_mcp_content_types_to_ai_content_embedded_resource_blob():
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_tool_use_and_tool_result():
"""Test conversion of MCP tool use/result content to AI function call/result content."""
tool_use_content = types.ToolUseContent(type="tool_use", id="call-1", name="calculator", input={"x": 1})
tool_result_content = types.ToolResultContent(
type="tool_result",
toolUseId="call-1",
content=[types.TextContent(type="text", text="done")],
isError=True,
)
function_call = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_use_content)[0]
function_result = _HELPER_MCP_TOOL._parse_content_from_mcp(tool_result_content)[0]
assert function_call.type == "function_call"
assert function_call.call_id == "call-1"
assert function_call.name == "calculator"
assert function_call.arguments == {"x": 1}
assert function_result.type == "function_result"
assert function_result.call_id == "call-1"
assert function_result.result == "done"
assert function_result.exception == ""
def test_ai_content_to_mcp_content_types_text():
"""Test conversion of AI text content to MCP content."""
ai_content = Content.from_text(text="Sample text")
mcp_content = _prepare_content_for_mcp(ai_content)
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.TextContent)
assert mcp_content.type == "text"
@@ -349,7 +454,7 @@ def test_ai_content_to_mcp_content_types_text():
def test_ai_content_to_mcp_content_types_data_image():
"""Test conversion of AI data content to MCP content."""
ai_content = Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png")
mcp_content = _prepare_content_for_mcp(ai_content)
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.ImageContent)
assert mcp_content.type == "image"
@@ -360,7 +465,7 @@ def test_ai_content_to_mcp_content_types_data_image():
def test_ai_content_to_mcp_content_types_data_audio():
"""Test conversion of AI data content to MCP content."""
ai_content = Content.from_uri(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
mcp_content = _prepare_content_for_mcp(ai_content)
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.AudioContent)
assert mcp_content.type == "audio"
@@ -374,7 +479,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
uri="data:application/octet-stream;base64,xyz",
media_type="application/octet-stream",
)
mcp_content = _prepare_content_for_mcp(ai_content)
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.EmbeddedResource)
assert mcp_content.type == "resource"
@@ -385,7 +490,7 @@ def test_ai_content_to_mcp_content_types_data_binary():
def test_ai_content_to_mcp_content_types_uri():
"""Test conversion of AI URI content to MCP content."""
ai_content = Content.from_uri(uri="https://example.com/resource", media_type="application/json")
mcp_content = _prepare_content_for_mcp(ai_content)
mcp_content = _HELPER_MCP_TOOL._prepare_content_for_mcp(ai_content)
assert isinstance(mcp_content, types.ResourceLink)
assert mcp_content.type == "resource_link"
@@ -401,12 +506,24 @@ def test_prepare_message_for_mcp():
Content.from_uri(uri="data:image/png;base64,xyz", media_type="image/png"),
],
)
mcp_contents = _prepare_message_for_mcp(message)
mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp(message)
assert len(mcp_contents) == 2
assert isinstance(mcp_contents[0], types.TextContent)
assert isinstance(mcp_contents[1], types.ImageContent)
def test_prepare_message_for_mcp_skips_unsupported_content() -> None:
unsupported = Content(type="annotations", text="ignored")
assert _HELPER_MCP_TOOL._prepare_content_for_mcp(unsupported) is None
mcp_contents = _HELPER_MCP_TOOL._prepare_message_for_mcp(
Message(role="user", contents=[Content.from_text("kept"), unsupported])
)
assert len(mcp_contents) == 1
assert isinstance(mcp_contents[0], types.TextContent)
@pytest.mark.parametrize(
"test_id,input_schema",
[
@@ -1287,6 +1404,18 @@ async def test_mcp_tool_approval_mode(approval_mode, expected_approvals):
assert func.approval_mode == expected_approvals[func.name]
def test_mcp_tool_approval_mode_returns_none_for_unmatched_names() -> None:
tool = MCPTool(
name="test_tool",
approval_mode={
"always_require_approval": ["tool_one"],
"never_require_approval": ["tool_two"],
},
)
assert tool._determine_approval_mode("tool_three") is None
@pytest.mark.parametrize(
"allowed_tools,expected_count,expected_names",
[
@@ -1618,6 +1747,46 @@ async def test_mcp_tool_sampling_callback_no_valid_content():
assert "Failed to get right content types from the response." in result.message
async def test_mcp_tool_sampling_callback_no_response_and_successful_message_creation():
"""Test sampling callback when the chat client returns no response and then valid content."""
tool = MCPStdioTool(name="test_tool", command="python")
tool.client = AsyncMock()
params = Mock()
params.messages = [types.PromptMessage(role="user", content=types.TextContent(type="text", text="Hi"))]
params.temperature = None
params.maxTokens = None
params.stopSequences = None
tool.client.get_response.return_value = None
no_response = await tool.sampling_callback(Mock(), params)
assert isinstance(no_response, types.ErrorData)
assert no_response.message == "Failed to get chat message content."
tool.client.get_response.return_value = Mock(
messages=[Message(role="assistant", contents=[Content.from_text("Hello")])],
model_id="test-model",
)
success = await tool.sampling_callback(Mock(), params)
assert isinstance(success, types.CreateMessageResult)
assert success.role == "assistant"
assert success.model == "test-model"
assert isinstance(success.content, types.TextContent)
assert success.content.text == "Hello"
async def test_mcp_tool_logging_callback_logs_at_requested_level() -> None:
tool = MCPStdioTool(name="test_tool", command="python")
with patch.object(logger, "log") as mock_log:
await tool.logging_callback(types.LoggingMessageNotificationParams(level="warning", data="be careful"))
mock_log.assert_called_once_with(logging.WARNING, "be careful")
# Test error handling in connect() method
@@ -1633,7 +1802,7 @@ async def test_connect_session_creation_failure():
tool.get_mcp_client = Mock(return_value=mock_context_manager)
# Mock ClientSession to raise an exception
with patch("agent_framework._mcp.ClientSession") as mock_session_class:
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.side_effect = RuntimeError("Session creation failed")
with pytest.raises(ToolException) as exc_info:
@@ -1658,7 +1827,7 @@ async def test_connect_initialization_failure_http_no_command():
mock_session = Mock()
mock_session.initialize = AsyncMock(side_effect=ConnectionError("Server not ready"))
with patch("agent_framework._mcp.ClientSession") as mock_session_class:
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
@@ -1687,6 +1856,18 @@ async def test_connect_cleanup_on_transport_failure():
tool._exit_stack.aclose.assert_called_once()
async def test_connect_cleanup_on_transport_failure_http_uses_generic_message():
"""Test HTTP transport failures use the generic connection message when no command exists."""
tool = MCPStreamableHTTPTool(name="test", url="https://example.com/mcp")
tool._exit_stack.aclose = AsyncMock()
tool.get_mcp_client = Mock(side_effect=RuntimeError("Transport failed"))
with pytest.raises(ToolException, match="Failed to connect to MCP server: Transport failed"):
await tool.connect()
tool._exit_stack.aclose.assert_called_once()
async def test_connect_cleanup_on_initialization_failure():
"""Test that _exit_stack.aclose() is called when initialization fails."""
tool = MCPStdioTool(name="test", command="test-command")
@@ -1705,7 +1886,7 @@ async def test_connect_cleanup_on_initialization_failure():
mock_session = Mock()
mock_session.initialize = AsyncMock(side_effect=RuntimeError("Init failed"))
with patch("agent_framework._mcp.ClientSession") as mock_session_class:
with patch("mcp.client.session.ClientSession") as mock_session_class:
mock_session_class.return_value.__aenter__ = AsyncMock(return_value=mock_session)
mock_session_class.return_value.__aexit__ = AsyncMock(return_value=None)
@@ -1722,18 +1903,20 @@ def test_mcp_stdio_tool_get_mcp_client_with_env_and_kwargs():
tool = MCPStdioTool(
name="test",
command="test-command",
encoding="utf-16",
env=env_vars,
custom_param="value1",
another_param=42,
)
with patch("agent_framework._mcp.stdio_client"), patch("agent_framework._mcp.StdioServerParameters") as mock_params:
with patch("mcp.client.stdio.stdio_client"), patch("mcp.client.stdio.StdioServerParameters") as mock_params:
tool.get_mcp_client()
# Verify all parameters including custom kwargs were passed
mock_params.assert_called_once_with(
command="test-command",
args=[],
encoding="utf-16",
env=env_vars,
custom_param="value1",
another_param=42,
@@ -1748,7 +1931,7 @@ def test_mcp_streamable_http_tool_get_mcp_client_all_params():
terminate_on_close=True,
)
with patch("agent_framework._mcp.streamable_http_client") as mock_http_client:
with patch("mcp.client.streamable_http.streamable_http_client") as mock_http_client:
tool.get_mcp_client()
# Verify streamable_http_client was called with None for http_client
@@ -1770,7 +1953,7 @@ def test_mcp_websocket_tool_get_mcp_client_with_kwargs():
compression="deflate",
)
with patch("agent_framework._mcp.websocket_client") as mock_ws_client:
with patch("mcp.client.websocket.websocket_client") as mock_ws_client:
tool.get_mcp_client()
# Verify all kwargs were passed
@@ -1928,8 +2111,8 @@ async def test_mcp_streamable_http_tool_httpx_client_cleanup():
# Mock the streamable_http_client to avoid actual connections
with (
patch("agent_framework._mcp.streamable_http_client") as mock_client,
patch("agent_framework._mcp.ClientSession") as mock_session_class,
patch("mcp.client.streamable_http.streamable_http_client") as mock_client,
patch("mcp.client.session.ClientSession") as mock_session_class,
):
# Setup mock context manager for streamable_http_client
mock_transport = (Mock(), Mock())
@@ -2624,6 +2807,80 @@ async def test_mcp_tool_get_prompt_reconnection_on_closed_resource_error():
assert "failed to reconnect" in str(exc_info.value).lower()
async def test_mcp_tool_call_tool_requires_loaded_tools() -> None:
tool = MCPTool(name="test_tool", load_tools=False)
with pytest.raises(ToolExecutionException, match="Tools are not loaded"):
await tool.call_tool("remote_tool")
async def test_mcp_tool_get_prompt_requires_loaded_prompts() -> None:
tool = MCPTool(name="test_tool", load_prompts=False)
with pytest.raises(ToolExecutionException, match="Prompts are not loaded"):
await tool.get_prompt("remote_prompt")
async def test_mcp_tool_call_tool_raises_after_reconnection_still_fails() -> None:
from anyio.streams.memory import ClosedResourceError
tool = MCPTool(name="test_tool", load_tools=True)
tool.session = Mock(call_tool=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()]))
with (
patch.object(tool, "connect", AsyncMock()) as mock_connect,
patch.object(logger, "error") as mock_error,
pytest.raises(ToolExecutionException, match="connection lost"),
):
await tool.call_tool("remote_tool")
mock_connect.assert_awaited_once_with(reset=True)
mock_error.assert_called_once()
async def test_mcp_tool_get_prompt_raises_after_reconnection_still_fails() -> None:
from anyio.streams.memory import ClosedResourceError
tool = MCPTool(name="test_tool", load_prompts=True)
tool.session = Mock(get_prompt=AsyncMock(side_effect=[ClosedResourceError(), ClosedResourceError()]))
with (
patch.object(tool, "connect", AsyncMock()) as mock_connect,
patch.object(logger, "error") as mock_error,
pytest.raises(ToolExecutionException, match="connection lost"),
):
await tool.get_prompt("remote_prompt")
mock_connect.assert_awaited_once_with(reset=True)
mock_error.assert_called_once()
async def test_mcp_tool_wraps_unexpected_call_tool_and_get_prompt_errors() -> None:
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
tool.session = Mock()
tool.session.call_tool = AsyncMock(side_effect=RuntimeError("tool boom"))
tool.session.get_prompt = AsyncMock(side_effect=RuntimeError("prompt boom"))
with pytest.raises(ToolExecutionException, match="Failed to call tool 'remote_tool'"):
await tool.call_tool("remote_tool")
with pytest.raises(ToolExecutionException, match="Failed to call prompt 'remote_prompt'"):
await tool.get_prompt("remote_prompt")
async def test_mcp_tool_aenter_wraps_unexpected_errors_and_closes() -> None:
tool = MCPStdioTool(name="test_tool", command="python")
with (
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("boom"))),
patch.object(tool, "close", AsyncMock()) as mock_close,
pytest.raises(ToolExecutionException, match="Failed to enter context manager"),
):
await tool.__aenter__()
mock_close.assert_awaited_once()
async def test_mcp_tool_close_cleans_up_in_original_task(caplog):
"""Closing an MCP tool from another task should still unwind contexts in the owner task."""
import asyncio
@@ -2663,7 +2920,7 @@ async def test_mcp_tool_close_cleans_up_in_original_task(caplog):
with (
patch.object(tool, "get_mcp_client", return_value=transport_context),
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
):
await asyncio.create_task(tool.connect())
@@ -2721,7 +2978,7 @@ async def test_mcp_tool_connect_reset_cleans_up_in_original_task(caplog):
with (
patch.object(tool, "get_mcp_client", side_effect=transport_contexts),
patch("agent_framework._mcp.ClientSession", side_effect=session_contexts),
patch("mcp.client.session.ClientSession", side_effect=session_contexts),
):
await tool.connect()
@@ -2905,7 +3162,7 @@ async def test_connect_sets_logging_level_when_logger_level_is_set():
with (
patch.object(tool, "get_mcp_client", return_value=mock_context),
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
patch.object(logger, "level", logging.DEBUG), # Set logger level to DEBUG
):
await tool.connect()
@@ -2942,7 +3199,7 @@ async def test_connect_does_not_set_logging_level_when_logger_level_is_notset():
with (
patch.object(tool, "get_mcp_client", return_value=mock_context),
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
patch.object(logger, "level", logging.NOTSET), # Set logger level to NOTSET
):
await tool.connect()
@@ -2980,7 +3237,7 @@ async def test_connect_handles_set_logging_level_exception():
with (
patch.object(tool, "get_mcp_client", return_value=mock_context),
patch("agent_framework._mcp.ClientSession", return_value=mock_session_context),
patch("mcp.client.session.ClientSession", return_value=mock_session_context),
patch.object(logger, "level", logging.INFO), # Set logger level to INFO
patch.object(logger, "warning") as mock_warning,
):
@@ -2996,6 +3253,48 @@ async def test_connect_handles_set_logging_level_exception():
assert "Failed to set log level" in call_args[0][0]
async def test_connect_reinitializes_existing_session_and_loads_tools_and_prompts() -> None:
tool = MCPTool(name="test_tool", load_tools=True, load_prompts=True)
tool.is_connected = True
tool.session = Mock()
tool.session._request_id = 0
tool.session.initialize = AsyncMock()
with (
patch.object(tool, "load_tools", AsyncMock()) as mock_load_tools,
patch.object(tool, "load_prompts", AsyncMock()) as mock_load_prompts,
patch.object(logger, "level", logging.NOTSET),
):
await tool._connect_on_owner()
tool.session.initialize.assert_awaited_once()
mock_load_tools.assert_awaited_once()
mock_load_prompts.assert_awaited_once()
assert tool._tools_loaded is True
assert tool._prompts_loaded is True
async def test_ensure_connected_reconnects_on_failed_ping() -> None:
tool = MCPTool(name="test_tool")
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
with patch.object(tool, "connect", AsyncMock()) as mock_connect:
await tool._ensure_connected()
mock_connect.assert_awaited_once_with(reset=True)
async def test_ensure_connected_wraps_reconnect_failure() -> None:
tool = MCPTool(name="test_tool")
tool.session = Mock(send_ping=AsyncMock(side_effect=RuntimeError("closed")))
with (
patch.object(tool, "connect", AsyncMock(side_effect=RuntimeError("still closed"))),
pytest.raises(ToolExecutionException, match="Failed to establish MCP connection"),
):
await tool._ensure_connected()
async def test_mcp_tool_filters_framework_kwargs():
"""Test that call_tool filters out framework-specific kwargs before calling MCP session.
@@ -3,7 +3,7 @@
import logging
from collections.abc import AsyncIterable, Awaitable, MutableSequence, Sequence
from typing import Any
from unittest.mock import Mock
from unittest.mock import Mock, patch
import pytest
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
@@ -1080,7 +1080,8 @@ def test_configure_otel_providers_reads_env_sensitive_data(monkeypatch):
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_SENSITIVE_DATA", "true")
observability.configure_otel_providers()
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
@@ -1135,7 +1136,8 @@ def test_configure_otel_providers_explicit_param_overrides_env(monkeypatch):
importlib.reload(observability)
# Explicit False should override the env var True
observability.configure_otel_providers(enable_sensitive_data=False)
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_sensitive_data=False)
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is False
@@ -1196,7 +1198,8 @@ def test_enable_instrumentation_does_not_clobber_console_exporters(monkeypatch):
importlib.reload(observability)
# Set console exporters via configure_otel_providers
observability.configure_otel_providers(enable_console_exporters=True)
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_console_exporters=True)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
# Calling enable_instrumentation should not clobber the value
@@ -1224,7 +1227,8 @@ def test_enable_instrumentation_with_sensitive_data_does_not_touch_console_expor
importlib.reload(observability)
# Set console exporters via configure_otel_providers
observability.configure_otel_providers(enable_console_exporters=True)
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_console_exporters=True)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
# Calling enable_instrumentation with explicit sensitive_data should not clobber console exporters
@@ -1275,7 +1279,8 @@ def test_configure_otel_providers_reads_env_console_exporters(monkeypatch):
# Simulate load_dotenv() setting env var after import
monkeypatch.setenv("ENABLE_CONSOLE_EXPORTERS", "true")
observability.configure_otel_providers()
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers()
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is True
@@ -1298,7 +1303,8 @@ def test_configure_otel_providers_explicit_console_exporters_overrides_env(monke
importlib.reload(observability)
# Explicit False should override the env var True
observability.configure_otel_providers(enable_console_exporters=False)
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(enable_console_exporters=False)
assert observability.OBSERVABILITY_SETTINGS.enable_console_exporters is False
@@ -2005,6 +2011,14 @@ async def test_agent_streaming_observability(span_exporter: InMemorySpanExporter
assert len(spans) == 1
def test_agent_middleware_wraps_agent_telemetry() -> None:
"""Agent middleware must run outside telemetry so middleware time is excluded from agent latency."""
from agent_framework import Agent
from agent_framework._middleware import AgentMiddlewareLayer
assert Agent.__mro__.index(AgentMiddlewareLayer) < Agent.__mro__.index(AgentTelemetryLayer)
# region Test AgentTelemetryLayer error cases
@@ -3049,11 +3063,12 @@ def test_configure_otel_providers_with_env_file_path(monkeypatch, tmp_path):
env_file = tmp_path / ".env"
env_file.write_text("ENABLE_INSTRUMENTATION=true\n")
observability.configure_otel_providers(
env_file_path=str(env_file),
enable_sensitive_data=True,
vs_code_extension_port=None,
)
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(
env_file_path=str(env_file),
enable_sensitive_data=True,
vs_code_extension_port=None,
)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.enable_sensitive_data is True
@@ -3078,11 +3093,12 @@ def test_configure_otel_providers_with_env_file_and_vs_code_port(monkeypatch, tm
env_file = tmp_path / ".env"
env_file.write_text("ENABLE_INSTRUMENTATION=true\n")
observability.configure_otel_providers(
env_file_path=str(env_file),
env_file_encoding="utf-8",
vs_code_extension_port=4317,
)
with patch.object(observability.OBSERVABILITY_SETTINGS, "_configure"):
observability.configure_otel_providers(
env_file_path=str(env_file),
env_file_encoding="utf-8",
vs_code_extension_port=4317,
)
assert observability.OBSERVABILITY_SETTINGS.enable_instrumentation is True
assert observability.OBSERVABILITY_SETTINGS.vs_code_extension_port == 4317
@@ -0,0 +1,181 @@
# Copyright (c) Microsoft. All rights reserved.
from __future__ import annotations
import sys
import pytest
import agent_framework
import agent_framework.observability as observability
from agent_framework import Agent
def _hide_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
real_import = builtins.__import__
for module_name in list(sys.modules):
if module_name == "opentelemetry.sdk" or module_name.startswith("opentelemetry.sdk."):
sys.modules.pop(module_name, None)
def _import_without_otel_sdk(
name: str,
globals_: dict[str, object] | None = None,
locals_: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
if name == "opentelemetry.sdk" or name.startswith("opentelemetry.sdk."):
raise ModuleNotFoundError(f"No module named '{name}'", name=name)
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _import_without_otel_sdk)
def test_create_resource_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
_hide_otel_sdk(monkeypatch)
with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"):
observability.create_resource()
def test_observability_settings_initializes_without_cached_resource(monkeypatch: pytest.MonkeyPatch) -> None:
_hide_otel_sdk(monkeypatch)
settings = observability.ObservabilitySettings()
assert not hasattr(settings, "_resource")
def test_configure_otel_providers_requires_otel_sdk(monkeypatch: pytest.MonkeyPatch) -> None:
_hide_otel_sdk(monkeypatch)
for key in [
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT",
"OTEL_EXPORTER_OTLP_METRICS_ENDPOINT",
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
"VS_CODE_EXTENSION_PORT",
]:
monkeypatch.delenv(key, raising=False)
with pytest.raises(ModuleNotFoundError, match="opentelemetry-sdk"):
observability.configure_otel_providers()
def test_agent_framework_mcp_exports_remain_importable_without_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
import agent_framework._mcp as mcp_module
real_import = builtins.__import__
def _import_without_mcp(
name: str,
globals_: dict[str, object] | None = None,
locals_: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
if name == "mcp" or name.startswith("mcp."):
raise ModuleNotFoundError("No module named 'mcp'")
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
assert agent_framework.MCPStdioTool is mcp_module.MCPStdioTool
with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"):
agent_framework.MCPStdioTool(name="test", command="python").get_mcp_client()
def test_mcp_streamable_http_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
real_import = builtins.__import__
def _import_without_mcp(
name: str,
globals_: dict[str, object] | None = None,
locals_: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
if name == "mcp" or name.startswith("mcp."):
raise ModuleNotFoundError("No module named 'mcp'")
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"):
agent_framework.MCPStreamableHTTPTool(name="test", url="https://example.com").get_mcp_client()
def test_agent_as_mcp_server_requires_mcp(client, monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
real_import = builtins.__import__
def _import_without_mcp(
name: str,
globals_: dict[str, object] | None = None,
locals_: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
if name == "mcp" or name.startswith("mcp."):
raise ModuleNotFoundError("No module named 'mcp'")
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
agent = Agent(client=client)
with pytest.raises(ModuleNotFoundError, match=r"Please install `mcp`\.$"):
agent.as_mcp_server()
def test_mcp_websocket_tool_requires_ws_support(monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
real_import = builtins.__import__
sys.modules.pop("mcp.client.websocket", None)
def _import_without_websocket_support(
name: str,
globals_: dict[str, object] | None = None,
locals_: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
if name == "mcp.client.websocket":
raise ModuleNotFoundError("No module named 'websockets'", name="websockets")
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _import_without_websocket_support)
with pytest.raises(ModuleNotFoundError, match=r"mcp\[ws\]"):
agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client()
def test_mcp_websocket_tool_requires_mcp(monkeypatch: pytest.MonkeyPatch) -> None:
import builtins
real_import = builtins.__import__
sys.modules.pop("mcp.client.websocket", None)
def _import_without_mcp(
name: str,
globals_: dict[str, object] | None = None,
locals_: dict[str, object] | None = None,
fromlist: tuple[str, ...] = (),
level: int = 0,
) -> object:
if name == "mcp.client.websocket":
raise ModuleNotFoundError("No module named 'mcp.client.websocket'", name="mcp.client.websocket")
return real_import(name, globals_, locals_, fromlist, level)
monkeypatch.setattr(builtins, "__import__", _import_without_mcp)
with pytest.raises(ModuleNotFoundError, match=r"agent-framework-core\[mcp\]|mcp\[ws\]"):
agent_framework.MCPWebsocketTool(name="test", url="wss://example.com").get_mcp_client()