diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index e0bc3ba51a..e83a9244a7 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -9,6 +9,7 @@ import json import re from collections.abc import Callable, Mapping +from dataclasses import dataclass from typing import TYPE_CHECKING, Any, TypeVar, cast import azure.durable_functions as df @@ -39,6 +40,22 @@ EntityHandler = Callable[[df.DurableEntityContext], None] HandlerT = TypeVar("HandlerT", bound=Callable[..., Any]) + +@dataclass +class AgentMetadata: + """Metadata for a registered agent. + + Attributes: + agent: The agent instance implementing AgentProtocol + http_endpoint_enabled: Whether HTTP endpoint is enabled for this agent + mcp_tool_enabled: Whether MCP tool endpoint is enabled for this agent + """ + + agent: AgentProtocol + http_endpoint_enabled: bool + mcp_tool_enabled: bool + + if TYPE_CHECKING: class DFAppBase: @@ -56,6 +73,15 @@ def orchestration_trigger(self, context_name: str) -> Callable[[HandlerT], Handl def activity_trigger(self, input_name: str) -> Callable[[HandlerT], HandlerT]: ... + def mcp_tool_trigger( + self, + arg_name: str, + tool_name: str, + description: str, + tool_properties: str, + data_type: func.DataType, + ) -> Callable[[HandlerT], HandlerT]: ... + else: DFAppBase = df.DFApp # type: ignore[assignment] @@ -117,14 +143,15 @@ def my_orchestration(context): agents: Dictionary of agent name to AgentProtocol instance enable_health_check: Whether health check endpoint is enabled enable_http_endpoints: Whether HTTP endpoints are created for agents + enable_mcp_tool_trigger: Whether MCP tool triggers are created for agents max_poll_retries: Maximum polling attempts when waiting for responses poll_interval_seconds: Delay (seconds) between polling attempts """ - agents: dict[str, AgentProtocol] + _agent_metadata: dict[str, AgentMetadata] enable_health_check: bool enable_http_endpoints: bool - agent_http_endpoint_flags: dict[str, bool] + enable_mcp_tool_trigger: bool def __init__( self, @@ -134,6 +161,7 @@ def __init__( enable_http_endpoints: bool = True, max_poll_retries: int = DEFAULT_MAX_POLL_RETRIES, poll_interval_seconds: float = DEFAULT_POLL_INTERVAL_SECONDS, + enable_mcp_tool_trigger: bool = False, default_callback: AgentResponseCallbackProtocol | None = None, ): """Initialize the AgentFunctionApp. @@ -142,6 +170,8 @@ def __init__( :param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``). :param enable_health_check: Enable the built-in health check endpoint (default: ``True``). :param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``). + :param enable_mcp_tool_trigger: Enable MCP tool triggers for agents (default: ``False``). + When enabled, agents will be exposed as MCP tools that can be invoked by MCP-compatible clients. :param max_poll_retries: Maximum polling attempts when waiting for a response. Defaults to ``DEFAULT_MAX_POLL_RETRIES``. :param poll_interval_seconds: Delay in seconds between polling attempts. @@ -155,11 +185,11 @@ def __init__( # Initialize parent DFApp super().__init__(http_auth_level=http_auth_level) - # Initialize agents dictionary - self.agents = {} - self.agent_http_endpoint_flags = {} + # Initialize agent metadata dictionary + self._agent_metadata = {} self.enable_health_check = enable_health_check self.enable_http_endpoints = enable_http_endpoints + self.enable_mcp_tool_trigger = enable_mcp_tool_trigger self.default_callback = default_callback try: @@ -186,11 +216,21 @@ def __init__( logger.debug("[AgentFunctionApp] Initialization complete") + @property + def agents(self) -> dict[str, AgentProtocol]: + """Returns dict of agent names to agent instances. + + Returns: + Dictionary mapping agent names to their AgentProtocol instances. + """ + return {name: metadata.agent for name, metadata in self._agent_metadata.items()} + def add_agent( self, agent: AgentProtocol, callback: AgentResponseCallbackProtocol | None = None, enable_http_endpoint: bool | None = None, + enable_mcp_tool_trigger: bool | None = None, ) -> None: """Add an agent to the function app after initialization. @@ -198,8 +238,10 @@ def add_agent( agent: The Microsoft Agent Framework agent instance (must implement AgentProtocol) The agent must have a 'name' attribute. callback: Optional callback invoked during agent execution - enable_http_endpoint: Optional flag that overrides the app-level - HTTP endpoint setting for this agent + enable_http_endpoint: Optional flag to enable/disable HTTP endpoint for this agent. + The app level enable_http_endpoints setting will override this setting. + enable_mcp_tool_trigger: Optional flag to enable/disable MCP tool trigger for this agent. + The app level enable_mcp_tool_trigger setting will override this setting. Raises: ValueError: If the agent doesn't have a 'name' attribute or if an agent @@ -210,12 +252,17 @@ def add_agent( if name is None: raise ValueError("Agent does not have a 'name' attribute. All agents must have a 'name' attribute.") - if name in self.agents: + if name in self._agent_metadata: raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.") effective_enable_http_endpoint = ( self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint) ) + effective_enable_mcp_endpoint = ( + self.enable_mcp_tool_trigger + if enable_mcp_tool_trigger is None + else self._coerce_to_bool(enable_mcp_tool_trigger) + ) logger.debug(f"[AgentFunctionApp] Adding agent: {name}") logger.debug(f"[AgentFunctionApp] Route: /api/agents/{name}") @@ -224,17 +271,21 @@ def add_agent( "enabled" if effective_enable_http_endpoint else "disabled", name, ) + logger.debug( + f"[AgentFunctionApp] MCP tool trigger: {'enabled' if effective_enable_mcp_endpoint else 'disabled'}" + ) - self.agents[name] = agent - self.agent_http_endpoint_flags[name] = effective_enable_http_endpoint + # Store agent metadata + self._agent_metadata[name] = AgentMetadata( + agent=agent, + http_endpoint_enabled=effective_enable_http_endpoint, + mcp_tool_enabled=effective_enable_mcp_endpoint, + ) effective_callback = callback or self.default_callback self._setup_agent_functions( - agent, - name, - effective_callback, - effective_enable_http_endpoint, + agent, name, effective_callback, effective_enable_http_endpoint, effective_enable_mcp_endpoint ) logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully") @@ -258,7 +309,7 @@ def get_agent( """ normalized_name = str(agent_name) - if normalized_name not in self.agents: + if normalized_name not in self._agent_metadata: raise ValueError(f"Agent '{normalized_name}' is not registered with this app.") return DurableAIAgent(context, normalized_name) @@ -269,15 +320,16 @@ def _setup_agent_functions( agent_name: str, callback: AgentResponseCallbackProtocol | None, enable_http_endpoint: bool, + enable_mcp_tool_trigger: bool, ) -> None: - """Set up the HTTP trigger and entity for a specific agent. + """Set up the HTTP trigger, entity, and MCP tool trigger for a specific agent. Args: agent: The agent instance agent_name: The name to use for routing and entity registration callback: Optional callback to receive response updates - enable_http_endpoint: Whether the HTTP run route is enabled for - this agent + enable_http_endpoint: Whether to create HTTP endpoint + enable_mcp_tool_trigger: Whether to create MCP tool trigger """ logger.debug(f"[AgentFunctionApp] Setting up functions for agent '{agent_name}'...") @@ -290,6 +342,12 @@ def _setup_agent_functions( ) self._setup_agent_entity(agent, agent_name, callback) + if enable_mcp_tool_trigger: + agent_description = agent.description + self._setup_mcp_tool_trigger(agent_name, agent_description) + else: + logger.debug(f"[AgentFunctionApp] MCP tool trigger disabled for agent '{agent_name}'") + def _setup_http_run_route(self, agent_name: str) -> None: """Register the POST route that triggers agent execution. @@ -448,6 +506,159 @@ def entity_function(context: df.DurableEntityContext) -> None: entity_function.__name__ = entity_name_with_prefix self.entity_trigger(context_name="context", entity_name=entity_name_with_prefix)(entity_function) + def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None) -> None: + """Register an MCP tool trigger for an agent using Azure Functions native MCP support. + + This creates a native Azure Functions MCP tool trigger that exposes the agent + as an MCP tool, allowing it to be invoked by MCP-compatible clients. + + Args: + agent_name: The agent name (used as the MCP tool name) + agent_description: Optional description for the MCP tool (shown to clients) + """ + mcp_function_name = self._build_function_name(agent_name, "mcptool") + + # Define tool properties as JSON (MCP tool parameters) + tool_properties = json.dumps([ + { + "propertyName": "query", + "propertyType": "string", + "description": "The query to send to the agent.", + "isRequired": True, + "isArray": False, + }, + { + "propertyName": "threadId", + "propertyType": "string", + "description": "Optional thread identifier for conversation continuity.", + "isRequired": False, + "isArray": False, + }, + ]) + + function_name_decorator = self.function_name(mcp_function_name) + mcp_tool_decorator = self.mcp_tool_trigger( + arg_name="context", + tool_name=agent_name, + description=agent_description or f"Interact with {agent_name} agent", + tool_properties=tool_properties, + data_type=func.DataType.UNDEFINED, + ) + durable_client_decorator = self.durable_client_input(client_name="client") + + @function_name_decorator + @mcp_tool_decorator + @durable_client_decorator + async def mcp_tool_handler(context: str, client: df.DurableOrchestrationClient) -> str: + """Handle MCP tool invocation for the agent. + + Args: + context: MCP tool invocation context containing arguments (query, threadId) + client: Durable orchestration client for entity communication + + Returns: + Agent response text + """ + logger.debug("[MCP Tool Trigger] Received invocation for agent: %s", agent_name) + return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) + + logger.debug("[AgentFunctionApp] Registered MCP tool trigger for agent: %s", agent_name) + + async def _handle_mcp_tool_invocation( + self, agent_name: str, context: str, client: df.DurableOrchestrationClient + ) -> str: + """Handle an MCP tool invocation. + + This method processes MCP tool requests and delegates to the agent entity. + + Args: + agent_name: Name of the agent being invoked + context: MCP tool invocation context as a JSON string + client: Durable orchestration client + + Returns: + Agent response text + + Raises: + ValueError: If required arguments are missing or context is invalid JSON + RuntimeError: If agent execution fails + """ + logger.debug("[MCP Tool Handler] Processing invocation for agent '%s'", agent_name) + + # Parse JSON context string + try: + parsed_context = json.loads(context) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid MCP context format: {e}") from e + + # Extract arguments from MCP context + arguments = parsed_context.get("arguments", {}) if isinstance(parsed_context, dict) else {} + + # Validate required 'query' argument + query = arguments.get("query") + if not query or not isinstance(query, str): + raise ValueError("MCP Tool invocation is missing required 'query' argument of type string.") + + # Extract optional threadId + thread_id = arguments.get("threadId") + + # Create or parse session ID + if thread_id and isinstance(thread_id, str) and thread_id.strip(): + try: + session_id = AgentSessionId.parse(thread_id) + except ValueError as e: + logger.warning( + "Failed to parse AgentSessionId from thread_id '%s': %s. Falling back to new session ID.", + thread_id, + e, + ) + session_id = AgentSessionId(name=agent_name, key=thread_id) + else: + # Generate new session ID + session_id = AgentSessionId.with_random_key(agent_name) + + # Build entity instance ID + entity_instance_id = session_id.to_entity_id() + + # Create run request + correlation_id = self._generate_unique_id() + run_request = self._build_request_data( + req_body={"message": query, "role": "user"}, + message=query, + thread_id=str(session_id), + correlation_id=correlation_id, + request_response_format=REQUEST_RESPONSE_FORMAT_TEXT, + ) + + query_preview = query[:50] + "..." if len(query) > 50 else query + logger.info("[MCP Tool] Invoking agent '%s' with query: %s", agent_name, query_preview) + + # Signal entity to run agent + await client.signal_entity(entity_instance_id, "run_agent", run_request) + + # Poll for response (similar to HTTP handler) + try: + result = await self._get_response_from_entity( + client=client, + entity_instance_id=entity_instance_id, + correlation_id=correlation_id, + message=query, + thread_id=str(session_id), + ) + + # Extract and return response text + if result.get("status") == "success": + response_text = str(result.get("response", "No response")) + logger.info("[MCP Tool] Agent '%s' responded successfully", agent_name) + return response_text + error_msg = result.get("error", "Unknown error") + logger.error("[MCP Tool] Agent '%s' execution failed: %s", agent_name, error_msg) + raise RuntimeError(f"Agent execution failed: {error_msg}") + + except Exception as exc: + logger.error("[MCP Tool] Error invoking agent '%s': %s", agent_name, exc, exc_info=True) + raise + def _setup_health_route(self) -> None: """Register the optional health check route.""" health_route = self.route(route="health", methods=["GET"]) @@ -458,16 +669,14 @@ def health_check(req: func.HttpRequest) -> func.HttpResponse: agent_info = [ { "name": name, - "type": type(agent).__name__, - "http_endpoint_enabled": self.agent_http_endpoint_flags.get( - name, - self.enable_http_endpoints, - ), + "type": type(metadata.agent).__name__, + "http_endpoint_enabled": metadata.http_endpoint_enabled, + "mcp_tool_enabled": metadata.mcp_tool_enabled, } - for name, agent in self.agents.items() + for name, metadata in self._agent_metadata.items() ] return func.HttpResponse( - json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self.agents)}), + json.dumps({"status": "healthy", "agents": agent_info, "agent_count": len(self._agent_metadata)}), status_code=200, mimetype=MIMETYPE_APPLICATION_JSON, ) diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index ebf6eef3e6..c65368e79e 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -2,6 +2,7 @@ """Unit tests for AgentFunctionApp.""" +import json from collections.abc import Awaitable, Callable from typing import Any, TypeVar from unittest.mock import ANY, AsyncMock, Mock, patch @@ -87,7 +88,7 @@ def test_add_agent_uses_specific_callback(self) -> None: app.add_agent(mock_agent, callback=specific_callback) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is specific_callback assert enable_http_endpoint is True @@ -103,7 +104,7 @@ def test_default_callback_applied_when_no_specific(self) -> None: app.add_agent(mock_agent) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is default_callback assert enable_http_endpoint is True @@ -118,7 +119,7 @@ def test_init_with_agents_uses_default_callback(self) -> None: AgentFunctionApp(agents=[mock_agent], default_callback=default_callback) setup_mock.assert_called_once() - _, _, passed_callback, enable_http_endpoint = setup_mock.call_args[0] + _, _, passed_callback, enable_http_endpoint, enable_mcp_tool_trigger = setup_mock.call_args[0] assert passed_callback is default_callback assert enable_http_endpoint is True @@ -239,7 +240,7 @@ def test_agent_override_enables_http_route_when_app_disabled(self) -> None: http_route_mock.assert_called_once_with("OverrideAgent") agent_entity_mock.assert_called_once_with(mock_agent, "OverrideAgent", ANY) - assert app.agent_http_endpoint_flags["OverrideAgent"] is True + assert app._agent_metadata["OverrideAgent"].http_endpoint_enabled is True def test_agent_override_disables_http_route_when_app_enabled(self) -> None: """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: http_route_mock.assert_not_called() agent_entity_mock.assert_called_once_with(mock_agent, "DisabledOverride", ANY) - assert app.agent_http_endpoint_flags["DisabledOverride"] is False + assert app._agent_metadata["DisabledOverride"].http_endpoint_enabled is False def test_multiple_apps_independent(self) -> None: """Test that multiple AgentFunctionApp instances are independent.""" @@ -797,5 +798,271 @@ async def test_http_run_rejects_empty_message(self) -> None: client.signal_entity.assert_not_called() +class TestMCPToolEndpoint: + """Test suite for MCP tool endpoint functionality.""" + + def test_init_with_mcp_tool_endpoint_enabled(self) -> None: + """Test initialization with MCP tool endpoint enabled.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) + + assert app.enable_mcp_tool_trigger is True + + def test_init_with_mcp_tool_endpoint_disabled(self) -> None: + """Test initialization with MCP tool endpoint disabled (default).""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + + assert app.enable_mcp_tool_trigger is False + + def test_add_agent_with_mcp_tool_trigger_enabled(self) -> None: + """Test adding an agent with MCP tool trigger explicitly enabled.""" + mock_agent = Mock() + mock_agent.name = "MCPAgent" + mock_agent.description = "Test MCP Agent" + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + app = AgentFunctionApp() + app.add_agent(mock_agent, enable_mcp_tool_trigger=True) + + setup_mock.assert_called_once() + _, _, _, _, enable_mcp = setup_mock.call_args[0] + assert enable_mcp is True + + def test_add_agent_with_mcp_tool_trigger_disabled(self) -> None: + """Test adding an agent with MCP tool trigger explicitly disabled.""" + mock_agent = Mock() + mock_agent.name = "NoMCPAgent" + + with patch.object(AgentFunctionApp, "_setup_agent_functions") as setup_mock: + app = AgentFunctionApp(enable_mcp_tool_trigger=True) + app.add_agent(mock_agent, enable_mcp_tool_trigger=False) + + setup_mock.assert_called_once() + _, _, _, _, enable_mcp = setup_mock.call_args[0] + assert enable_mcp is False + + def test_agent_override_enables_mcp_when_app_disabled(self) -> None: + """Test that per-agent override can enable MCP when app-level is disabled.""" + mock_agent = Mock() + mock_agent.name = "OverrideAgent" + + with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock: + app = AgentFunctionApp(enable_mcp_tool_trigger=False) + app.add_agent(mock_agent, enable_mcp_tool_trigger=True) + + mcp_setup_mock.assert_called_once() + + def test_agent_override_disables_mcp_when_app_enabled(self) -> None: + """Test that per-agent override can disable MCP when app-level is enabled.""" + mock_agent = Mock() + mock_agent.name = "NoOverrideAgent" + + with patch.object(AgentFunctionApp, "_setup_mcp_tool_trigger") as mcp_setup_mock: + app = AgentFunctionApp(enable_mcp_tool_trigger=True) + app.add_agent(mock_agent, enable_mcp_tool_trigger=False) + + mcp_setup_mock.assert_not_called() + + def test_setup_mcp_tool_trigger_registers_decorators(self) -> None: + """Test that _setup_mcp_tool_trigger registers the correct decorators.""" + mock_agent = Mock() + mock_agent.name = "MCPToolAgent" + mock_agent.description = "Test MCP Tool" + + app = AgentFunctionApp() + + # Mock the decorators + with ( + patch.object(app, "function_name") as func_name_mock, + patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, + patch.object(app, "durable_client_input") as client_mock, + ): + # Setup mock decorator chain + func_name_mock.return_value = lambda f: f + mcp_trigger_mock.return_value = lambda f: f + client_mock.return_value = lambda f: f + + app._setup_mcp_tool_trigger(mock_agent.name, mock_agent.description) + + # Verify decorators were called with correct parameters + func_name_mock.assert_called_once() + mcp_trigger_mock.assert_called_once_with( + arg_name="context", + tool_name=mock_agent.name, + description=mock_agent.description, + tool_properties=ANY, + data_type=func.DataType.UNDEFINED, + ) + client_mock.assert_called_once_with(client_name="client") + + def test_setup_mcp_tool_trigger_uses_default_description(self) -> None: + """Test that _setup_mcp_tool_trigger uses default description when none provided.""" + mock_agent = Mock() + mock_agent.name = "NoDescAgent" + + app = AgentFunctionApp() + + with ( + patch.object(app, "function_name", return_value=lambda f: f), + patch.object(app, "mcp_tool_trigger") as mcp_trigger_mock, + patch.object(app, "durable_client_input", return_value=lambda f: f), + ): + mcp_trigger_mock.return_value = lambda f: f + + app._setup_mcp_tool_trigger(mock_agent.name, None) + + # Verify default description was used + call_args = mcp_trigger_mock.call_args + assert call_args[1]["description"] == f"Interact with {mock_agent.name} agent" + + async def test_handle_mcp_tool_invocation_with_json_string(self) -> None: + """Test _handle_mcp_tool_invocation with JSON string context.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Mock the entity response + mock_state = Mock() + mock_state.entity_state = { + "schemaVersion": "1.0.0", + "data": {"conversationHistory": []}, + } + client.read_entity_state.return_value = mock_state + + # Create JSON string context + context = '{"arguments": {"query": "test query", "threadId": "test-thread"}}' + + with patch.object(app, "_get_response_from_entity") as get_response_mock: + get_response_mock.return_value = {"status": "success", "response": "Test response"} + + result = await app._handle_mcp_tool_invocation("TestAgent", context, client) + + assert result == "Test response" + get_response_mock.assert_called_once() + + async def test_handle_mcp_tool_invocation_with_json_context(self) -> None: + """Test _handle_mcp_tool_invocation with JSON string context.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Mock the entity response + mock_state = Mock() + mock_state.entity_state = { + "schemaVersion": "1.0.0", + "data": {"conversationHistory": []}, + } + client.read_entity_state.return_value = mock_state + + # Create JSON string context + context = json.dumps({"arguments": {"query": "test query", "threadId": "test-thread"}}) + + with patch.object(app, "_get_response_from_entity") as get_response_mock: + get_response_mock.return_value = {"status": "success", "response": "Test response"} + + result = await app._handle_mcp_tool_invocation("TestAgent", context, client) + + assert result == "Test response" + get_response_mock.assert_called_once() + + async def test_handle_mcp_tool_invocation_missing_query(self) -> None: + """Test _handle_mcp_tool_invocation raises ValueError when query is missing.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Context missing query (as JSON string) + context = json.dumps({"arguments": {}}) + + with pytest.raises(ValueError, match="missing required 'query' argument"): + await app._handle_mcp_tool_invocation("TestAgent", context, client) + + async def test_handle_mcp_tool_invocation_invalid_json(self) -> None: + """Test _handle_mcp_tool_invocation raises ValueError for invalid JSON.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Invalid JSON string + context = "not valid json" + + with pytest.raises(ValueError, match="Invalid MCP context format"): + await app._handle_mcp_tool_invocation("TestAgent", context, client) + + async def test_handle_mcp_tool_invocation_runtime_error(self) -> None: + """Test _handle_mcp_tool_invocation raises RuntimeError when agent fails.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + app = AgentFunctionApp(agents=[mock_agent]) + client = AsyncMock() + + # Mock the entity response + mock_state = Mock() + mock_state.entity_state = { + "schemaVersion": "1.0.0", + "data": {"conversationHistory": []}, + } + client.read_entity_state.return_value = mock_state + + context = '{"arguments": {"query": "test query"}}' + + with patch.object(app, "_get_response_from_entity") as get_response_mock: + get_response_mock.return_value = {"status": "failed", "error": "Agent error"} + + with pytest.raises(RuntimeError, match="Agent execution failed"): + await app._handle_mcp_tool_invocation("TestAgent", context, client) + + def test_health_check_includes_mcp_tool_enabled(self) -> None: + """Test that health check endpoint includes mcp_tool_enabled field.""" + mock_agent = Mock() + mock_agent.name = "HealthAgent" + + app = AgentFunctionApp(agents=[mock_agent], enable_mcp_tool_trigger=True) + + # Capture the health check handler function + captured_handler = None + + def capture_decorator(*args, **kwargs): + def decorator(func): + nonlocal captured_handler + captured_handler = func + return func + + return decorator + + with patch.object(app, "route", side_effect=capture_decorator): + app._setup_health_route() + + # Verify we captured the handler + assert captured_handler is not None + + # Call the health handler + request = Mock() + response = captured_handler(request) + + # Verify response includes mcp_tool_enabled + import json + + body = json.loads(response.get_body().decode("utf-8")) + assert "agents" in body + assert len(body["agents"]) == 1 + assert "mcp_tool_enabled" in body["agents"][0] + assert body["agents"][0]["mcp_tool_enabled"] is True + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/README.md b/python/samples/getting_started/azure_functions/08_mcp_server/README.md new file mode 100644 index 0000000000..ed8ecfb1e9 --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/README.md @@ -0,0 +1,187 @@ +# Agent as MCP Tool Sample + +This sample demonstrates how to configure AI agents to be accessible as both HTTP endpoints and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) tools, enabling flexible integration patterns for AI agent consumption. + +## Key Concepts Demonstrated + +- **Multi-trigger Agent Configuration**: Configure agents to support HTTP triggers, MCP tool triggers, or both +- **Microsoft Agent Framework Integration**: Use the framework to define AI agents with specific roles and capabilities +- **Flexible Agent Registration**: Register agents with customizable trigger configurations +- **MCP Server Hosting**: Expose agents as MCP tools for consumption by MCP-compatible clients + +## Sample Architecture + +This sample creates three agents with different trigger configurations: + +| Agent | Role | HTTP Trigger | MCP Tool Trigger | Description | +|-------|------|--------------|------------------|-------------| +| **Joker** | Comedy specialist | ✅ Enabled | ❌ Disabled | Accessible only via HTTP requests | +| **StockAdvisor** | Financial data | ❌ Disabled | ✅ Enabled | Accessible only as MCP tool | +| **PlantAdvisor** | Indoor plant recommendations | ✅ Enabled | ✅ Enabled | Accessible via both HTTP and MCP | + +## Environment Setup + +See the [README.md](../README.md) file in the parent directory for complete setup instructions, including: + +- Prerequisites installation +- Azure OpenAI configuration +- Durable Task Scheduler setup +- Storage emulator configuration + +## Configuration + +Update your `local.settings.json` with your Azure OpenAI credentials: + +```json +{ + "Values": { + "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "your-deployment-name", + "AZURE_OPENAI_KEY": "your-api-key-if-not-using-rbac" + } +} +``` + +## Running the Sample + +1. **Start the Function App**: + ```bash + cd python/samples/getting_started/azure_functions/08_mcp_server + func start + ``` + +2. **Note the MCP Server Endpoint**: When the app starts, you'll see the MCP server endpoint in the terminal output. It will look like: + ``` + MCP server endpoint: http://localhost:7071/runtime/webhooks/mcp + ``` + +## Testing MCP Tool Integration + +### Using MCP Inspector + +1. Install the [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) +2. Connect using the MCP server endpoint from your terminal output +3. Select **"Streamable HTTP"** as the transport method +4. Test the available MCP tools: + - `StockAdvisor` - Available only as MCP tool + - `PlantAdvisor` - Available as both HTTP and MCP tool + +### Using Other MCP Clients + +Any MCP-compatible client can connect to the server endpoint and utilize the exposed agent tools. The agents will appear as callable tools within the MCP protocol. + +## Testing HTTP Endpoints + +For agents with HTTP triggers enabled (Joker and PlantAdvisor), you can test them using curl: + +```bash +# Test Joker agent (HTTP only) +curl -X POST http://localhost:7071/api/agents/Joker/run \ + -H "Content-Type: application/json" \ + -d '{"message": "Tell me a joke"}' + +# Test PlantAdvisor agent (HTTP and MCP) +curl -X POST http://localhost:7071/api/agents/PlantAdvisor/run \ + -H "Content-Type: application/json" \ + -d '{"message": "Recommend an indoor plant"}' +``` + +Note: StockAdvisor does not have HTTP endpoints and is only accessible via MCP tool triggers. + +## Expected Output + +**HTTP Responses** will be returned directly to your HTTP client. + +**MCP Tool Responses** will be visible in: +- The terminal where `func start` is running +- Your MCP client interface +- The DTS dashboard at `http://localhost:8080` (if using Durable Task Scheduler) + +## Health Check + +Check the health endpoint to see which agents have which triggers enabled: + +```bash +curl http://localhost:7071/api/health +``` + +Expected response: + +```json +{ + "status": "healthy", + "agents": [ + { + "name": "Joker", + "type": "Agent", + "http_endpoint_enabled": true, + "mcp_tool_enabled": false + }, + { + "name": "StockAdvisor", + "type": "Agent", + "http_endpoint_enabled": false, + "mcp_tool_enabled": true + }, + { + "name": "PlantAdvisor", + "type": "Agent", + "http_endpoint_enabled": true, + "mcp_tool_enabled": true + } + ], + "agent_count": 3 +} +``` + +## Code Structure + +The sample shows how to enable MCP tool triggers with flexible agent configuration: + +```python +from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient + +# Create Azure OpenAI Chat Client +chat_client = AzureOpenAIChatClient() + +# Define agents with different roles +joker_agent = chat_client.create_agent( + name="Joker", + instructions="You are good at telling jokes.", +) + +stock_agent = chat_client.create_agent( + name="StockAdvisor", + instructions="Check stock prices.", +) + +plant_agent = chat_client.create_agent( + name="PlantAdvisor", + instructions="Recommend plants.", + description="Get plant recommendations.", +) + +# Create the AgentFunctionApp +app = AgentFunctionApp(enable_health_check=True) + +# Configure agents with different trigger combinations: +# HTTP trigger only (default) +app.add_agent(joker_agent) + +# MCP tool trigger only (HTTP disabled) +app.add_agent(stock_agent, enable_http_endpoint=False, enable_mcp_tool_trigger=True) + +# Both HTTP and MCP tool triggers enabled +app.add_agent(plant_agent, enable_http_endpoint=True, enable_mcp_tool_trigger=True) +``` + +This automatically creates the following endpoints based on agent configuration: +- `POST /api/agents/{AgentName}/run` - HTTP endpoint (when `enable_http_endpoint=True`) +- MCP tool triggers for agents with `enable_mcp_tool_trigger=True` +- `GET /api/health` - Health check endpoint showing agent configurations + +## Learn More + +- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) +- [Microsoft Agent Framework Documentation](https://github.com/microsoft/agent-framework) +- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/) diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/function_app.py b/python/samples/getting_started/azure_functions/08_mcp_server/function_app.py new file mode 100644 index 0000000000..14bd230b2a --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/function_app.py @@ -0,0 +1,63 @@ +""" +Example showing how to configure AI agents with different trigger configurations. + +This sample demonstrates how to configure agents to be accessible as both HTTP endpoints +and Model Context Protocol (MCP) tools, enabling flexible integration patterns for AI agent +consumption. + +Key concepts demonstrated: +- Multi-trigger Agent Configuration: Configure agents to support HTTP triggers, MCP tool triggers, or both +- Microsoft Agent Framework Integration: Use the framework to define AI agents with specific roles +- Flexible Agent Registration: Register agents with customizable trigger configurations + +This sample creates three agents with different trigger configurations: +- Joker: HTTP trigger only (default) +- StockAdvisor: MCP tool trigger only (HTTP disabled) +- PlantAdvisor: Both HTTP and MCP tool triggers enabled + +Required environment variables: +- AZURE_OPENAI_ENDPOINT: Your Azure OpenAI endpoint +- AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: Your Azure OpenAI deployment name + +Authentication uses AzureCliCredential (Azure Identity). +""" + +from agent_framework.azure import AgentFunctionApp, AzureOpenAIChatClient + +# Create Azure OpenAI Chat Client +# This uses AzureCliCredential for authentication (requires 'az login') +chat_client = AzureOpenAIChatClient() + +# Define three AI agents with different roles +# Agent 1: Joker - HTTP trigger only (default) +agent1 = chat_client.create_agent( + name="Joker", + instructions="You are good at telling jokes.", +) + +# Agent 2: StockAdvisor - MCP tool trigger only +agent2 = chat_client.create_agent( + name="StockAdvisor", + instructions="Check stock prices.", +) + +# Agent 3: PlantAdvisor - Both HTTP and MCP tool triggers +agent3 = chat_client.create_agent( + name="PlantAdvisor", + instructions="Recommend plants.", + description="Get plant recommendations.", +) + +# Create the AgentFunctionApp with selective trigger configuration +app = AgentFunctionApp( + enable_health_check=True, +) + +# Agent 1: HTTP trigger only (default) +app.add_agent(agent1) + +# Agent 2: Disable HTTP trigger, enable MCP tool trigger only +app.add_agent(agent2, enable_http_endpoint=False, enable_mcp_tool_trigger=True) + +# Agent 3: Enable both HTTP and MCP tool triggers +app.add_agent(agent3, enable_http_endpoint=True, enable_mcp_tool_trigger=True) diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/host.json b/python/samples/getting_started/azure_functions/08_mcp_server/host.json new file mode 100644 index 0000000000..b7e5ad1c0b --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/host.json @@ -0,0 +1,7 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/local.settings.json.template b/python/samples/getting_started/azure_functions/08_mcp_server/local.settings.json.template new file mode 100644 index 0000000000..6c98a7d1cb --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/local.settings.json.template @@ -0,0 +1,10 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "" + } +} diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/requirements.txt b/python/samples/getting_started/azure_functions/08_mcp_server/requirements.txt new file mode 100644 index 0000000000..39ad8a124f --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/requirements.txt @@ -0,0 +1,2 @@ +agent-framework-azurefunctions +azure-identity