Skip to content

Commit b1c210c

Browse files
gavin-aguiarlarohradmytrostrukcgillumCopilot
authored
Python: Added MCP tool support for azure functions package. (#2385)
* Python: Add Scaffolding for Durable AzureFunctions package to Agent Framework (#1823) * Add scafolding * update readme * add code owners and label * update owners * .NET: Durable extension: initial src and unit tests (#1900) * Python: Add Durable Agent Wrapper code (#1913) * add initial changes * Move code and add single sample * Update logger * Remove unused code * address PR comments * cleanup code and address comments --------- Co-authored-by: Dmytro Struk <[email protected]> * Azure Functions .NET samples (#1939) * Python: Add Unit tests for Azurefunctions package (#1976) * Add Unit tests for Azurefunctions * remove duplicate import * .NET: [Feature Branch] Migrate state schema updates and support for agents as MCP tools (#1979) * Python: Add more samples for Azure Functions (#1980) * Move all samples * fix comments * remove dead lines * Make samples simpler * Agents as MCP tools * Removed unused files and updated sample * .NET: [Feature Branch] Durable Task extension integration tests (#2017) * .NET: [Feature Branch] Update OpenAI config for integration tests (#2063) * Python: Add Integration tests for AzureFunctions (#2020) * Add Integration tests * Remove DTS extension * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Apply suggestions from code review Co-authored-by: Copilot <[email protected]> * Add pyi file for type safety * Add samples in readme * Updated all readme instructions * Address comments * Update readmes * Fix requirements * Address comments --------- Co-authored-by: Copilot <[email protected]> * Addressed copilot feedback * Minor refactoring and added tests * Updated mcp sample * Fixed broken link in readme * Addressed copilot comments * Addressed feedback * Updated property to enable_mcp_tool_trigger --------- Co-authored-by: Laveesh Rohra <[email protected]> Co-authored-by: Dmytro Struk <[email protected]> Co-authored-by: Chris Gillum <[email protected]> Co-authored-by: Copilot <[email protected]>
1 parent b8260ae commit b1c210c

File tree

7 files changed

+775
-30
lines changed

7 files changed

+775
-30
lines changed

python/packages/azurefunctions/agent_framework_azurefunctions/_app.py

Lines changed: 234 additions & 25 deletions
Large diffs are not rendered by default.

python/packages/azurefunctions/tests/test_app.py

Lines changed: 272 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
"""Unit tests for AgentFunctionApp."""
44

5+
import json
56
from collections.abc import Awaitable, Callable
67
from typing import Any, TypeVar
78
from unittest.mock import ANY, AsyncMock, Mock, patch
@@ -87,7 +88,7 @@ def test_add_agent_uses_specific_callback(self) -> None:
8788
app.add_agent(mock_agent, callback=specific_callback)
8889

8990
setup_mock.assert_called_once()
90-
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
91+
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
9192
assert passed_callback is specific_callback
9293
assert enable_http_endpoint is True
9394

@@ -103,7 +104,7 @@ def test_default_callback_applied_when_no_specific(self) -> None:
103104
app.add_agent(mock_agent)
104105

105106
setup_mock.assert_called_once()
106-
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
107+
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
107108
assert passed_callback is default_callback
108109
assert enable_http_endpoint is True
109110

@@ -118,7 +119,7 @@ def test_init_with_agents_uses_default_callback(self) -> None:
118119
AgentFunctionApp(agents=[mock_agent], default_callback=default_callback)
119120

120121
setup_mock.assert_called_once()
121-
_, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0]
122+
_, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0]
122123
assert passed_callback is default_callback
123124
assert enable_http_endpoint is True
124125

@@ -239,7 +240,7 @@ def test_agent_override_enables_http_route_when_app_disabled(self) -> None:
239240

240241
http_route_mock.assert_called_once_with("OverrideAgent")
241242
agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY)
242-
assert app.agent_http_endpoint_flags["OverrideAgent"] is True
243+
assert app._agent_metadata["OverrideAgent"].http_endpoint_enabled is True
243244

244245
def test_agent_override_disables_http_route_when_app_enabled(self) -> None:
245246
"""Agent-level override should disable HTTP route even when app enables it."""
@@ -256,7 +257,7 @@ def test_agent_override_disables_http_route_when_app_enabled(self) -> None:
256257

257258
http_route_mock.assert_not_called()
258259
agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY)
259-
assert app.agent_http_endpoint_flags["DisabledOverride"] is False
260+
assert app._agent_metadata["DisabledOverride"].http_endpoint_enabled is False
260261

261262
def test_multiple_apps_independent(self) -> None:
262263
"""Test that multiple AgentFunctionApp instances are independent."""
@@ -797,5 +798,271 @@ async def test_http_run_rejects_empty_message(self) -> None:
797798
client.signal_entity.assert_not_called()
798799

799800

801+
class TestMCPToolEndpoint:
802+
"""Test suite for MCP tool endpoint functionality."""
803+
804+
def test_init_with_mcp_tool_endpoint_enabled(self) -> None:
805+
"""Test initialization with MCP tool endpoint enabled."""
806+
mock_agent = Mock()
807+
mock_agent.name = "TestAgent"
808+
809+
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
810+
811+
assert app.enable_mcp_tool_trigger is True
812+
813+
def test_init_with_mcp_tool_endpoint_disabled(self) -> None:
814+
"""Test initialization with MCP tool endpoint disabled (default)."""
815+
mock_agent = Mock()
816+
mock_agent.name = "TestAgent"
817+
818+
app = AgentFunctionApp(agents=[mock_agent])
819+
820+
assert app.enable_mcp_tool_trigger is False
821+
822+
def test_add_agent_with_mcp_tool_trigger_enabled(self) -> None:
823+
"""Test adding an agent with MCP tool trigger explicitly enabled."""
824+
mock_agent = Mock()
825+
mock_agent.name = "MCPAgent"
826+
mock_agent.description = "Test MCP Agent"
827+
828+
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
829+
app = AgentFunctionApp()
830+
app.add_agent(mock_agent, enable_mcp_tool_trigger=True)
831+
832+
setup_mock.assert_called_once()
833+
_, _, _, _, enable_mcp = setup_mock.call_args[0]
834+
assert enable_mcp is True
835+
836+
def test_add_agent_with_mcp_tool_trigger_disabled(self) -> None:
837+
"""Test adding an agent with MCP tool trigger explicitly disabled."""
838+
mock_agent = Mock()
839+
mock_agent.name = "NoMCPAgent"
840+
841+
with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock:
842+
app = AgentFunctionApp(enable_mcp_tool_trigger=True)
843+
app.add_agent(mock_agent, enable_mcp_tool_trigger=False)
844+
845+
setup_mock.assert_called_once()
846+
_, _, _, _, enable_mcp = setup_mock.call_args[0]
847+
assert enable_mcp is False
848+
849+
def test_agent_override_enables_mcp_when_app_disabled(self) -> None:
850+
"""Test that per-agent override can enable MCP when app-level is disabled."""
851+
mock_agent = Mock()
852+
mock_agent.name = "OverrideAgent"
853+
854+
with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock:
855+
app = AgentFunctionApp(enable_mcp_tool_trigger=False)
856+
app.add_agent(mock_agent, enable_mcp_tool_trigger=True)
857+
858+
mcp_setup_mock.assert_called_once()
859+
860+
def test_agent_override_disables_mcp_when_app_enabled(self) -> None:
861+
"""Test that per-agent override can disable MCP when app-level is enabled."""
862+
mock_agent = Mock()
863+
mock_agent.name = "NoOverrideAgent"
864+
865+
with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock:
866+
app = AgentFunctionApp(enable_mcp_tool_trigger=True)
867+
app.add_agent(mock_agent, enable_mcp_tool_trigger=False)
868+
869+
mcp_setup_mock.assert_not_called()
870+
871+
def test_setup_mcp_tool_trigger_registers_decorators(self) -> None:
872+
"""Test that _setup_mcp_tool_trigger registers the correct decorators."""
873+
mock_agent = Mock()
874+
mock_agent.name = "MCPToolAgent"
875+
mock_agent.description = "Test MCP Tool"
876+
877+
app = AgentFunctionApp()
878+
879+
# Mock the decorators
880+
with (
881+
patch.object(app, "function_name") as func_name_mock,
882+
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
883+
patch.object(app, "durable_client_input") as client_mock,
884+
):
885+
# Setup mock decorator chain
886+
func_name_mock.return_value = lambda f: f
887+
mcp_trigger_mock.return_value = lambda f: f
888+
client_mock.return_value = lambda f: f
889+
890+
app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description)
891+
892+
# Verify decorators were called with correct parameters
893+
func_name_mock.assert_called_once()
894+
mcp_trigger_mock.assert_called_once_with(
895+
arg_name="context",
896+
tool_name=mock_agent.name,
897+
description=mock_agent.description,
898+
tool_properties=ANY,
899+
data_type=func.DataType.UNDEFINED,
900+
)
901+
client_mock.assert_called_once_with(client_name="client")
902+
903+
def test_setup_mcp_tool_trigger_uses_default_description(self) -> None:
904+
"""Test that _setup_mcp_tool_trigger uses default description when none provided."""
905+
mock_agent = Mock()
906+
mock_agent.name = "NoDescAgent"
907+
908+
app = AgentFunctionApp()
909+
910+
with (
911+
patch.object(app, "function_name", return_value=lambda f: f),
912+
patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock,
913+
patch.object(app, "durable_client_input", return_value=lambda f: f),
914+
):
915+
mcp_trigger_mock.return_value = lambda f: f
916+
917+
app._setup_mcp_tool_trigger(mock_agent.name, None)
918+
919+
# Verify default description was used
920+
call_args = mcp_trigger_mock.call_args
921+
assert call_args[1]["description"] == f"Interact with {mock_agent.name} agent"
922+
923+
async def test_handle_mcp_tool_invocation_with_json_string(self) -> None:
924+
"""Test _handle_mcp_tool_invocation with JSON string context."""
925+
mock_agent = Mock()
926+
mock_agent.name = "TestAgent"
927+
928+
app = AgentFunctionApp(agents=[mock_agent])
929+
client = AsyncMock()
930+
931+
# Mock the entity response
932+
mock_state = Mock()
933+
mock_state.entity_state = {
934+
"schemaVersion": "1.0.0",
935+
"data": {"conversationHistory": []},
936+
}
937+
client.read_entity_state.return_value = mock_state
938+
939+
# Create JSON string context
940+
context = '{"arguments": {"query": "test query", "threadId": "test-thread"}}'
941+
942+
with patch.object(app, "_get_response_from_entity") as get_response_mock:
943+
get_response_mock.return_value = {"status": "success", "response": "Test response"}
944+
945+
result = await app._handle_mcp_tool_invocation("TestAgent", context, client)
946+
947+
assert result == "Test response"
948+
get_response_mock.assert_called_once()
949+
950+
async def test_handle_mcp_tool_invocation_with_json_context(self) -> None:
951+
"""Test _handle_mcp_tool_invocation with JSON string context."""
952+
mock_agent = Mock()
953+
mock_agent.name = "TestAgent"
954+
955+
app = AgentFunctionApp(agents=[mock_agent])
956+
client = AsyncMock()
957+
958+
# Mock the entity response
959+
mock_state = Mock()
960+
mock_state.entity_state = {
961+
"schemaVersion": "1.0.0",
962+
"data": {"conversationHistory": []},
963+
}
964+
client.read_entity_state.return_value = mock_state
965+
966+
# Create JSON string context
967+
context = json.dumps({"arguments": {"query": "test query", "threadId": "test-thread"}})
968+
969+
with patch.object(app, "_get_response_from_entity") as get_response_mock:
970+
get_response_mock.return_value = {"status": "success", "response": "Test response"}
971+
972+
result = await app._handle_mcp_tool_invocation("TestAgent", context, client)
973+
974+
assert result == "Test response"
975+
get_response_mock.assert_called_once()
976+
977+
async def test_handle_mcp_tool_invocation_missing_query(self) -> None:
978+
"""Test _handle_mcp_tool_invocation raises ValueError when query is missing."""
979+
mock_agent = Mock()
980+
mock_agent.name = "TestAgent"
981+
982+
app = AgentFunctionApp(agents=[mock_agent])
983+
client = AsyncMock()
984+
985+
# Context missing query (as JSON string)
986+
context = json.dumps({"arguments": {}})
987+
988+
with pytest.raises(ValueError, match="missing required 'query' argument"):
989+
await app._handle_mcp_tool_invocation("TestAgent", context, client)
990+
991+
async def test_handle_mcp_tool_invocation_invalid_json(self) -> None:
992+
"""Test _handle_mcp_tool_invocation raises ValueError for invalid JSON."""
993+
mock_agent = Mock()
994+
mock_agent.name = "TestAgent"
995+
996+
app = AgentFunctionApp(agents=[mock_agent])
997+
client = AsyncMock()
998+
999+
# Invalid JSON string
1000+
context = "not valid json"
1001+
1002+
with pytest.raises(ValueError, match="Invalid MCP context format"):
1003+
await app._handle_mcp_tool_invocation("TestAgent", context, client)
1004+
1005+
async def test_handle_mcp_tool_invocation_runtime_error(self) -> None:
1006+
"""Test _handle_mcp_tool_invocation raises RuntimeError when agent fails."""
1007+
mock_agent = Mock()
1008+
mock_agent.name = "TestAgent"
1009+
1010+
app = AgentFunctionApp(agents=[mock_agent])
1011+
client = AsyncMock()
1012+
1013+
# Mock the entity response
1014+
mock_state = Mock()
1015+
mock_state.entity_state = {
1016+
"schemaVersion": "1.0.0",
1017+
"data": {"conversationHistory": []},
1018+
}
1019+
client.read_entity_state.return_value = mock_state
1020+
1021+
context = '{"arguments": {"query": "test query"}}'
1022+
1023+
with patch.object(app, "_get_response_from_entity") as get_response_mock:
1024+
get_response_mock.return_value = {"status": "failed", "error": "Agent error"}
1025+
1026+
with pytest.raises(RuntimeError, match="Agent execution failed"):
1027+
await app._handle_mcp_tool_invocation("TestAgent", context, client)
1028+
1029+
def test_health_check_includes_mcp_tool_enabled(self) -> None:
1030+
"""Test that health check endpoint includes mcp_tool_enabled field."""
1031+
mock_agent = Mock()
1032+
mock_agent.name = "HealthAgent"
1033+
1034+
app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True)
1035+
1036+
# Capture the health check handler function
1037+
captured_handler = None
1038+
1039+
def capture_decorator(*args, **kwargs):
1040+
def decorator(func):
1041+
nonlocal captured_handler
1042+
captured_handler = func
1043+
return func
1044+
1045+
return decorator
1046+
1047+
with patch.object(app, "route", side_effect=capture_decorator):
1048+
app._setup_health_route()
1049+
1050+
# Verify we captured the handler
1051+
assert captured_handler is not None
1052+
1053+
# Call the health handler
1054+
request = Mock()
1055+
response = captured_handler(request)
1056+
1057+
# Verify response includes mcp_tool_enabled
1058+
import json
1059+
1060+
body = json.loads(response.get_body().decode("utf-8"))
1061+
assert "agents" in body
1062+
assert len(body["agents"]) == 1
1063+
assert "mcp_tool_enabled" in body["agents"][0]
1064+
assert body["agents"][0]["mcp_tool_enabled"] is True
1065+
1066+
8001067
if __name__ == "__main__":
8011068
pytest.main([__file__, "-v", "--tb=short"])

0 commit comments

Comments
 (0)