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 <13853051+dmytrostruk@users.noreply.github.com>

* 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 <175728472+Copilot@users.noreply.github.com>

* Apply suggestions from code review

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* 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 <175728472+Copilot@users.noreply.github.com>

* 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 <larohra@microsoft.com>
Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
Co-authored-by: Chris Gillum <cgillum@microsoft.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Gavin Aguiar
2025-11-25 12:50:02 -06:00
committed by GitHub
Unverified
parent b8260ae24c
commit b1c210c9d8
7 changed files with 775 additions and 30 deletions
@@ -9,6 +9,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution.
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 @@ logger = get_logger("agent_framework.azurefunctions")
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 @@ if TYPE_CHECKING:
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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
: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 @@ class AgentFunctionApp(DFAppBase):
# 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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
"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 @@ class AgentFunctionApp(DFAppBase):
"""
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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
)
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 @@ class AgentFunctionApp(DFAppBase):
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 @@ class AgentFunctionApp(DFAppBase):
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,
)
@@ -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 @@ class TestAgentFunctionAppInit:
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 @@ class TestAgentFunctionAppInit:
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 @@ class TestAgentFunctionAppInit:
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 @@ class TestAgentFunctionAppSetup:
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 @@ class TestAgentFunctionAppSetup:
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 @@ class TestHttpRunRoute:
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"])