Files
agent-framework/python/packages/main/tests/main/test_mcp.py
T
Eduard van Valkenburg 40ab6e9d67 Python: name changes executed (#607)
* name changes executed

* updated adr to accepted

* renamed openai base config

* renamed openai config to mixin

* added renames in user docs

* reverted mcperror

* fix tests

* remove sse from tests
2025-09-04 15:00:38 +00:00

537 lines
20 KiB
Python

# Copyright (c) Microsoft. All rights reserved.
# type: ignore[reportPrivateUsage]
import os
from contextlib import _AsyncGeneratorContextManager # type: ignore
from typing import Any
from unittest.mock import AsyncMock, Mock
import pytest
from mcp import types
from mcp.client.session import ClientSession
from mcp.shared.exceptions import McpError
from pydantic import AnyUrl, ValidationError
from agent_framework import (
ChatMessage,
DataContent,
MCPStdioTool,
MCPStreamableHTTPTool,
MCPWebsocketTool,
Role,
TextContent,
ToolProtocol,
UriContent,
)
from agent_framework._mcp import (
MCPTool,
_ai_content_to_mcp_types,
_chat_message_to_mcp_types,
_get_input_model_from_mcp_prompt,
_get_input_model_from_mcp_tool,
_mcp_call_tool_result_to_ai_contents,
_mcp_prompt_message_to_chat_message,
_mcp_type_to_ai_content,
_normalize_mcp_name,
)
from agent_framework.exceptions import ToolExecutionException
# Integration test skip condition
skip_if_mcp_integration_tests_disabled = pytest.mark.skipif(
os.getenv("RUN_INTEGRATION_TESTS", "false").lower() != "true" or os.getenv("LOCAL_MCP_URL", "") == "",
reason="No LOCAL_MCP_URL provided; skipping integration tests."
if os.getenv("RUN_INTEGRATION_TESTS", "false").lower() == "true"
else "Integration tests are disabled.",
)
# Helper function tests
def test_normalize_mcp_name():
"""Test MCP name normalization."""
assert _normalize_mcp_name("valid_name") == "valid_name"
assert _normalize_mcp_name("name-with-dashes") == "name-with-dashes"
assert _normalize_mcp_name("name.with.dots") == "name.with.dots"
assert _normalize_mcp_name("name with spaces") == "name-with-spaces"
assert _normalize_mcp_name("name@with#special$chars") == "name-with-special-chars"
assert _normalize_mcp_name("name/with\\slashes") == "name-with-slashes"
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 = _mcp_prompt_message_to_chat_message(mcp_message)
assert isinstance(ai_content, ChatMessage)
assert ai_content.role.value == "user"
assert len(ai_content.contents) == 1
assert isinstance(ai_content.contents[0], TextContent)
assert ai_content.contents[0].text == "Hello, world!"
assert ai_content.raw_representation == mcp_message
def test_mcp_call_tool_result_to_ai_contents():
"""Test conversion from MCP tool result to AI contents."""
mcp_result = types.CallToolResult(
content=[
types.TextContent(type="text", text="Result text"),
types.ImageContent(type="image", data="data:image/png;base64,xyz", mimeType="image/png"),
]
)
ai_contents = _mcp_call_tool_result_to_ai_contents(mcp_result)
assert len(ai_contents) == 2
assert isinstance(ai_contents[0], TextContent)
assert ai_contents[0].text == "Result text"
assert isinstance(ai_contents[1], DataContent)
assert ai_contents[1].uri == "data:image/png;base64,xyz"
assert ai_contents[1].media_type == "image/png"
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 = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, TextContent)
assert ai_content.text == "Sample text"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_image():
"""Test conversion of MCP image content to AI content."""
mcp_content = types.ImageContent(type="image", data="data:image/jpeg;base64,abc", mimeType="image/jpeg")
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:image/jpeg;base64,abc"
assert ai_content.media_type == "image/jpeg"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_audio():
"""Test conversion of MCP audio content to AI content."""
mcp_content = types.AudioContent(type="audio", data="data:audio/wav;base64,def", mimeType="audio/wav")
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:audio/wav;base64,def"
assert ai_content.media_type == "audio/wav"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_resource_link():
"""Test conversion of MCP resource link to AI content."""
mcp_content = types.ResourceLink(
type="resource_link",
uri=AnyUrl("https://example.com/resource"),
name="test_resource",
mimeType="application/json",
)
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, UriContent)
assert ai_content.uri == "https://example.com/resource"
assert ai_content.media_type == "application/json"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_embedded_resource_text():
"""Test conversion of MCP embedded text resource to AI content."""
text_resource = types.TextResourceContents(
uri=AnyUrl("file://test.txt"), mimeType="text/plain", text="Embedded text content"
)
mcp_content = types.EmbeddedResource(type="resource", resource=text_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, TextContent)
assert ai_content.text == "Embedded text content"
assert ai_content.raw_representation == mcp_content
def test_mcp_content_types_to_ai_content_embedded_resource_blob():
"""Test conversion of MCP embedded blob resource to AI content."""
# Use a proper data URI in the blob field since that's what the MCP implementation expects
blob_resource = types.BlobResourceContents(
uri=AnyUrl("file://test.bin"),
mimeType="application/octet-stream",
blob="data:application/octet-stream;base64,dGVzdCBkYXRh",
)
mcp_content = types.EmbeddedResource(type="resource", resource=blob_resource)
ai_content = _mcp_type_to_ai_content(mcp_content)
assert isinstance(ai_content, DataContent)
assert ai_content.uri == "data:application/octet-stream;base64,dGVzdCBkYXRh"
assert ai_content.media_type == "application/octet-stream"
assert ai_content.raw_representation == mcp_content
def test_ai_content_to_mcp_content_types_text():
"""Test conversion of AI text content to MCP content."""
ai_content = TextContent(text="Sample text")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.TextContent)
assert mcp_content.type == "text"
assert mcp_content.text == "Sample text"
def test_ai_content_to_mcp_content_types_data_image():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:image/png;base64,xyz", media_type="image/png")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.ImageContent)
assert mcp_content.type == "image"
assert mcp_content.data == "data:image/png;base64,xyz"
assert mcp_content.mimeType == "image/png"
def test_ai_content_to_mcp_content_types_data_audio():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:audio/mpeg;base64,xyz", media_type="audio/mpeg")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.AudioContent)
assert mcp_content.type == "audio"
assert mcp_content.data == "data:audio/mpeg;base64,xyz"
assert mcp_content.mimeType == "audio/mpeg"
def test_ai_content_to_mcp_content_types_data_binary():
"""Test conversion of AI data content to MCP content."""
ai_content = DataContent(uri="data:application/octet-stream;base64,xyz", media_type="application/octet-stream")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.EmbeddedResource)
assert mcp_content.type == "resource"
assert mcp_content.resource.blob == "data:application/octet-stream;base64,xyz"
assert mcp_content.resource.mimeType == "application/octet-stream"
def test_ai_content_to_mcp_content_types_uri():
"""Test conversion of AI URI content to MCP content."""
ai_content = UriContent(uri="https://example.com/resource", media_type="application/json")
mcp_content = _ai_content_to_mcp_types(ai_content)
assert isinstance(mcp_content, types.ResourceLink)
assert mcp_content.type == "resource_link"
assert str(mcp_content.uri) == "https://example.com/resource"
assert mcp_content.mimeType == "application/json"
def test_chat_message_to_mcp_types():
message = ChatMessage(
role="user",
contents=[TextContent(text="test"), DataContent(uri="data:image/png;base64,xyz", media_type="image/png")],
)
mcp_contents = _chat_message_to_mcp_types(message)
assert len(mcp_contents) == 2
assert isinstance(mcp_contents[0], types.TextContent)
assert isinstance(mcp_contents[1], types.ImageContent)
def test_get_input_model_from_mcp_tool():
"""Test creation of input model from MCP tool."""
tool = types.Tool(
name="test_tool",
description="A test tool",
inputSchema={
"type": "object",
"properties": {"param1": {"type": "string"}, "param2": {"type": "number"}},
"required": ["param1"],
},
)
model = _get_input_model_from_mcp_tool(tool)
# Create an instance to verify the model works
instance = model(param1="test", param2=42)
assert instance.param1 == "test"
assert instance.param2 == 42
# Test validation
with pytest.raises(ValidationError): # Missing required param1
model(param2=42)
def test_get_input_model_from_mcp_prompt():
"""Test creation of input model from MCP prompt."""
prompt = types.Prompt(
name="test_prompt",
description="A test prompt",
arguments=[
types.PromptArgument(name="arg1", description="First argument", required=True),
types.PromptArgument(name="arg2", description="Second argument", required=False),
],
)
model = _get_input_model_from_mcp_prompt(prompt)
# Create an instance to verify the model works
instance = model(arg1="test", arg2="optional")
assert instance.arg1 == "test"
assert instance.arg2 == "optional"
# Test validation
with pytest.raises(ValidationError): # Missing required arg1
model(arg2="optional")
# MCPTool tests
async def test_local_mcp_server_initialization():
"""Test MCPTool initialization."""
server = MCPTool(name="test_server")
assert isinstance(server, ToolProtocol)
assert server.name == "test_server"
assert server.session is None
assert server.functions == []
async def test_local_mcp_server_context_manager():
"""Test MCPTool as context manager."""
class TestServer(MCPTool):
async def connect(self):
# Mock connection
self.session = Mock(spec=ClientSession)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
assert server.session is not None
assert server.session is None
async def test_local_mcp_server_load_functions():
"""Test loading functions from MCP server."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
# Mock tools list response
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
assert isinstance(server, ToolProtocol)
async with server:
await server.load_tools()
assert len(server.functions) == 1
assert server.functions[0].name == "test_tool"
async def test_local_mcp_server_load_prompts():
"""Test loading prompts from MCP server."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
# Mock prompts list response
self.session.list_prompts = AsyncMock(
return_value=types.ListPromptsResult(
prompts=[
types.Prompt(
name="test_prompt",
description="Test prompt",
arguments=[types.PromptArgument(name="arg", description="Test arg", required=True)],
)
]
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_prompts()
assert len(server.functions) == 1
assert server.functions[0].name == "test_prompt"
async def test_local_mcp_server_function_execution():
"""Test function execution through MCP server."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(
content=[types.TextContent(type="text", text="Tool executed successfully")]
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
result = await func.invoke(param="test_value")
assert len(result) == 1
assert isinstance(result[0], TextContent)
assert result[0].text == "Tool executed successfully"
async def test_local_mcp_server_function_execution_error():
"""Test function execution error handling."""
class TestServer(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_tools = AsyncMock(
return_value=types.ListToolsResult(
tools=[
types.Tool(
name="test_tool",
description="Test tool",
inputSchema={
"type": "object",
"properties": {"param": {"type": "string"}},
"required": ["param"],
},
)
]
)
)
# Mock a tool call that raises an MCP error
self.session.call_tool = AsyncMock(
side_effect=McpError(types.ErrorData(code=-1, message="Tool execution failed"))
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestServer(name="test_server")
async with server:
await server.load_tools()
func = server.functions[0]
with pytest.raises(ToolExecutionException):
await func.invoke(param="test_value")
async def test_local_mcp_server_prompt_execution():
"""Test prompt execution through MCP server."""
class TestMCPTool(MCPTool):
async def connect(self):
self.session = Mock(spec=ClientSession)
self.session.list_prompts = AsyncMock(
return_value=types.ListPromptsResult(
prompts=[
types.Prompt(
name="test_prompt",
description="Test prompt",
arguments=[types.PromptArgument(name="arg", description="Test arg", required=True)],
)
]
)
)
self.session.get_prompt = AsyncMock(
return_value=types.GetPromptResult(
description="Generated prompt",
messages=[
types.PromptMessage(role="user", content=types.TextContent(type="text", text="Test message"))
],
)
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
server = TestMCPTool(name="test_server")
async with server:
await server.load_prompts()
prompt = server.functions[0]
result = await prompt.invoke(arg="test_value")
assert len(result) == 1
assert isinstance(result[0], ChatMessage)
assert result[0].role == Role.USER
assert len(result[0].contents) == 1
assert result[0].contents[0].text == "Test message"
# Server implementation tests
def test_local_mcp_stdio_tool_init():
"""Test MCPStdioTool initialization."""
tool = MCPStdioTool(name="test", command="echo", args=["hello"])
assert tool.name == "test"
assert tool.command == "echo"
assert tool.args == ["hello"]
def test_local_mcp_websocket_tool_init():
"""Test MCPWebsocketTool initialization."""
tool = MCPWebsocketTool(name="test", url="ws://localhost:8080")
assert tool.name == "test"
assert tool.url == "ws://localhost:8080"
def test_local_mcp_streamable_http_tool_init():
"""Test MCPStreamableHTTPTool initialization."""
tool = MCPStreamableHTTPTool(name="test", url="http://localhost:8080")
assert tool.name == "test"
assert tool.url == "http://localhost:8080"
# Integration test
@skip_if_mcp_integration_tests_disabled
async def test_streamable_http_integration():
"""Test MCP StreamableHTTP integration."""
url = os.environ.get("LOCAL_MCP_URL", "")
if not url.startswith("http"):
pytest.skip("LOCAL_MCP_URL is not an HTTP URL")
tool = MCPStreamableHTTPTool(name="integration_test", url=url)
async with tool:
# Test that we can connect and load tools
assert tool.session is not None
assert isinstance(tool.functions, list)
# If there are functions available, try to get information about one
assert tool.functions, "The MCP server should have at least one function."
func = tool.functions[0]
assert hasattr(func, "name")
assert hasattr(func, "description")
result = await func.invoke(query="What is Agent Framework?")
assert result[0].text is not None