diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py index fb1db84824..820670a988 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py @@ -14,6 +14,7 @@ get_logger, normalize_tools, ) +from agent_framework._mcp import MCPTool from agent_framework.exceptions import ServiceInitializationError from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( @@ -206,10 +207,32 @@ async def create_agent( if rai_config: args["rai_config"] = rai_config - # Normalize tools once and reuse for both Azure AI API and ChatAgent + # Normalize tools and separate MCP tools from other tools normalized_tools = normalize_tools(tools) + mcp_tools: list[MCPTool] = [] + non_mcp_tools: list[ToolProtocol | MutableMapping[str, Any]] = [] + if normalized_tools: - args["tools"] = to_azure_ai_tools(normalized_tools) + for tool in normalized_tools: + if isinstance(tool, MCPTool): + mcp_tools.append(tool) + else: + non_mcp_tools.append(tool) + + # Connect MCP tools and discover their functions BEFORE creating the agent + # This is required because Azure AI Responses API doesn't accept tools at request time + mcp_discovered_functions: list[AIFunction[Any, Any]] = [] + for mcp_tool in mcp_tools: + if not mcp_tool.is_connected: + await mcp_tool.connect() + mcp_discovered_functions.extend(mcp_tool.functions) + + # Combine non-MCP tools with discovered MCP functions for Azure AI + all_tools_for_azure: list[ToolProtocol | MutableMapping[str, Any]] = list(non_mcp_tools) + all_tools_for_azure.extend(mcp_discovered_functions) + + if all_tools_for_azure: + args["tools"] = to_azure_ai_tools(all_tools_for_azure) created_agent = await self._project_client.agents.create_version( agent_name=name, @@ -404,10 +427,12 @@ def _merge_tools( continue merged.append(hosted_tool) - # Add user-provided function tools (these have the actual implementations) + # Add user-provided function tools and MCP tools if provided_tools: for provided_tool in provided_tools: - if isinstance(provided_tool, AIFunction): + # AIFunction - has implementation for function calling + # MCPTool - ChatAgent handles MCP connection and tool discovery at runtime + if isinstance(provided_tool, (AIFunction, MCPTool)): merged.append(provided_tool) # type: ignore[reportUnknownArgumentType] return merged diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py index 627f5a96ea..cc5d1ea634 100644 --- a/python/packages/azure-ai/tests/test_provider.py +++ b/python/packages/azure-ai/tests/test_provider.py @@ -4,7 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from agent_framework import ChatAgent +from agent_framework import AIFunction, ChatAgent +from agent_framework._mcp import MCPTool from agent_framework.exceptions import ServiceInitializationError from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( @@ -441,6 +442,170 @@ class TestSchema(BaseModel): assert "schema" in result +class MockMCPTool(MCPTool): # pyright: ignore[reportGeneralTypeIssues] + """A mock MCPTool subclass for testing that passes isinstance checks. + + Note: This intentionally does NOT call super().__init__() because MCPTool's + constructor requires MCP server connection parameters that aren't needed for + unit testing. We only need isinstance(obj, MCPTool) to return True. + """ + + def __init__(self, functions: list[AIFunction] | None = None) -> None: + self.name = "MockMCPTool" + self.description = "A mock MCP tool for testing" + self.is_connected = False + self._mock_functions = functions or [] + self._connect_called = False + + @property + def functions(self) -> list[AIFunction]: + return self._mock_functions + + async def connect(self, *, reset: bool = False) -> None: + self._connect_called = True + self.is_connected = True + + +@pytest.fixture +def mock_mcp_tool() -> MockMCPTool: + """Fixture that provides a mock MCPTool.""" + mock_functions = [ + create_mock_ai_function("mcp_function_1", "First MCP function"), + create_mock_ai_function("mcp_function_2", "Second MCP function"), + ] + return MockMCPTool(functions=mock_functions) + + +def create_mock_ai_function(name: str, description: str = "A mock function") -> AIFunction: + """Create a real AIFunction for testing.""" + + def mock_func(arg: str) -> str: + return f"Result from {name}: {arg}" + + return AIFunction(func=mock_func, name=name, description=description) + + +async def test_provider_create_agent_with_mcp_tool( + mock_project_client: MagicMock, + azure_ai_unit_test_env: dict[str, str], + mock_mcp_tool: "MockMCPTool", +) -> None: + """Test that create_agent connects MCP tools and passes discovered functions to Azure AI.""" + + # Patch normalize_tools to return tools as-is in a list (avoids callable check) + def mock_normalize_tools(tools): + if tools is None: + return [] + if isinstance(tools, list): + return tools + return [tools] + + with ( + patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings, + patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, + patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), + ): + mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] + mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + mock_to_azure_tools.return_value = [{"type": "function", "name": "mcp_function_1"}] + + provider = AzureAIProjectAgentProvider(project_client=mock_project_client) + + # Mock agent creation response + mock_agent_version = MagicMock(spec=AgentVersionDetails) + mock_agent_version.id = "agent-id" + mock_agent_version.name = "test-agent" + mock_agent_version.version = "1.0" + mock_agent_version.description = "Test Agent" + mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) + mock_agent_version.definition.model = "gpt-4" + mock_agent_version.definition.instructions = "Test instructions" + mock_agent_version.definition.tools = [] + + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) + + # Call create_agent with MCP tool + await provider.create_agent( + name="test-agent", + model="gpt-4", + instructions="Test instructions", + tools=mock_mcp_tool, + ) + + # Verify MCP tool was connected + assert mock_mcp_tool._connect_called is True + assert mock_mcp_tool.is_connected is True + + # Verify to_azure_ai_tools was called with the discovered MCP functions + mock_to_azure_tools.assert_called_once() + tools_passed = mock_to_azure_tools.call_args[0][0] + assert len(tools_passed) == 2 + assert tools_passed[0].name == "mcp_function_1" + assert tools_passed[1].name == "mcp_function_2" + + +async def test_provider_create_agent_with_mcp_and_regular_tools( + mock_project_client: MagicMock, + azure_ai_unit_test_env: dict[str, str], + mock_mcp_tool: "MockMCPTool", +) -> None: + """Test that create_agent handles both MCP tools and regular AIFunctions.""" + # Create a regular AIFunction + regular_function = create_mock_ai_function("regular_function", "A regular function") + + # Patch normalize_tools to return tools as-is in a list (avoids callable check) + def mock_normalize_tools(tools): + if tools is None: + return [] + if isinstance(tools, list): + return tools + return [tools] + + with ( + patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings, + patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, + patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), + ): + mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] + mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + mock_to_azure_tools.return_value = [] + + provider = AzureAIProjectAgentProvider(project_client=mock_project_client) + + # Mock agent creation response + mock_agent_version = MagicMock(spec=AgentVersionDetails) + mock_agent_version.id = "agent-id" + mock_agent_version.name = "test-agent" + mock_agent_version.version = "1.0" + mock_agent_version.description = None + mock_agent_version.definition = MagicMock(spec=PromptAgentDefinition) + mock_agent_version.definition.model = "gpt-4" + mock_agent_version.definition.instructions = None + mock_agent_version.definition.tools = [] + + mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent_version) + + # Pass both MCP tool and regular function + await provider.create_agent( + name="test-agent", + model="gpt-4", + tools=[mock_mcp_tool, regular_function], + ) + + # Verify to_azure_ai_tools was called with: + # - The regular AIFunction (1) + # - The 2 discovered MCP functions + mock_to_azure_tools.assert_called_once() + tools_passed = mock_to_azure_tools.call_args[0][0] + assert len(tools_passed) == 3 # 1 regular + 2 MCP functions + + # Verify the regular function is in the list + tool_names = [t.name for t in tools_passed] + assert "regular_function" in tool_names + assert "mcp_function_1" in tool_names + assert "mcp_function_2" in tool_names + + @pytest.mark.flaky @skip_if_azure_ai_integration_tests_disabled async def test_provider_create_and_get_agent_integration() -> None: diff --git a/python/samples/getting_started/agents/azure_ai/azure_ai_with_local_mcp.py b/python/samples/getting_started/agents/azure_ai/azure_ai_with_local_mcp.py index 91b6228b71..a3ce3be5ea 100644 --- a/python/samples/getting_started/agents/azure_ai/azure_ai_with_local_mcp.py +++ b/python/samples/getting_started/agents/azure_ai/azure_ai_with_local_mcp.py @@ -22,6 +22,11 @@ async def main() -> None: """Example showing use of Local MCP Tool with AzureAIProjectAgentProvider.""" print("=== Azure AI Agent with Local MCP Tools Example ===\n") + mcp_tool = MCPStreamableHTTPTool( + name="Microsoft Learn MCP", + url="https://learn.microsoft.com/api/mcp", + ) + async with ( AzureCliCredential() as credential, AzureAIProjectAgentProvider(credential=credential) as provider, @@ -29,23 +34,22 @@ async def main() -> None: agent = await provider.create_agent( name="DocsAgent", instructions="You are a helpful assistant that can help with Microsoft documentation questions.", - tools=MCPStreamableHTTPTool( - name="Microsoft Learn MCP", - url="https://learn.microsoft.com/api/mcp", - ), + tools=mcp_tool, ) - # First query - first_query = "How to create an Azure storage account using az cli?" - print(f"User: {first_query}") - first_result = await agent.run(first_query) - print(f"Agent: {first_result}") - print("\n=======================================\n") - # Second query - second_query = "What is Microsoft Agent Framework?" - print(f"User: {second_query}") - second_result = await agent.run(second_query) - print(f"Agent: {second_result}") + # Use agent as context manager to ensure proper cleanup + async with agent: + # First query + first_query = "How to create an Azure storage account using az cli?" + print(f"User: {first_query}") + first_result = await agent.run(first_query) + print(f"Agent: {first_result}") + print("\n=======================================\n") + # Second query + second_query = "What is Microsoft Agent Framework?" + print(f"User: {second_query}") + second_result = await agent.run(second_query) + print(f"Agent: {second_result}") if __name__ == "__main__":