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"])
@@ -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/)
@@ -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)
@@ -0,0 +1,7 @@
{
"version": "2.0",
"extensionBundle": {
"id": "Microsoft.Azure.Functions.ExtensionBundle",
"version": "[4.*, 5.0.0)"
}
}
@@ -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_ENDPOINT>",
"AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "<AZURE_OPENAI_CHAT_DEPLOYMENT_NAME>"
}
}
@@ -0,0 +1,2 @@
agent-framework-azurefunctions
azure-identity