mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
49d69b3bf5
commit
c012aac5f2
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user