22
33"""Unit tests for AgentFunctionApp."""
44
5+ import json
56from collections .abc import Awaitable , Callable
67from typing import Any , TypeVar
78from 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+
8001067if __name__ == "__main__" :
8011068 pytest .main ([__file__ , "-v" , "--tb=short" ])
0 commit comments