From 9150176c13c46ed7b02472410d894edfd2014e69 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Fri, 7 Nov 2025 15:32:05 -0600 Subject: [PATCH 1/3] Agents as MCP tools --- .../agent_framework_azurefunctions/_app.py | 389 ++++++++++- .../mcp/__init__.py | 31 + .../mcp/_endpoints.py | 637 ++++++++++++++++++ .../mcp/_extension.py | 238 +++++++ .../mcp/_models.py | 108 +++ .../azure_functions/08_mcp_server/README.md | 185 +++++ .../azure_functions/08_mcp_server/demo.http | 83 +++ .../08_mcp_server/function_app.py | 85 +++ .../azure_functions/08_mcp_server/host.json | 7 + .../local.settings.json.template | 10 + .../08_mcp_server/requirements.txt | 2 + 11 files changed, 1744 insertions(+), 31 deletions(-) create mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py create mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py create mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py create mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py create mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/README.md create mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/demo.http create mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/function_app.py create mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/host.json create mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/local.settings.json.template create mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/requirements.txt diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 2187f6c031..97a32ba84b 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -9,7 +9,7 @@ import json import re from collections.abc import Mapping -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast import azure.durable_functions as df import azure.functions as func @@ -21,6 +21,9 @@ from ._models import AgentSessionId, ChatRole, RunRequest from ._state import AgentState +if TYPE_CHECKING: + from .mcp._extension import MCPServerExtension + logger = get_logger("agent_framework.azurefunctions") SESSION_ID_FIELD: str = "sessionId" @@ -70,12 +73,16 @@ class AgentFunctionApp(df.DFApp): This creates: - HTTP trigger endpoint for each agent's requests (if enabled) - Durable entity for each agent's state management and execution + - MCP tool trigger for agents (if enabled) - Full access to all Azure Functions capabilities Attributes: 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_http_endpoints: Whether HTTP endpoints are created for agents by default + enable_mcp_tool_triggers: Whether MCP tool triggers are created for agents by default + agent_http_endpoint_flags: Per-agent HTTP endpoint settings + agent_mcp_tool_flags: Per-agent MCP tool trigger settings max_poll_retries: Maximum polling attempts when waiting for responses poll_interval_seconds: Delay (seconds) between polling attempts """ @@ -83,7 +90,9 @@ class AgentFunctionApp(df.DFApp): agents: dict[str, AgentProtocol] enable_health_check: bool enable_http_endpoints: bool + enable_mcp_tool_triggers: bool agent_http_endpoint_flags: dict[str, bool] + agent_mcp_tool_flags: dict[str, bool] def __init__( self, @@ -91,6 +100,7 @@ def __init__( http_auth_level: func.AuthLevel = func.AuthLevel.ANONYMOUS, enable_health_check: bool = True, enable_http_endpoints: bool = True, + enable_mcp_tool_triggers: bool = False, max_poll_retries: int = 10, poll_interval_seconds: float = 0.5, default_callback: AgentResponseCallbackProtocol | None = None, @@ -101,13 +111,15 @@ def __init__( agents: List of agent instances to register http_auth_level: HTTP authentication level (default: ANONYMOUS) enable_health_check: Enable built-in health check endpoint (default: True) - enable_http_endpoints: Enable HTTP endpoints for agents (default: True) + enable_http_endpoints: Enable HTTP endpoints for agents by default (default: True) + enable_mcp_tool_triggers: Enable MCP tool triggers for agents by default (default: False) max_poll_retries: Maximum number of polling attempts when waiting for a response poll_interval_seconds: Delay (in seconds) between polling attempts default_callback: Optional callback invoked for agents without specific callbacks Note: If no agents are provided, they can be added later using add_agent(). + Per-agent settings in add_agent() override these global defaults. """ logger.debug("[AgentFunctionApp] Initializing with Durable Entities...") @@ -116,9 +128,11 @@ def __init__( # Initialize agents dictionary self.agents = {} - self.agent_http_endpoint_flags = {} self.enable_health_check = enable_health_check self.enable_http_endpoints = enable_http_endpoints + self.enable_mcp_tool_triggers = enable_mcp_tool_triggers + self.agent_http_endpoint_flags = {} + self.agent_mcp_tool_flags = {} self.default_callback = default_callback try: @@ -150,6 +164,7 @@ def add_agent( 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. @@ -157,8 +172,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. + If None, uses the app-level enable_http_endpoints setting. + enable_mcp_tool_trigger: Optional flag to enable/disable MCP tool trigger for this agent. + If None, uses the app-level enable_mcp_tool_triggers setting. Raises: ValueError: If the agent doesn't have a 'name' attribute or if an agent @@ -172,29 +189,27 @@ def add_agent( if name in self.agents: raise ValueError(f"Agent with name '{name}' is already registered. Each agent must have a unique name.") - effective_enable_http_endpoint = ( + # Resolve effective settings (per-agent overrides global) + effective_enable_http = ( self.enable_http_endpoints if enable_http_endpoint is None else self._coerce_to_bool(enable_http_endpoint) ) + effective_enable_mcp = ( + self.enable_mcp_tool_triggers + 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}") - logger.debug( - "[AgentFunctionApp] HTTP endpoint %s for agent '%s'", - "enabled" if effective_enable_http_endpoint else "disabled", - name, - ) + logger.debug(f"[AgentFunctionApp] HTTP endpoint: {'enabled' if effective_enable_http else 'disabled'}") + logger.debug(f"[AgentFunctionApp] MCP tool trigger: {'enabled' if effective_enable_mcp else 'disabled'}") self.agents[name] = agent - self.agent_http_endpoint_flags[name] = effective_enable_http_endpoint + self.agent_http_endpoint_flags[name] = effective_enable_http + self.agent_mcp_tool_flags[name] = effective_enable_mcp effective_callback = callback or self.default_callback - self._setup_agent_functions( - agent, - name, - effective_callback, - effective_enable_http_endpoint, - ) + self._setup_agent_functions(agent, name, effective_callback, effective_enable_http, effective_enable_mcp) logger.debug(f"[AgentFunctionApp] Agent '{name}' added successfully") @@ -204,27 +219,36 @@ 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}'...") + # Set up HTTP endpoints if enabled if enable_http_endpoint: self._setup_http_run_route(agent_name) + self._setup_get_state_route(agent_name) else: - logger.debug( - "[AgentFunctionApp] HTTP run route disabled for agent '%s'", - agent_name, - ) + logger.debug(f"[AgentFunctionApp] HTTP endpoints disabled for agent '{agent_name}'") + + # Always set up entity (for state management) self._setup_agent_entity(agent, agent_name, callback) + # Set up MCP tool trigger if enabled + if enable_mcp_tool_trigger: + agent_description = getattr(agent, "description", None) + 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. @@ -355,6 +379,258 @@ 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_get_state_route(self, agent_name: str) -> None: + """Register the GET route for retrieving conversation state. + + Args: + agent_name: The agent name (used for both routing and entity identification) + """ + state_function_name = self._build_function_name(agent_name, "state") + + @self.function_name(state_function_name) + @self.route( + route=f"agents/{agent_name}/{{{SESSION_ID_FIELD}}}", + methods=["GET"], + ) + @self.durable_client_input(client_name="client") + async def get_conversation_state( + req: func.HttpRequest, client: df.DurableOrchestrationClient + ) -> func.HttpResponse: + """GET endpoint to retrieve conversation state for a given sessionId.""" + session_key = req.route_params.get(SESSION_ID_FIELD) + + logger.debug(f"[GET State] Retrieving state for session: {session_key}") + + try: + session_id = AgentSessionId(name=agent_name, key=session_key) + entity_instance_id = session_id.to_entity_id() + + state_response = await client.read_entity_state(entity_instance_id) + + if not state_response or not state_response.entity_exists: + logger.warning(f"[GET State] Session not found: {session_key}") + return func.HttpResponse( + json.dumps({"error": "Session not found"}), + status_code=404, + mimetype="application/json", + ) + + state = state_response.entity_state + if isinstance(state, str): + state = json.loads(state) if state else {} + + logger.debug(f"[GET State] Found conversation with {state.get('message_count', 0)} messages") + + return func.HttpResponse(json.dumps(state, indent=2), status_code=200, mimetype="application/json") + + except Exception as exc: + logger.error(f"[GET State] Error: {str(exc)}", exc_info=True) + return func.HttpResponse( + json.dumps({"error": str(exc)}), status_code=500, mimetype="application/json" + ) + + def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None) -> None: + """Register an MCP tool trigger for an agent. + + 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 = f"mcptool_{agent_name}" + + 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, + }, + ] + ) + + @self.function_name(mcp_function_name) + @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, + ) + @self.durable_client_input(client_name="client") + async def mcp_tool_handler(context: Any, client: df.DurableOrchestrationClient) -> str: + """Handle MCP tool invocation for the agent.""" + return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) + + logger.debug(f"[AgentFunctionApp] Registered MCP tool trigger: {agent_name}") + + async def _handle_mcp_tool_invocation( + self, agent_name: str, context: Any, client: df.DurableOrchestrationClient + ) -> str: + """Handle an MCP tool invocation.""" + arguments = context.get("arguments", {}) if isinstance(context, dict) else {} + + 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.") + + thread_id = arguments.get("threadId") + + if thread_id and isinstance(thread_id, str) and thread_id.strip(): + try: + session_id = AgentSessionId.parse(thread_id) + except Exception: + session_id = AgentSessionId(name=agent_name, key=thread_id) + else: + session_id = AgentSessionId.with_random_key(agent_name) + + entity_instance_id = session_id.to_entity_id() + + correlation_id = self._generate_unique_id() + run_request = self._build_request_data( + req_body={"message": query, "role": "user"}, + message=query, + conversation_id=str(session_id), + correlation_id=correlation_id, + ) + + logger.debug(f"[MCP Tool] Invoking agent '{agent_name}' with query: {query[:50]}...") + + await client.signal_entity(entity_instance_id, "run_agent", run_request) + + try: + result = await self._get_response_from_entity( + client=client, + entity_instance_id=entity_instance_id, + correlation_id=correlation_id, + message=query, + session_key=str(session_id), + ) + + if result.get("status") == "success": + response_text = result.get("response", "No response") + logger.debug(f"[MCP Tool] Agent '{agent_name}' responded successfully") + return response_text + else: + error_msg = result.get("error", "Unknown error") + logger.error(f"[MCP Tool] Agent '{agent_name}' execution failed: {error_msg}") + raise RuntimeError(f"Agent execution failed: {error_msg}") + + except Exception as exc: + logger.error(f"[MCP Tool] Error invoking agent '{agent_name}': {str(exc)}", exc_info=True) + raise + + async def _get_response_from_entity( + self, + client: df.DurableOrchestrationClient, + entity_instance_id: df.EntityId, + correlation_id: str, + message: str, + session_key: str, + ) -> dict[str, Any]: + """Poll the entity state until a response is available or timeout occurs.""" + import asyncio + + max_retries = 120 + retry_count = 0 + result: dict[str, Any] | None = None + + logger.debug(f"[Polling] Waiting for response with correlation ID: {correlation_id}") + + while retry_count < max_retries: + await asyncio.sleep(0.5) + + result = await self._poll_entity_for_response( + client=client, + entity_instance_id=entity_instance_id, + correlation_id=correlation_id, + message=message, + session_key=session_key, + ) + if result is not None: + break + + logger.debug(f"[Polling] Response not available yet (retry {retry_count})") + retry_count += 1 + + if result is not None: + return result + + logger.warning( + f"[Polling] Response with correlation ID {correlation_id} " + f"not found in time (waited {max_retries * 0.5} seconds)" + ) + return await self._build_timeout_result(message=message, session_key=session_key, correlation_id=correlation_id) + + async def _poll_entity_for_response( + self, + client: df.DurableOrchestrationClient, + entity_instance_id: df.EntityId, + correlation_id: str, + message: str, + session_key: str, + ) -> dict[str, Any] | None: + """Poll entity once for a response matching the correlation ID.""" + result: dict[str, Any] | None = None + try: + state = await self._read_cached_state(client, entity_instance_id) + + if state is None: + return None + + agent_response = state.try_get_agent_response(correlation_id) + if agent_response: + result = self._build_success_result( + response_data=agent_response, + message=message, + session_key=session_key, + correlation_id=correlation_id, + state=state, + ) + logger.debug(f"[Polling] Found response for correlation ID: {correlation_id}") + + except Exception as exc: + logger.warning(f"[Polling] Error reading entity state: {exc}") + + return result + + async def _build_timeout_result(self, message: str, session_key: str, correlation_id: str) -> dict[str, Any]: + """Create the timeout response.""" + return { + "response": "Agent is still processing or timed out...", + "message": message, + SESSION_ID_FIELD: session_key, + "status": "timeout", + "correlationId": correlation_id, + } + + def _build_success_result( + self, + response_data: dict[str, Any], + message: str, + session_key: str, + correlation_id: str, + state: AgentState, + ) -> dict[str, Any]: + """Build the success result returned to the caller.""" + return { + "response": response_data.get("response", ""), + "message": message, + SESSION_ID_FIELD: session_key, + "status": "success", + "correlationId": correlation_id, + "message_count": state.message_count, + "timestamp": response_data.get("timestamp"), + } + def _setup_health_route(self) -> None: """Register the optional health check route.""" @@ -365,10 +641,8 @@ def health_check(req: func.HttpRequest) -> func.HttpResponse: { "name": name, "type": type(agent).__name__, - "httpEndpointEnabled": self.agent_http_endpoint_flags.get( - name, - self.enable_http_endpoints, - ), + "httpEndpointEnabled": self.agent_http_endpoint_flags.get(name, self.enable_http_endpoints), + "mcpToolTriggerEnabled": self.agent_mcp_tool_flags.get(name, self.enable_mcp_tool_triggers), } for name, agent in self.agents.items() ] @@ -644,6 +918,59 @@ def _coerce_chat_role(self, value: Any) -> ChatRole: logger.warning("[AgentFunctionApp] Invalid role '%s'; defaulting to user", value) return ChatRole.USER + async def _read_cached_state( + self, + client: df.DurableOrchestrationClient, + entity_instance_id: df.EntityId, + ) -> AgentState | None: + """Read the entity state from storage. + + Args: + client: Durable orchestration client + entity_instance_id: Entity ID to read + + Returns: + AgentState if entity exists, None otherwise + """ + state_response = await client.read_entity_state(entity_instance_id) + if not state_response or not state_response.entity_exists: + return None + + state_payload = state_response.entity_state + if not isinstance(state_payload, dict): + return None + + agent_state = AgentState() + agent_state.restore_state(state_payload) + return agent_state + + def register_mcp_server(self, mcp_extension: "MCPServerExtension") -> None: + """Register MCP server endpoints. + + This enables the Model Context Protocol (MCP) for exposing agents as tools + that can be used by MCP clients like Claude Desktop, Cursor, etc. + + Args: + mcp_extension: MCPServerExtension instance configured with desired settings + + Example: + ```python + from agent_framework.azurefunctions.mcp import MCPServerExtension + + mcp = MCPServerExtension(app) + app.register_mcp_server(mcp) + ``` + + Note: + This should be called after all agents are registered via add_agent(). + """ + logger.info(f"Registering MCP server with route prefix: {mcp_extension.route_prefix}") + logger.info(f"Exposing {len(mcp_extension.get_exposed_agents())} agents as MCP tools") + + mcp_extension.register() + + logger.info("MCP server registered successfully") + def _coerce_to_bool(self, value: Any) -> bool: """Convert various representations into a boolean flag.""" if isinstance(value, bool): diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py new file mode 100644 index 0000000000..5eae4bc5c3 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py @@ -0,0 +1,31 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""MCP (Model Context Protocol) Server Integration for Agent Framework. + +This module provides the MCPServerExtension class to easily expose durable agents +as MCP tools, allowing them to be used by any MCP-compatible client (Claude Desktop, +Cursor, VSCode, etc.). + +Example: + ```python + from agent_framework.azurefunctions import AgentFunctionApp + from agent_framework.azurefunctions.mcp import MCPServerExtension + + app = AgentFunctionApp("MyApp") + app.add_agent(weather_agent) + + # Enable MCP server - all agents become MCP tools + mcp = MCPServerExtension(app) + app.register_mcp_server(mcp) + ``` +""" + +from ._extension import MCPServerExtension +from ._models import MCPCallResult, MCPResource, MCPTool + +__all__ = [ + "MCPServerExtension", + "MCPTool", + "MCPResource", + "MCPCallResult", +] diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py new file mode 100644 index 0000000000..847b80fbe2 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py @@ -0,0 +1,637 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""MCP Protocol HTTP Endpoints. + +This module implements the HTTP handlers for MCP protocol operations. +""" + +import json +import uuid +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any + +import azure.durable_functions as df +import azure.functions as func +from agent_framework import get_logger + +if TYPE_CHECKING: + from ._extension import MCPServerExtension + +logger = get_logger("agent_framework.azurefunctions.mcp") + + +def create_list_tools_endpoint( + extension: "MCPServerExtension", +) -> Callable[[func.HttpRequest], Awaitable[func.HttpResponse]]: + """Create endpoint to list available tools (agents). + + Returns: + Async function that handles GET/POST requests to list MCP tools + """ + + async def list_tools(req: func.HttpRequest) -> func.HttpResponse: + """MCP endpoint: List available tools. + + Returns JSON response with tool definitions for all exposed agents. + + Example response: + { + "tools": [ + { + "name": "WeatherAgent", + "description": "Get weather information", + "inputSchema": {...} + } + ] + } + """ + logger.info("MCP: Listing available tools") + + tools = [] + + for agent_name in extension.get_exposed_agents(): + agent = extension.app.agents.get(agent_name) + if not agent: + logger.warning(f"Agent '{agent_name}' not found in app registry") + continue + + # Get custom configuration or use defaults + config = extension._tool_configs.get(agent_name, {}) + + tool = { + "name": agent_name, + "description": config.get("description") or f"Invoke {agent_name} agent", + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to send to the agent", + }, + "sessionId": { + "type": "string", + "description": "Optional session ID for conversation continuity", + }, + "enable_tool_calls": { + "type": "boolean", + "description": "Whether to allow the agent to use tools (default: true)", + }, + }, + "required": ["message"], + }, + } + + # Add custom fields if configured + if config.get("display_name"): + tool["displayName"] = config["display_name"] + if config.get("category"): + tool["category"] = config["category"] + if config.get("examples"): + tool["examples"] = config["examples"] + + tools.append(tool) + + logger.info(f"MCP: Returning {len(tools)} tools") + + return func.HttpResponse( + json.dumps({"tools": tools}), mimetype="application/json", status_code=200 + ) + + return list_tools + + +def create_call_tool_endpoint( + extension: "MCPServerExtension", +) -> Callable[[func.HttpRequest, df.DurableOrchestrationClient], Awaitable[func.HttpResponse]]: + """Create endpoint to invoke a tool (agent). + + Returns: + Async function that handles POST requests to call MCP tools + """ + + async def call_tool( + req: func.HttpRequest, client: df.DurableOrchestrationClient + ) -> func.HttpResponse: + """MCP endpoint: Call a tool (invoke an agent). + + Expects JSON request body with: + { + "name": "WeatherAgent", + "arguments": { + "message": "What's the weather?", + "sessionId": "optional-session-id", + "enable_tool_calls": true + } + } + + Returns: + { + "content": [{"type": "text", "text": "Response text"}], + "metadata": { + "sessionId": "session-id", + "messageCount": 1, + "timestamp": "..." + } + } + """ + try: + body = req.get_json() + except ValueError: + logger.error("MCP: Invalid JSON in request body") + return func.HttpResponse( + json.dumps({"error": "Invalid JSON"}), + mimetype="application/json", + status_code=400, + ) + + # Extract tool name and arguments + tool_name = body.get("name") + arguments = body.get("arguments", {}) + + logger.info(f"MCP: Call tool request - name={tool_name}") + + if not tool_name: + logger.error("MCP: Missing tool name in request") + return func.HttpResponse( + json.dumps({"error": "Missing tool name"}), + mimetype="application/json", + status_code=400, + ) + + # Check if agent exists and is exposed + if tool_name not in extension.get_exposed_agents(): + logger.error(f"MCP: Tool '{tool_name}' not found or not exposed") + return func.HttpResponse( + json.dumps({"error": f"Tool '{tool_name}' not found"}), + mimetype="application/json", + status_code=404, + ) + + # Get agent + agent = extension.app.agents.get(tool_name) + if not agent: + logger.error(f"MCP: Agent '{tool_name}' not found in registry") + return func.HttpResponse( + json.dumps({"error": f"Agent '{tool_name}' not found"}), + mimetype="application/json", + status_code=404, + ) + + # Extract parameters + message = arguments.get("message") + session_id = arguments.get("sessionId") + enable_tool_calls = arguments.get("enable_tool_calls", True) + + if not message: + logger.error("MCP: Missing 'message' in arguments") + return func.HttpResponse( + json.dumps({"error": "Missing 'message' in arguments"}), + mimetype="application/json", + status_code=400, + ) + + # Create or parse session ID + from .._models import AgentSessionId + + if session_id: + try: + session = AgentSessionId.parse(session_id) + logger.info(f"MCP: Using existing session - {session_id}") + except Exception as e: + logger.error(f"MCP: Invalid session ID format - {session_id}: {e}") + return func.HttpResponse( + json.dumps({"error": f"Invalid session ID format: {str(e)}"}), + mimetype="application/json", + status_code=400, + ) + else: + session = AgentSessionId.with_random_key(tool_name) + logger.info(f"MCP: Created new session - {session}") + + # Invoke agent via durable entity + entity_id = session.to_entity_id() + + try: + logger.info(f"MCP: Invoking entity - {entity_id}") + + # Generate correlation ID for tracking this request + correlation_id = str(uuid.uuid4()) + + # Signal the entity to run the agent + await client.signal_entity( + entity_id, + "run_agent", + { + "message": message, + "enable_tool_calls": enable_tool_calls, + "mcp_invocation": True, + "correlation_id": correlation_id, + "conversation_id": session.key, + }, + ) + + logger.info(f"MCP: Signal sent to entity - {entity_id}") + + # Poll for response using the app's method + result = await extension.app._get_response_from_entity( + client=client, + entity_instance_id=entity_id, + correlation_id=correlation_id, + message=message, + session_key=session.key, + ) + + logger.info(f"MCP: Entity invocation successful - {entity_id}") + + # Format response for MCP + response_text = result.get("response", "") + if not response_text and result.get("error"): + response_text = f"Error: {result.get('error')}" + + response: dict[str, Any] = { + "content": [{"type": "text", "text": response_text}], + "metadata": { + "sessionId": str(session), + "messageCount": result.get("message_count", 0), + "agentName": tool_name, + }, + } + + # Add timestamp if available + if result.get("timestamp"): + response["metadata"]["timestamp"] = result["timestamp"] + + # Mark as error if agent returned error + if result.get("status") == "error": + response["isError"] = True + + return func.HttpResponse( + json.dumps(response), mimetype="application/json", status_code=200 + ) + + except Exception as e: + logger.error(f"MCP: Error invoking entity - {entity_id}: {str(e)}", exc_info=True) + return func.HttpResponse( + json.dumps( + { + "content": [{"type": "text", "text": f"Error invoking agent: {str(e)}"}], + "isError": True, + "metadata": {"sessionId": str(session), "agentName": tool_name}, + } + ), + mimetype="application/json", + status_code=500, + ) + + return call_tool + + +def create_list_resources_endpoint( + extension: "MCPServerExtension", +) -> Callable[[func.HttpRequest], Awaitable[func.HttpResponse]]: + """Create endpoint to list available resources (conversation histories).""" + + async def list_resources(req: func.HttpRequest) -> func.HttpResponse: + """MCP endpoint: List resources.""" + logger.info("MCP: Listing resources (not yet implemented)") + + resources: list[Any] = [] + + return func.HttpResponse( + json.dumps({"resources": resources}), mimetype="application/json", status_code=200 + ) + + return list_resources + + +def create_read_resource_endpoint( + extension: "MCPServerExtension", +) -> Callable[ + [func.HttpRequest, str, df.DurableOrchestrationClient], Awaitable[func.HttpResponse] +]: + """Create endpoint to read a resource (conversation history).""" + + async def read_resource( + req: func.HttpRequest, resource_uri: str, client: df.DurableOrchestrationClient + ) -> func.HttpResponse: + """MCP endpoint: Read resource.""" + logger.info(f"MCP: Reading resource {resource_uri} (not yet implemented)") + + return func.HttpResponse( + json.dumps({"error": "Resources not yet implemented"}), + mimetype="application/json", + status_code=501, # Not Implemented + ) + + return read_resource + + +def create_list_prompts_endpoint( + extension: "MCPServerExtension", +) -> Callable[[func.HttpRequest], Awaitable[func.HttpResponse]]: + """Create endpoint to list available prompts.""" + + async def list_prompts(req: func.HttpRequest) -> func.HttpResponse: + """MCP endpoint: List prompts.""" + logger.info("MCP: Listing prompts (not yet implemented)") + + prompts: list[Any] = [] + + return func.HttpResponse( + json.dumps({"prompts": prompts}), mimetype="application/json", status_code=200 + ) + + return list_prompts + + +def create_jsonrpc_handler( + extension: "MCPServerExtension", +) -> Callable[[func.HttpRequest, df.DurableOrchestrationClient], Awaitable[func.HttpResponse]]: + """Create JSON-RPC 2.0 message handler for MCP protocol. + + This handles the full MCP protocol including initialize handshake. + MCP clients send JSON-RPC messages to the base endpoint. + """ + + async def handle_jsonrpc( + req: func.HttpRequest, client: df.DurableOrchestrationClient + ) -> func.HttpResponse: + """Handle JSON-RPC 2.0 messages from MCP clients. + + Supported methods: + - initialize: Handshake to establish connection + - tools/list: List available tools + - tools/call: Invoke a tool + """ + try: + body = req.get_json() + except ValueError: + logger.error("MCP JSON-RPC: Invalid JSON in request") + return func.HttpResponse( + json.dumps( + {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} + ), + mimetype="application/json", + status_code=400, + ) + + # Extract JSON-RPC fields + jsonrpc_version = body.get("jsonrpc") + method = body.get("method") + params = body.get("params", {}) + request_id = body.get("id") + + logger.info(f"MCP JSON-RPC: method={method}, id={request_id}") + + # Validate JSON-RPC version + if jsonrpc_version != "2.0": + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": { + "code": -32600, + "message": "Invalid Request - must be JSON-RPC 2.0", + }, + "id": request_id, + } + ), + mimetype="application/json", + status_code=400, + ) + + # Handle notifications (requests without an id) + if request_id is None: + # Notifications don't expect a response + if method == "notifications/initialized": + logger.info("MCP JSON-RPC: Client initialized notification received") + return func.HttpResponse(status_code=204) # No Content + elif method and method.startswith("notifications/"): + logger.info(f"MCP JSON-RPC: Notification received: {method}") + return func.HttpResponse(status_code=204) # No Content + else: + # Invalid notification + logger.warning(f"MCP JSON-RPC: Invalid notification: {method}") + return func.HttpResponse(status_code=204) # Still return 204 for notifications + + # Handle different methods + if method == "initialize": + # MCP handshake + logger.info("MCP JSON-RPC: Handling initialize request") + result = { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {}, + "resources": {} if extension.enable_resources else None, + "prompts": {} if extension.enable_prompts else None, + }, + "serverInfo": {"name": "durable-agent-mcp-server", "version": "1.0.0"}, + } + # Remove None capabilities + result["capabilities"] = { + k: v for k, v in result["capabilities"].items() if v is not None + } + + return func.HttpResponse( + json.dumps({"jsonrpc": "2.0", "result": result, "id": request_id}), + mimetype="application/json", + status_code=200, + ) + + elif method == "tools/list": + # List available tools + logger.info("MCP JSON-RPC: Handling tools/list request") + tools = [] + + for agent_name in extension.get_exposed_agents(): + agent = extension.app.agents.get(agent_name) + if not agent: + continue + + config = extension._tool_configs.get(agent_name, {}) + + tool = { + "name": agent_name, + "description": config.get("description") or f"Invoke {agent_name} agent", + "inputSchema": { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "Message to send to the agent", + }, + "sessionId": { + "type": "string", + "description": "Optional session ID for conversation continuity", + }, + }, + "required": ["message"], + }, + } + + if config.get("display_name"): + tool["displayName"] = config["display_name"] + if config.get("category"): + tool["category"] = config["category"] + if config.get("examples"): + tool["examples"] = config["examples"] + + tools.append(tool) + + return func.HttpResponse( + json.dumps({"jsonrpc": "2.0", "result": {"tools": tools}, "id": request_id}), + mimetype="application/json", + status_code=200, + ) + + elif method == "tools/call": + # Invoke a tool + tool_name = params.get("name") + arguments = params.get("arguments", {}) + + logger.info(f"MCP JSON-RPC: Handling tools/call - name={tool_name}") + + if not tool_name: + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32602, "message": "Missing tool name"}, + "id": request_id, + } + ), + mimetype="application/json", + status_code=400, + ) + + # Check if agent exists + if tool_name not in extension.get_exposed_agents(): + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32602, "message": f"Tool '{tool_name}' not found"}, + "id": request_id, + } + ), + mimetype="application/json", + status_code=404, + ) + + # Extract parameters + message = arguments.get("message") + session_id = arguments.get("sessionId") + enable_tool_calls = arguments.get("enable_tool_calls", True) + + if not message: + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32602, "message": "Missing 'message' in arguments"}, + "id": request_id, + } + ), + mimetype="application/json", + status_code=400, + ) + + # Create or parse session ID + from .._models import AgentSessionId + + if session_id: + try: + session = AgentSessionId.parse(session_id) + except Exception as e: + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32602, "message": f"Invalid session ID: {str(e)}"}, + "id": request_id, + } + ), + mimetype="application/json", + status_code=400, + ) + else: + session = AgentSessionId.with_random_key(tool_name) + + # Invoke agent + entity_id = session.to_entity_id() + + try: + # Generate correlation ID for tracking this request + correlation_id = str(uuid.uuid4()) + + # Signal the entity to run the agent + await client.signal_entity( + entity_id, + "run_agent", + { + "message": message, + "enable_tool_calls": enable_tool_calls, + "mcp_invocation": True, + "correlation_id": correlation_id, + "conversation_id": session.key, + }, + ) + + # Poll for response using the app's method + result = await extension.app._get_response_from_entity( + client=client, + entity_instance_id=entity_id, + correlation_id=correlation_id, + message=message, + session_key=session.key, + ) + + response_text = result.get("response", "") + if not response_text and result.get("error"): + response_text = f"Error: {result.get('error')}" + + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "result": { + "content": [{"type": "text", "text": response_text}], + "isError": result.get("status") == "error", + }, + "id": request_id, + } + ), + mimetype="application/json", + status_code=200, + ) + + except Exception as e: + logger.error(f"MCP JSON-RPC: Error invoking tool: {str(e)}", exc_info=True) + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32603, "message": f"Internal error: {str(e)}"}, + "id": request_id, + } + ), + mimetype="application/json", + status_code=500, + ) + + else: + # Method not found + logger.warning(f"MCP JSON-RPC: Unknown method: {method}") + return func.HttpResponse( + json.dumps( + { + "jsonrpc": "2.0", + "error": {"code": -32601, "message": f"Method not found: {method}"}, + "id": request_id, + } + ), + mimetype="application/json", + status_code=404, + ) + + return handle_jsonrpc diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py new file mode 100644 index 0000000000..67175732c1 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py @@ -0,0 +1,238 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""MCP Server Extension for AgentFunctionApp. + +This module provides the MCPServerExtension class that adds MCP protocol support +to durable agents, allowing them to be used as tools by MCP clients. +""" + +from typing import TYPE_CHECKING, Any + +from agent_framework import get_logger + +if TYPE_CHECKING: + from .._app import AgentFunctionApp + +logger = get_logger("agent_framework.azurefunctions.mcp") + + +class MCPServerExtension: + """Extension to expose durable agents as MCP tools. + + This creates HTTP endpoints that implement the MCP protocol, + allowing any MCP client (Claude Desktop, Cursor, etc.) to invoke + agents as tools while maintaining durable state. + + Example: + ```python + from agent_framework.azurefunctions import AgentFunctionApp + from agent_framework.azurefunctions.mcp import MCPServerExtension + + app = AgentFunctionApp("MyApp") + app.add_agent(weather_agent) + + # Enable MCP server - all agents become MCP tools + mcp = MCPServerExtension(app) + app.register_mcp_server(mcp) + ``` + + Advanced Usage: + ```python + # Selective agent exposure + mcp = MCPServerExtension( + app, + expose_agents=["WeatherAgent", "MathAgent"], + route_prefix="/mcp/v1" + ) + + # Customize tool appearance + mcp.configure_tool( + "WeatherAgent", + description="Get detailed weather information", + display_name="Weather Tool", + examples=["What's the weather in Seattle?"] + ) + + app.register_mcp_server(mcp) + ``` + """ + + def __init__( + self, + app: "AgentFunctionApp", + expose_agents: list[str] | None = None, + route_prefix: str = "mcp/v1", + enable_streaming: bool = False, + enable_resources: bool = False, + enable_prompts: bool = False, + auth_level: str = "function", + ) -> None: + """Initialize MCP server extension. + + Args: + app: AgentFunctionApp instance to extend + expose_agents: List of agent names to expose. If None, all agents are exposed + route_prefix: URL prefix for MCP endpoints (default: mcp/v1, becomes /api/mcp/v1) + enable_streaming: Enable SSE streaming + enable_resources: Enable resource endpoints for conversation history + enable_prompts: Enable prompt template endpoints + auth_level: Azure Functions authentication level (anonymous, function, admin) + """ + self.app = app + self.expose_agents = expose_agents + self.route_prefix = route_prefix + self.enable_streaming = enable_streaming + self.enable_resources = enable_resources + self.enable_prompts = enable_prompts + self.auth_level = auth_level + self._tool_configs: dict[str, dict[str, Any]] = {} + + logger.info(f"MCPServerExtension initialized with route_prefix={route_prefix}") + + def configure_tool( + self, + agent_name: str, + description: str | None = None, + display_name: str | None = None, + category: str | None = None, + examples: list[str] | None = None, + ) -> None: + """Configure how an agent appears as an MCP tool. + + Args: + agent_name: Name of the agent to configure + description: Custom description for the tool + display_name: Display name shown to users + category: Category for grouping tools + examples: Example prompts for using the tool + + Example: + ```python + mcp.configure_tool( + "WeatherAgent", + description="Get weather information for any location", + display_name="Weather Information", + category="utilities", + examples=[ + "What's the weather in Seattle?", + "Will it rain tomorrow in Boston?" + ] + ) + ``` + """ + logger.info(f"Configuring MCP tool: {agent_name}") + + self._tool_configs[agent_name] = { + "description": description, + "display_name": display_name, + "category": category, + "examples": examples, + } + + def get_exposed_agents(self) -> list[str]: + """Get list of agents to expose via MCP. + + Returns: + List of agent names that should be available as MCP tools + """ + if self.expose_agents is not None: + # Filter to only include registered agents + exposed = [name for name in self.expose_agents if name in self.app.agents] + if len(exposed) < len(self.expose_agents): + missing = set(self.expose_agents) - set(exposed) + logger.warning(f"Some agents in expose_agents not found: {missing}") + return exposed + + # Expose all registered agents + return list(self.app.agents.keys()) + + def register(self) -> None: + """Register MCP endpoints with the function app. + + This is called by AgentFunctionApp.register_mcp_server() and should not + be called directly by users. + + Registers the following endpoints: + - POST {route_prefix} - JSON-RPC 2.0 handler + - GET/POST {route_prefix}/tools - List available tools + - POST {route_prefix}/call - Invoke a tool + - GET/POST {route_prefix}/resources - List resources (if enabled) + - GET {route_prefix}/resources/{uri} - Read resource (if enabled) + - GET/POST {route_prefix}/prompts - List prompts (if enabled) + """ + from ._endpoints import ( + create_call_tool_endpoint, + create_jsonrpc_handler, + create_list_prompts_endpoint, + create_list_resources_endpoint, + create_list_tools_endpoint, + create_read_resource_endpoint, + ) + + logger.info(f"Registering MCP endpoints with route_prefix={self.route_prefix}") + logger.info(f"Exposing {len(self.get_exposed_agents())} agents as MCP tools") + + # JSON-RPC base endpoint for MCP protocol + logger.info(f"Registering: {self.route_prefix} (JSON-RPC handler)") + jsonrpc_func = create_jsonrpc_handler(self) + jsonrpc_func = self.app.durable_client_input(client_name="client")(jsonrpc_func) + jsonrpc_func = self.app.route( + route=f"{self.route_prefix}", methods=["POST"], auth_level=self.auth_level + )(jsonrpc_func) + + # List tools endpoint (backward compatibility) + logger.info(f"Registering: {self.route_prefix}/tools") + self.app.route( + route=f"{self.route_prefix}/tools", + methods=["GET", "POST"], + auth_level=self.auth_level, + )(create_list_tools_endpoint(self)) + + # Call tool endpoint (needs durable client) + logger.info(f"Registering: {self.route_prefix}/call") + call_tool_func = create_call_tool_endpoint(self) + # Apply decorators + call_tool_func = self.app.durable_client_input(client_name="client")(call_tool_func) + call_tool_func = self.app.route( + route=f"{self.route_prefix}/call", methods=["POST"], auth_level=self.auth_level + )(call_tool_func) + + # Resources endpoints + if self.enable_resources: + logger.info(f"Registering: {self.route_prefix}/resources") + self.app.route( + route=f"{self.route_prefix}/resources", + methods=["GET", "POST"], + auth_level=self.auth_level, + )(create_list_resources_endpoint(self)) + + logger.info(f"Registering: {self.route_prefix}/resources/{{resource_uri}}") + read_resource_func = create_read_resource_endpoint(self) + # Apply decorators (read_resource needs durable client) + read_resource_func = self.app.durable_client_input(client_name="client")( + read_resource_func + ) + read_resource_func = self.app.route( + route=f"{self.route_prefix}/resources/{{resource_uri}}", + methods=["GET"], + auth_level=self.auth_level, + )(read_resource_func) + + # Prompts endpoints + if self.enable_prompts: + logger.info(f"Registering: {self.route_prefix}/prompts") + self.app.route( + route=f"{self.route_prefix}/prompts", + methods=["GET", "POST"], + auth_level=self.auth_level, + )(create_list_prompts_endpoint(self)) + + logger.info("MCP server endpoints registered successfully") + + # Log exposed agents + for agent_name in self.get_exposed_agents(): + config = self._tool_configs.get(agent_name) + if config: + logger.info(f" - {agent_name}: {config.get('description', 'No description')}") + else: + logger.info(f" - {agent_name}") diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py new file mode 100644 index 0000000000..a4ecf92ff6 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py @@ -0,0 +1,108 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""MCP Protocol Data Models. + +This module defines the data structures used for MCP protocol communication. +""" + +from collections.abc import Sequence +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class MCPTool: + """Represents an MCP tool definition. + + An MCP tool corresponds to a durable agent that can be invoked by MCP clients. + """ + + name: str + description: str + inputSchema: dict[str, Any] + displayName: str | None = None + category: str | None = None + examples: list[str] | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = { + "name": self.name, + "description": self.description, + "inputSchema": self.inputSchema, + } + if self.displayName: + result["displayName"] = self.displayName + if self.category: + result["category"] = self.category + if self.examples: + result["examples"] = self.examples + return result + + +@dataclass +class MCPResource: + """Represents an MCP resource (e.g., conversation history). + + Resources provide read-only access to data like conversation histories. + """ + + uri: str + name: str + mimeType: str + description: str | None = None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = { + "uri": self.uri, + "name": self.name, + "mimeType": self.mimeType, + } + if self.description: + result["description"] = self.description + return result + + +@dataclass +class MCPCallResult: + """Result from calling an MCP tool. + + Contains the response content and optional metadata about the invocation. + """ + + content: list[dict[str, Any]] = field(default_factory=list) + metadata: dict[str, Any] | None = None + isError: bool = False + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + result = {"content": self.content} + if self.metadata: + result["metadata"] = self.metadata + if self.isError: + result["isError"] = self.isError + return result + + @classmethod + def from_text( + cls, + text: str, + metadata: dict[str, Any] | None = None, + is_error: bool = False, + ) -> "MCPCallResult": + """Create a result from text content.""" + return cls( + content=[{"type": "text", "text": text}], + metadata=metadata, + isError=is_error, + ) + + @classmethod + def from_error(cls, error_message: str, **metadata: Any) -> "MCPCallResult": + """Create an error result.""" + return cls( + content=[{"type": "text", "text": f"Error: {error_message}"}], + metadata=metadata if metadata else None, + isError=True, + ) 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..4116575325 --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/README.md @@ -0,0 +1,185 @@ +# MCP Server Sample (Python) + +This sample demonstrates how to expose AI agents as MCP (Model Context Protocol) tools using the Durable Extension for Agent Framework. The sample creates a weather agent and exposes it via both standard HTTP endpoints and MCP protocol endpoints for use with MCP-compatible clients like Claude Desktop, Cursor, or VSCode. + +## Key Concepts Demonstrated + +- Defining an AI agent with the Microsoft Agent Framework and exposing it through MCP protocol. +- Using `MCPServerExtension` to automatically generate MCP-compliant endpoints. +- Supporting both direct HTTP API access and MCP JSON-RPC protocol for the same agent. +- Session-based conversation management compatible with MCP clients. +- Dual-mode access: standard HTTP triggers (`/api/agents/{agent_name}/run`) and MCP endpoints (`/api/mcp/v1/*`). + +## Environment Setup + +### 1. Create and activate a virtual environment + +**Windows (PowerShell):** +```powershell +python -m venv .venv +.venv\Scripts\Activate.ps1 +``` + +**Linux/macOS:** +```bash +python -m venv .venv +source .venv/bin/activate +``` + +### 2. Install dependencies + +See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. + +### 3. Configure local settings + +Copy `local.settings.json.template` to `local.settings.json`, then set the Azure OpenAI values (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and optionally `AZURE_OPENAI_API_KEY`) to match your environment. + +## Running the Sample + +With the environment setup and function app running, you can test the sample using either standard HTTP endpoints or MCP protocol endpoints. + +You can use the `demo.http` file to send requests, or command line tools as shown below. + +### Test via Standard HTTP Endpoint + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/agents/WeatherAgent/run \ + -H "Content-Type: application/json" \ + -d '{"message": "What is the weather in Seattle?"}' +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/agents/WeatherAgent/run ` + -ContentType application/json ` + -Body '{"message": "What is the weather in Seattle?"}' +``` + +Expected response: +```json +{ + "status": "accepted", + "response": "Agent request accepted", + "message": "What is the weather in Seattle?", + "conversation_id": "", + "correlation_id": "" +} +``` + +### Test MCP Endpoints + +List available MCP tools: + +Bash (Linux/macOS/WSL): + +```bash +curl http://localhost:7071/api/mcp/v1/tools +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Uri http://localhost:7071/api/mcp/v1/tools +``` + +Call agent via MCP protocol: + +Bash (Linux/macOS/WSL): + +```bash +curl -X POST http://localhost:7071/api/mcp/v1/call \ + -H "Content-Type: application/json" \ + -d '{"name": "WeatherAgent", "arguments": {"message": "What is the weather in Seattle?"}}' +``` + +PowerShell: + +```powershell +Invoke-RestMethod -Method Post ` + -Uri http://localhost:7071/api/mcp/v1/call ` + -ContentType application/json ` + -Body '{"name": "WeatherAgent", "arguments": {"message": "What is the weather in Seattle?"}}' +``` + +## Using with MCP Clients + +Once the function app is running, you can connect MCP-compatible clients to interact with your agents. + +### Claude Desktop + +Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows): + +```json +{ + "mcpServers": { + "weather-agent": { + "url": "http://localhost:7071/api/mcp/v1" + } + } +} +``` + +### VSCode/Cursor + +Configure your MCP client extension settings to connect to `http://localhost:7071/api/mcp/v1`. + +## Code Structure + +The sample shows how to enable MCP protocol support with minimal code changes: + +```python +# Create your agent as usual +weather_agent = _create_weather_agent() + +# Initialize the Function app +app = AgentFunctionApp(agents=[weather_agent]) + +# Enable MCP with just 2 lines +mcp = MCPServerExtension(app, [weather_agent]) +app.register_mcp_server(mcp) +``` + +This automatically creates the following endpoints: +- `POST /api/agents/WeatherAgent/run` - Standard HTTP endpoint +- `POST /api/mcp/v1` - MCP JSON-RPC handler +- `GET /api/mcp/v1/tools` - List available MCP tools +- `POST /api/mcp/v1/call` - Direct MCP tool invocation + +## Expected Output + +When you call the agent via the standard HTTP endpoint, you receive a 202 Accepted response: + +```json +{ + "status": "accepted", + "response": "Agent request accepted", + "message": "What is the weather in Seattle?", + "conversation_id": "", + "correlation_id": "" +} +``` + +When you list MCP tools, you receive the agent's metadata: + +```json +{ + "tools": [ + { + "name": "WeatherAgent", + "description": "A helpful weather assistant", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "sessionId": {"type": "string"} + }, + "required": ["message"] + } + } + ] +} +``` diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/demo.http b/python/samples/getting_started/azure_functions/08_mcp_server/demo.http new file mode 100644 index 0000000000..6b335cd089 --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/demo.http @@ -0,0 +1,83 @@ +### Variables +@baseUrl = http://localhost:7071 + +### Health Check +GET {{baseUrl}}/api/health + +### List MCP Tools +GET {{baseUrl}}/api/mcp/v1/tools + +### Call Agent via Standard HTTP Endpoint +POST {{baseUrl}}/api/agents/WeatherAgent/run +Content-Type: application/json + +{ + "message": "What is the weather in Seattle?" +} + +### Call Agent via MCP Protocol (JSON-RPC) +POST {{baseUrl}}/api/mcp/v1 +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 1, + "method": "tools/call", + "params": { + "name": "WeatherAgent", + "arguments": { + "message": "What is the weather in Seattle?" + } + } +} + +### Call MCP Tool via Direct Endpoint +POST {{baseUrl}}/api/mcp/v1/call +Content-Type: application/json + +{ + "name": "WeatherAgent", + "arguments": { + "message": "What is the weather in Seattle?" + } +} + +### Call Agent with Session Continuity +POST {{baseUrl}}/api/mcp/v1/call +Content-Type: application/json + +{ + "name": "WeatherAgent", + "arguments": { + "message": "What about New York?", + "sessionId": "test-session-123" + } +} + +### MCP JSON-RPC: List Tools +POST {{baseUrl}}/api/mcp/v1 +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + "params": {} +} + +### MCP JSON-RPC: Call Tool with Session +POST {{baseUrl}}/api/mcp/v1 +Content-Type: application/json + +{ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { + "name": "WeatherAgent", + "arguments": { + "message": "Compare the weather in Tokyo and Paris", + "sessionId": "multi-city-session" + } + } +} 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..93542d82a4 --- /dev/null +++ b/python/samples/getting_started/azure_functions/08_mcp_server/function_app.py @@ -0,0 +1,85 @@ +"""Expose Azure OpenAI agents as MCP (Model Context Protocol) tools. + +Components used in this sample: +- AzureOpenAIChatClient to create an agent with the Azure OpenAI deployment. +- AgentFunctionApp to expose standard HTTP endpoints via Durable Functions. +- MCPServerExtension to automatically generate MCP-compliant endpoints for agent tools. + +Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, plus either +`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host.""" + +import logging +from typing import Any + +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.azurefunctions import AgentFunctionApp +from agent_framework.azurefunctions.mcp import MCPServerExtension + +logger = logging.getLogger(__name__) + + +def get_weather(location: str) -> dict[str, Any]: + """Get current weather for a location.""" + + logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})") + result = { + "location": location, + "temperature": 72, + "conditions": "Sunny", + "humidity": 45, + "wind_speed": 5, + } + logger.info(f"✓ [TOOL RESULT] {result}") + return result + + +# 1. Create the weather agent with a tool function. +def _create_weather_agent() -> Any: + """Create the WeatherAgent with get_weather tool.""" + + return AzureOpenAIChatClient().create_agent( + name="WeatherAgent", + instructions="You are a helpful weather assistant. Use the get_weather tool to provide weather information.", + tools=[get_weather], + ) + + +# 2. Register the agent with AgentFunctionApp to expose standard HTTP endpoints. +app = AgentFunctionApp(agents=[_create_weather_agent()], enable_health_check=True) + +# 3. Enable MCP protocol support with 2 additional lines. +mcp = MCPServerExtension(app) +app.register_mcp_server(mcp) + +""" +Expected output when invoking `POST /api/agents/WeatherAgent/run`: + +HTTP/1.1 202 Accepted +{ + "status": "accepted", + "response": "Agent request accepted", + "message": "What is the weather in Seattle?", + "conversation_id": "", + "correlation_id": "" +} + +Expected output when invoking `GET /api/mcp/v1/tools`: + +HTTP/1.1 200 OK +{ + "tools": [ + { + "name": "WeatherAgent", + "description": "You are a helpful weather assistant...", + "inputSchema": { + "type": "object", + "properties": { + "message": {"type": "string"}, + "sessionId": {"type": "string"} + }, + "required": ["message"] + } + } + ] +} +""" 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 From a6b6659b9902521fbd9cb7c5455c0e0a70bd3ab5 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Mon, 10 Nov 2025 12:19:01 -0600 Subject: [PATCH 2/3] Removed unused files and updated sample --- .../agent_framework_azurefunctions/_app.py | 81 ++- .../mcp/__init__.py | 31 - .../mcp/_endpoints.py | 637 ------------------ .../mcp/_extension.py | 238 ------- .../mcp/_models.py | 108 --- .../azure_functions/08_mcp_server/README.md | 142 +++- .../azure_functions/08_mcp_server/demo.http | 83 --- .../08_mcp_server/function_app.py | 133 ++-- 8 files changed, 243 insertions(+), 1210 deletions(-) delete mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py delete mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py delete mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py delete mode 100644 python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py delete mode 100644 python/samples/getting_started/azure_functions/08_mcp_server/demo.http diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 97a32ba84b..48b7d31c3a 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -21,9 +21,6 @@ from ._models import AgentSessionId, ChatRole, RunRequest from ._state import AgentState -if TYPE_CHECKING: - from .mcp._extension import MCPServerExtension - logger = get_logger("agent_framework.azurefunctions") SESSION_ID_FIELD: str = "sessionId" @@ -430,7 +427,11 @@ async def get_conversation_state( ) def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None) -> None: - """Register an MCP tool trigger for an agent. + """ + 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) @@ -438,6 +439,7 @@ def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None """ mcp_function_name = f"mcptool_{agent_name}" + # Define tool properties as JSON (MCP tool parameters) tool_properties = json.dumps( [ { @@ -470,30 +472,61 @@ async def mcp_tool_handler(context: Any, client: df.DurableOrchestrationClient) """Handle MCP tool invocation for the agent.""" return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) - logger.debug(f"[AgentFunctionApp] Registered MCP tool trigger: {agent_name}") + logger.info(f"[AgentFunctionApp] Registered MCP tool trigger: {agent_name}") async def _handle_mcp_tool_invocation( self, agent_name: str, context: Any, client: df.DurableOrchestrationClient ) -> str: - """Handle an MCP tool invocation.""" + """ + 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 containing arguments + client: Durable orchestration client + + Returns: + Agent response text + + Raises: + ValueError: If required arguments are missing + RuntimeError: If agent execution fails + """ + # Parse context if it's a JSON string + if isinstance(context, str): + try: + context = json.loads(context) + except json.JSONDecodeError as e: + raise ValueError(f"Invalid MCP context format: {e}") + + # Extract arguments from MCP context arguments = context.get("arguments", {}) if isinstance(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 Exception: + # If parsing fails, create new session ID with thread_id as key 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"}, @@ -502,10 +535,14 @@ async def _handle_mcp_tool_invocation( correlation_id=correlation_id, ) - logger.debug(f"[MCP Tool] Invoking agent '{agent_name}' with query: {query[:50]}...") + logger.info( + f"[MCP Tool] Invoking agent '{agent_name}' with query: {query[:50]}..." + ("" if len(query) <= 50 else "") + ) + # 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, @@ -515,9 +552,10 @@ async def _handle_mcp_tool_invocation( session_key=str(session_id), ) + # Extract and return response text if result.get("status") == "success": response_text = result.get("response", "No response") - logger.debug(f"[MCP Tool] Agent '{agent_name}' responded successfully") + logger.info(f"[MCP Tool] Agent '{agent_name}' responded successfully") return response_text else: error_msg = result.get("error", "Unknown error") @@ -944,33 +982,6 @@ async def _read_cached_state( agent_state.restore_state(state_payload) return agent_state - def register_mcp_server(self, mcp_extension: "MCPServerExtension") -> None: - """Register MCP server endpoints. - - This enables the Model Context Protocol (MCP) for exposing agents as tools - that can be used by MCP clients like Claude Desktop, Cursor, etc. - - Args: - mcp_extension: MCPServerExtension instance configured with desired settings - - Example: - ```python - from agent_framework.azurefunctions.mcp import MCPServerExtension - - mcp = MCPServerExtension(app) - app.register_mcp_server(mcp) - ``` - - Note: - This should be called after all agents are registered via add_agent(). - """ - logger.info(f"Registering MCP server with route prefix: {mcp_extension.route_prefix}") - logger.info(f"Exposing {len(mcp_extension.get_exposed_agents())} agents as MCP tools") - - mcp_extension.register() - - logger.info("MCP server registered successfully") - def _coerce_to_bool(self, value: Any) -> bool: """Convert various representations into a boolean flag.""" if isinstance(value, bool): diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py deleted file mode 100644 index 5eae4bc5c3..0000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/__init__.py +++ /dev/null @@ -1,31 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""MCP (Model Context Protocol) Server Integration for Agent Framework. - -This module provides the MCPServerExtension class to easily expose durable agents -as MCP tools, allowing them to be used by any MCP-compatible client (Claude Desktop, -Cursor, VSCode, etc.). - -Example: - ```python - from agent_framework.azurefunctions import AgentFunctionApp - from agent_framework.azurefunctions.mcp import MCPServerExtension - - app = AgentFunctionApp("MyApp") - app.add_agent(weather_agent) - - # Enable MCP server - all agents become MCP tools - mcp = MCPServerExtension(app) - app.register_mcp_server(mcp) - ``` -""" - -from ._extension import MCPServerExtension -from ._models import MCPCallResult, MCPResource, MCPTool - -__all__ = [ - "MCPServerExtension", - "MCPTool", - "MCPResource", - "MCPCallResult", -] diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py deleted file mode 100644 index 847b80fbe2..0000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_endpoints.py +++ /dev/null @@ -1,637 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""MCP Protocol HTTP Endpoints. - -This module implements the HTTP handlers for MCP protocol operations. -""" - -import json -import uuid -from collections.abc import Awaitable, Callable -from typing import TYPE_CHECKING, Any - -import azure.durable_functions as df -import azure.functions as func -from agent_framework import get_logger - -if TYPE_CHECKING: - from ._extension import MCPServerExtension - -logger = get_logger("agent_framework.azurefunctions.mcp") - - -def create_list_tools_endpoint( - extension: "MCPServerExtension", -) -> Callable[[func.HttpRequest], Awaitable[func.HttpResponse]]: - """Create endpoint to list available tools (agents). - - Returns: - Async function that handles GET/POST requests to list MCP tools - """ - - async def list_tools(req: func.HttpRequest) -> func.HttpResponse: - """MCP endpoint: List available tools. - - Returns JSON response with tool definitions for all exposed agents. - - Example response: - { - "tools": [ - { - "name": "WeatherAgent", - "description": "Get weather information", - "inputSchema": {...} - } - ] - } - """ - logger.info("MCP: Listing available tools") - - tools = [] - - for agent_name in extension.get_exposed_agents(): - agent = extension.app.agents.get(agent_name) - if not agent: - logger.warning(f"Agent '{agent_name}' not found in app registry") - continue - - # Get custom configuration or use defaults - config = extension._tool_configs.get(agent_name, {}) - - tool = { - "name": agent_name, - "description": config.get("description") or f"Invoke {agent_name} agent", - "inputSchema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Message to send to the agent", - }, - "sessionId": { - "type": "string", - "description": "Optional session ID for conversation continuity", - }, - "enable_tool_calls": { - "type": "boolean", - "description": "Whether to allow the agent to use tools (default: true)", - }, - }, - "required": ["message"], - }, - } - - # Add custom fields if configured - if config.get("display_name"): - tool["displayName"] = config["display_name"] - if config.get("category"): - tool["category"] = config["category"] - if config.get("examples"): - tool["examples"] = config["examples"] - - tools.append(tool) - - logger.info(f"MCP: Returning {len(tools)} tools") - - return func.HttpResponse( - json.dumps({"tools": tools}), mimetype="application/json", status_code=200 - ) - - return list_tools - - -def create_call_tool_endpoint( - extension: "MCPServerExtension", -) -> Callable[[func.HttpRequest, df.DurableOrchestrationClient], Awaitable[func.HttpResponse]]: - """Create endpoint to invoke a tool (agent). - - Returns: - Async function that handles POST requests to call MCP tools - """ - - async def call_tool( - req: func.HttpRequest, client: df.DurableOrchestrationClient - ) -> func.HttpResponse: - """MCP endpoint: Call a tool (invoke an agent). - - Expects JSON request body with: - { - "name": "WeatherAgent", - "arguments": { - "message": "What's the weather?", - "sessionId": "optional-session-id", - "enable_tool_calls": true - } - } - - Returns: - { - "content": [{"type": "text", "text": "Response text"}], - "metadata": { - "sessionId": "session-id", - "messageCount": 1, - "timestamp": "..." - } - } - """ - try: - body = req.get_json() - except ValueError: - logger.error("MCP: Invalid JSON in request body") - return func.HttpResponse( - json.dumps({"error": "Invalid JSON"}), - mimetype="application/json", - status_code=400, - ) - - # Extract tool name and arguments - tool_name = body.get("name") - arguments = body.get("arguments", {}) - - logger.info(f"MCP: Call tool request - name={tool_name}") - - if not tool_name: - logger.error("MCP: Missing tool name in request") - return func.HttpResponse( - json.dumps({"error": "Missing tool name"}), - mimetype="application/json", - status_code=400, - ) - - # Check if agent exists and is exposed - if tool_name not in extension.get_exposed_agents(): - logger.error(f"MCP: Tool '{tool_name}' not found or not exposed") - return func.HttpResponse( - json.dumps({"error": f"Tool '{tool_name}' not found"}), - mimetype="application/json", - status_code=404, - ) - - # Get agent - agent = extension.app.agents.get(tool_name) - if not agent: - logger.error(f"MCP: Agent '{tool_name}' not found in registry") - return func.HttpResponse( - json.dumps({"error": f"Agent '{tool_name}' not found"}), - mimetype="application/json", - status_code=404, - ) - - # Extract parameters - message = arguments.get("message") - session_id = arguments.get("sessionId") - enable_tool_calls = arguments.get("enable_tool_calls", True) - - if not message: - logger.error("MCP: Missing 'message' in arguments") - return func.HttpResponse( - json.dumps({"error": "Missing 'message' in arguments"}), - mimetype="application/json", - status_code=400, - ) - - # Create or parse session ID - from .._models import AgentSessionId - - if session_id: - try: - session = AgentSessionId.parse(session_id) - logger.info(f"MCP: Using existing session - {session_id}") - except Exception as e: - logger.error(f"MCP: Invalid session ID format - {session_id}: {e}") - return func.HttpResponse( - json.dumps({"error": f"Invalid session ID format: {str(e)}"}), - mimetype="application/json", - status_code=400, - ) - else: - session = AgentSessionId.with_random_key(tool_name) - logger.info(f"MCP: Created new session - {session}") - - # Invoke agent via durable entity - entity_id = session.to_entity_id() - - try: - logger.info(f"MCP: Invoking entity - {entity_id}") - - # Generate correlation ID for tracking this request - correlation_id = str(uuid.uuid4()) - - # Signal the entity to run the agent - await client.signal_entity( - entity_id, - "run_agent", - { - "message": message, - "enable_tool_calls": enable_tool_calls, - "mcp_invocation": True, - "correlation_id": correlation_id, - "conversation_id": session.key, - }, - ) - - logger.info(f"MCP: Signal sent to entity - {entity_id}") - - # Poll for response using the app's method - result = await extension.app._get_response_from_entity( - client=client, - entity_instance_id=entity_id, - correlation_id=correlation_id, - message=message, - session_key=session.key, - ) - - logger.info(f"MCP: Entity invocation successful - {entity_id}") - - # Format response for MCP - response_text = result.get("response", "") - if not response_text and result.get("error"): - response_text = f"Error: {result.get('error')}" - - response: dict[str, Any] = { - "content": [{"type": "text", "text": response_text}], - "metadata": { - "sessionId": str(session), - "messageCount": result.get("message_count", 0), - "agentName": tool_name, - }, - } - - # Add timestamp if available - if result.get("timestamp"): - response["metadata"]["timestamp"] = result["timestamp"] - - # Mark as error if agent returned error - if result.get("status") == "error": - response["isError"] = True - - return func.HttpResponse( - json.dumps(response), mimetype="application/json", status_code=200 - ) - - except Exception as e: - logger.error(f"MCP: Error invoking entity - {entity_id}: {str(e)}", exc_info=True) - return func.HttpResponse( - json.dumps( - { - "content": [{"type": "text", "text": f"Error invoking agent: {str(e)}"}], - "isError": True, - "metadata": {"sessionId": str(session), "agentName": tool_name}, - } - ), - mimetype="application/json", - status_code=500, - ) - - return call_tool - - -def create_list_resources_endpoint( - extension: "MCPServerExtension", -) -> Callable[[func.HttpRequest], Awaitable[func.HttpResponse]]: - """Create endpoint to list available resources (conversation histories).""" - - async def list_resources(req: func.HttpRequest) -> func.HttpResponse: - """MCP endpoint: List resources.""" - logger.info("MCP: Listing resources (not yet implemented)") - - resources: list[Any] = [] - - return func.HttpResponse( - json.dumps({"resources": resources}), mimetype="application/json", status_code=200 - ) - - return list_resources - - -def create_read_resource_endpoint( - extension: "MCPServerExtension", -) -> Callable[ - [func.HttpRequest, str, df.DurableOrchestrationClient], Awaitable[func.HttpResponse] -]: - """Create endpoint to read a resource (conversation history).""" - - async def read_resource( - req: func.HttpRequest, resource_uri: str, client: df.DurableOrchestrationClient - ) -> func.HttpResponse: - """MCP endpoint: Read resource.""" - logger.info(f"MCP: Reading resource {resource_uri} (not yet implemented)") - - return func.HttpResponse( - json.dumps({"error": "Resources not yet implemented"}), - mimetype="application/json", - status_code=501, # Not Implemented - ) - - return read_resource - - -def create_list_prompts_endpoint( - extension: "MCPServerExtension", -) -> Callable[[func.HttpRequest], Awaitable[func.HttpResponse]]: - """Create endpoint to list available prompts.""" - - async def list_prompts(req: func.HttpRequest) -> func.HttpResponse: - """MCP endpoint: List prompts.""" - logger.info("MCP: Listing prompts (not yet implemented)") - - prompts: list[Any] = [] - - return func.HttpResponse( - json.dumps({"prompts": prompts}), mimetype="application/json", status_code=200 - ) - - return list_prompts - - -def create_jsonrpc_handler( - extension: "MCPServerExtension", -) -> Callable[[func.HttpRequest, df.DurableOrchestrationClient], Awaitable[func.HttpResponse]]: - """Create JSON-RPC 2.0 message handler for MCP protocol. - - This handles the full MCP protocol including initialize handshake. - MCP clients send JSON-RPC messages to the base endpoint. - """ - - async def handle_jsonrpc( - req: func.HttpRequest, client: df.DurableOrchestrationClient - ) -> func.HttpResponse: - """Handle JSON-RPC 2.0 messages from MCP clients. - - Supported methods: - - initialize: Handshake to establish connection - - tools/list: List available tools - - tools/call: Invoke a tool - """ - try: - body = req.get_json() - except ValueError: - logger.error("MCP JSON-RPC: Invalid JSON in request") - return func.HttpResponse( - json.dumps( - {"jsonrpc": "2.0", "error": {"code": -32700, "message": "Parse error"}, "id": None} - ), - mimetype="application/json", - status_code=400, - ) - - # Extract JSON-RPC fields - jsonrpc_version = body.get("jsonrpc") - method = body.get("method") - params = body.get("params", {}) - request_id = body.get("id") - - logger.info(f"MCP JSON-RPC: method={method}, id={request_id}") - - # Validate JSON-RPC version - if jsonrpc_version != "2.0": - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": { - "code": -32600, - "message": "Invalid Request - must be JSON-RPC 2.0", - }, - "id": request_id, - } - ), - mimetype="application/json", - status_code=400, - ) - - # Handle notifications (requests without an id) - if request_id is None: - # Notifications don't expect a response - if method == "notifications/initialized": - logger.info("MCP JSON-RPC: Client initialized notification received") - return func.HttpResponse(status_code=204) # No Content - elif method and method.startswith("notifications/"): - logger.info(f"MCP JSON-RPC: Notification received: {method}") - return func.HttpResponse(status_code=204) # No Content - else: - # Invalid notification - logger.warning(f"MCP JSON-RPC: Invalid notification: {method}") - return func.HttpResponse(status_code=204) # Still return 204 for notifications - - # Handle different methods - if method == "initialize": - # MCP handshake - logger.info("MCP JSON-RPC: Handling initialize request") - result = { - "protocolVersion": "2024-11-05", - "capabilities": { - "tools": {}, - "resources": {} if extension.enable_resources else None, - "prompts": {} if extension.enable_prompts else None, - }, - "serverInfo": {"name": "durable-agent-mcp-server", "version": "1.0.0"}, - } - # Remove None capabilities - result["capabilities"] = { - k: v for k, v in result["capabilities"].items() if v is not None - } - - return func.HttpResponse( - json.dumps({"jsonrpc": "2.0", "result": result, "id": request_id}), - mimetype="application/json", - status_code=200, - ) - - elif method == "tools/list": - # List available tools - logger.info("MCP JSON-RPC: Handling tools/list request") - tools = [] - - for agent_name in extension.get_exposed_agents(): - agent = extension.app.agents.get(agent_name) - if not agent: - continue - - config = extension._tool_configs.get(agent_name, {}) - - tool = { - "name": agent_name, - "description": config.get("description") or f"Invoke {agent_name} agent", - "inputSchema": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Message to send to the agent", - }, - "sessionId": { - "type": "string", - "description": "Optional session ID for conversation continuity", - }, - }, - "required": ["message"], - }, - } - - if config.get("display_name"): - tool["displayName"] = config["display_name"] - if config.get("category"): - tool["category"] = config["category"] - if config.get("examples"): - tool["examples"] = config["examples"] - - tools.append(tool) - - return func.HttpResponse( - json.dumps({"jsonrpc": "2.0", "result": {"tools": tools}, "id": request_id}), - mimetype="application/json", - status_code=200, - ) - - elif method == "tools/call": - # Invoke a tool - tool_name = params.get("name") - arguments = params.get("arguments", {}) - - logger.info(f"MCP JSON-RPC: Handling tools/call - name={tool_name}") - - if not tool_name: - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "Missing tool name"}, - "id": request_id, - } - ), - mimetype="application/json", - status_code=400, - ) - - # Check if agent exists - if tool_name not in extension.get_exposed_agents(): - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": {"code": -32602, "message": f"Tool '{tool_name}' not found"}, - "id": request_id, - } - ), - mimetype="application/json", - status_code=404, - ) - - # Extract parameters - message = arguments.get("message") - session_id = arguments.get("sessionId") - enable_tool_calls = arguments.get("enable_tool_calls", True) - - if not message: - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": {"code": -32602, "message": "Missing 'message' in arguments"}, - "id": request_id, - } - ), - mimetype="application/json", - status_code=400, - ) - - # Create or parse session ID - from .._models import AgentSessionId - - if session_id: - try: - session = AgentSessionId.parse(session_id) - except Exception as e: - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": {"code": -32602, "message": f"Invalid session ID: {str(e)}"}, - "id": request_id, - } - ), - mimetype="application/json", - status_code=400, - ) - else: - session = AgentSessionId.with_random_key(tool_name) - - # Invoke agent - entity_id = session.to_entity_id() - - try: - # Generate correlation ID for tracking this request - correlation_id = str(uuid.uuid4()) - - # Signal the entity to run the agent - await client.signal_entity( - entity_id, - "run_agent", - { - "message": message, - "enable_tool_calls": enable_tool_calls, - "mcp_invocation": True, - "correlation_id": correlation_id, - "conversation_id": session.key, - }, - ) - - # Poll for response using the app's method - result = await extension.app._get_response_from_entity( - client=client, - entity_instance_id=entity_id, - correlation_id=correlation_id, - message=message, - session_key=session.key, - ) - - response_text = result.get("response", "") - if not response_text and result.get("error"): - response_text = f"Error: {result.get('error')}" - - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "result": { - "content": [{"type": "text", "text": response_text}], - "isError": result.get("status") == "error", - }, - "id": request_id, - } - ), - mimetype="application/json", - status_code=200, - ) - - except Exception as e: - logger.error(f"MCP JSON-RPC: Error invoking tool: {str(e)}", exc_info=True) - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": {"code": -32603, "message": f"Internal error: {str(e)}"}, - "id": request_id, - } - ), - mimetype="application/json", - status_code=500, - ) - - else: - # Method not found - logger.warning(f"MCP JSON-RPC: Unknown method: {method}") - return func.HttpResponse( - json.dumps( - { - "jsonrpc": "2.0", - "error": {"code": -32601, "message": f"Method not found: {method}"}, - "id": request_id, - } - ), - mimetype="application/json", - status_code=404, - ) - - return handle_jsonrpc diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py deleted file mode 100644 index 67175732c1..0000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_extension.py +++ /dev/null @@ -1,238 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""MCP Server Extension for AgentFunctionApp. - -This module provides the MCPServerExtension class that adds MCP protocol support -to durable agents, allowing them to be used as tools by MCP clients. -""" - -from typing import TYPE_CHECKING, Any - -from agent_framework import get_logger - -if TYPE_CHECKING: - from .._app import AgentFunctionApp - -logger = get_logger("agent_framework.azurefunctions.mcp") - - -class MCPServerExtension: - """Extension to expose durable agents as MCP tools. - - This creates HTTP endpoints that implement the MCP protocol, - allowing any MCP client (Claude Desktop, Cursor, etc.) to invoke - agents as tools while maintaining durable state. - - Example: - ```python - from agent_framework.azurefunctions import AgentFunctionApp - from agent_framework.azurefunctions.mcp import MCPServerExtension - - app = AgentFunctionApp("MyApp") - app.add_agent(weather_agent) - - # Enable MCP server - all agents become MCP tools - mcp = MCPServerExtension(app) - app.register_mcp_server(mcp) - ``` - - Advanced Usage: - ```python - # Selective agent exposure - mcp = MCPServerExtension( - app, - expose_agents=["WeatherAgent", "MathAgent"], - route_prefix="/mcp/v1" - ) - - # Customize tool appearance - mcp.configure_tool( - "WeatherAgent", - description="Get detailed weather information", - display_name="Weather Tool", - examples=["What's the weather in Seattle?"] - ) - - app.register_mcp_server(mcp) - ``` - """ - - def __init__( - self, - app: "AgentFunctionApp", - expose_agents: list[str] | None = None, - route_prefix: str = "mcp/v1", - enable_streaming: bool = False, - enable_resources: bool = False, - enable_prompts: bool = False, - auth_level: str = "function", - ) -> None: - """Initialize MCP server extension. - - Args: - app: AgentFunctionApp instance to extend - expose_agents: List of agent names to expose. If None, all agents are exposed - route_prefix: URL prefix for MCP endpoints (default: mcp/v1, becomes /api/mcp/v1) - enable_streaming: Enable SSE streaming - enable_resources: Enable resource endpoints for conversation history - enable_prompts: Enable prompt template endpoints - auth_level: Azure Functions authentication level (anonymous, function, admin) - """ - self.app = app - self.expose_agents = expose_agents - self.route_prefix = route_prefix - self.enable_streaming = enable_streaming - self.enable_resources = enable_resources - self.enable_prompts = enable_prompts - self.auth_level = auth_level - self._tool_configs: dict[str, dict[str, Any]] = {} - - logger.info(f"MCPServerExtension initialized with route_prefix={route_prefix}") - - def configure_tool( - self, - agent_name: str, - description: str | None = None, - display_name: str | None = None, - category: str | None = None, - examples: list[str] | None = None, - ) -> None: - """Configure how an agent appears as an MCP tool. - - Args: - agent_name: Name of the agent to configure - description: Custom description for the tool - display_name: Display name shown to users - category: Category for grouping tools - examples: Example prompts for using the tool - - Example: - ```python - mcp.configure_tool( - "WeatherAgent", - description="Get weather information for any location", - display_name="Weather Information", - category="utilities", - examples=[ - "What's the weather in Seattle?", - "Will it rain tomorrow in Boston?" - ] - ) - ``` - """ - logger.info(f"Configuring MCP tool: {agent_name}") - - self._tool_configs[agent_name] = { - "description": description, - "display_name": display_name, - "category": category, - "examples": examples, - } - - def get_exposed_agents(self) -> list[str]: - """Get list of agents to expose via MCP. - - Returns: - List of agent names that should be available as MCP tools - """ - if self.expose_agents is not None: - # Filter to only include registered agents - exposed = [name for name in self.expose_agents if name in self.app.agents] - if len(exposed) < len(self.expose_agents): - missing = set(self.expose_agents) - set(exposed) - logger.warning(f"Some agents in expose_agents not found: {missing}") - return exposed - - # Expose all registered agents - return list(self.app.agents.keys()) - - def register(self) -> None: - """Register MCP endpoints with the function app. - - This is called by AgentFunctionApp.register_mcp_server() and should not - be called directly by users. - - Registers the following endpoints: - - POST {route_prefix} - JSON-RPC 2.0 handler - - GET/POST {route_prefix}/tools - List available tools - - POST {route_prefix}/call - Invoke a tool - - GET/POST {route_prefix}/resources - List resources (if enabled) - - GET {route_prefix}/resources/{uri} - Read resource (if enabled) - - GET/POST {route_prefix}/prompts - List prompts (if enabled) - """ - from ._endpoints import ( - create_call_tool_endpoint, - create_jsonrpc_handler, - create_list_prompts_endpoint, - create_list_resources_endpoint, - create_list_tools_endpoint, - create_read_resource_endpoint, - ) - - logger.info(f"Registering MCP endpoints with route_prefix={self.route_prefix}") - logger.info(f"Exposing {len(self.get_exposed_agents())} agents as MCP tools") - - # JSON-RPC base endpoint for MCP protocol - logger.info(f"Registering: {self.route_prefix} (JSON-RPC handler)") - jsonrpc_func = create_jsonrpc_handler(self) - jsonrpc_func = self.app.durable_client_input(client_name="client")(jsonrpc_func) - jsonrpc_func = self.app.route( - route=f"{self.route_prefix}", methods=["POST"], auth_level=self.auth_level - )(jsonrpc_func) - - # List tools endpoint (backward compatibility) - logger.info(f"Registering: {self.route_prefix}/tools") - self.app.route( - route=f"{self.route_prefix}/tools", - methods=["GET", "POST"], - auth_level=self.auth_level, - )(create_list_tools_endpoint(self)) - - # Call tool endpoint (needs durable client) - logger.info(f"Registering: {self.route_prefix}/call") - call_tool_func = create_call_tool_endpoint(self) - # Apply decorators - call_tool_func = self.app.durable_client_input(client_name="client")(call_tool_func) - call_tool_func = self.app.route( - route=f"{self.route_prefix}/call", methods=["POST"], auth_level=self.auth_level - )(call_tool_func) - - # Resources endpoints - if self.enable_resources: - logger.info(f"Registering: {self.route_prefix}/resources") - self.app.route( - route=f"{self.route_prefix}/resources", - methods=["GET", "POST"], - auth_level=self.auth_level, - )(create_list_resources_endpoint(self)) - - logger.info(f"Registering: {self.route_prefix}/resources/{{resource_uri}}") - read_resource_func = create_read_resource_endpoint(self) - # Apply decorators (read_resource needs durable client) - read_resource_func = self.app.durable_client_input(client_name="client")( - read_resource_func - ) - read_resource_func = self.app.route( - route=f"{self.route_prefix}/resources/{{resource_uri}}", - methods=["GET"], - auth_level=self.auth_level, - )(read_resource_func) - - # Prompts endpoints - if self.enable_prompts: - logger.info(f"Registering: {self.route_prefix}/prompts") - self.app.route( - route=f"{self.route_prefix}/prompts", - methods=["GET", "POST"], - auth_level=self.auth_level, - )(create_list_prompts_endpoint(self)) - - logger.info("MCP server endpoints registered successfully") - - # Log exposed agents - for agent_name in self.get_exposed_agents(): - config = self._tool_configs.get(agent_name) - if config: - logger.info(f" - {agent_name}: {config.get('description', 'No description')}") - else: - logger.info(f" - {agent_name}") diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py b/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py deleted file mode 100644 index a4ecf92ff6..0000000000 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/mcp/_models.py +++ /dev/null @@ -1,108 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -"""MCP Protocol Data Models. - -This module defines the data structures used for MCP protocol communication. -""" - -from collections.abc import Sequence -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class MCPTool: - """Represents an MCP tool definition. - - An MCP tool corresponds to a durable agent that can be invoked by MCP clients. - """ - - name: str - description: str - inputSchema: dict[str, Any] - displayName: str | None = None - category: str | None = None - examples: list[str] | None = None - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - result = { - "name": self.name, - "description": self.description, - "inputSchema": self.inputSchema, - } - if self.displayName: - result["displayName"] = self.displayName - if self.category: - result["category"] = self.category - if self.examples: - result["examples"] = self.examples - return result - - -@dataclass -class MCPResource: - """Represents an MCP resource (e.g., conversation history). - - Resources provide read-only access to data like conversation histories. - """ - - uri: str - name: str - mimeType: str - description: str | None = None - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - result = { - "uri": self.uri, - "name": self.name, - "mimeType": self.mimeType, - } - if self.description: - result["description"] = self.description - return result - - -@dataclass -class MCPCallResult: - """Result from calling an MCP tool. - - Contains the response content and optional metadata about the invocation. - """ - - content: list[dict[str, Any]] = field(default_factory=list) - metadata: dict[str, Any] | None = None - isError: bool = False - - def to_dict(self) -> dict[str, Any]: - """Convert to dictionary for JSON serialization.""" - result = {"content": self.content} - if self.metadata: - result["metadata"] = self.metadata - if self.isError: - result["isError"] = self.isError - return result - - @classmethod - def from_text( - cls, - text: str, - metadata: dict[str, Any] | None = None, - is_error: bool = False, - ) -> "MCPCallResult": - """Create a result from text content.""" - return cls( - content=[{"type": "text", "text": text}], - metadata=metadata, - isError=is_error, - ) - - @classmethod - def from_error(cls, error_message: str, **metadata: Any) -> "MCPCallResult": - """Create an error result.""" - return cls( - content=[{"type": "text", "text": f"Error: {error_message}"}], - metadata=metadata if metadata else None, - isError=True, - ) 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 index 4116575325..61a997bca7 100644 --- a/python/samples/getting_started/azure_functions/08_mcp_server/README.md +++ b/python/samples/getting_started/azure_functions/08_mcp_server/README.md @@ -1,4 +1,144 @@ -# MCP Server Sample (Python) +# 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", + "httpEndpointEnabled": true, + "mcpToolTriggerEnabled": false + }, + { + "name": "StockAdvisor", + "type": "Agent", + "httpEndpointEnabled": false, + "mcpToolTriggerEnabled": true + }, + { + "name": "PlantAdvisor", + "type": "Agent", + "httpEndpointEnabled": true, + "mcpToolTriggerEnabled": true + } + ], + "agent_count": 3 +} +``` + +## Learn More + +- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) +- [Microsoft Agent Framework Documentation](https://github.com/Azure/agent-framework) +- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/) Sample (Python) This sample demonstrates how to expose AI agents as MCP (Model Context Protocol) tools using the Durable Extension for Agent Framework. The sample creates a weather agent and exposes it via both standard HTTP endpoints and MCP protocol endpoints for use with MCP-compatible clients like Claude Desktop, Cursor, or VSCode. diff --git a/python/samples/getting_started/azure_functions/08_mcp_server/demo.http b/python/samples/getting_started/azure_functions/08_mcp_server/demo.http deleted file mode 100644 index 6b335cd089..0000000000 --- a/python/samples/getting_started/azure_functions/08_mcp_server/demo.http +++ /dev/null @@ -1,83 +0,0 @@ -### Variables -@baseUrl = http://localhost:7071 - -### Health Check -GET {{baseUrl}}/api/health - -### List MCP Tools -GET {{baseUrl}}/api/mcp/v1/tools - -### Call Agent via Standard HTTP Endpoint -POST {{baseUrl}}/api/agents/WeatherAgent/run -Content-Type: application/json - -{ - "message": "What is the weather in Seattle?" -} - -### Call Agent via MCP Protocol (JSON-RPC) -POST {{baseUrl}}/api/mcp/v1 -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "WeatherAgent", - "arguments": { - "message": "What is the weather in Seattle?" - } - } -} - -### Call MCP Tool via Direct Endpoint -POST {{baseUrl}}/api/mcp/v1/call -Content-Type: application/json - -{ - "name": "WeatherAgent", - "arguments": { - "message": "What is the weather in Seattle?" - } -} - -### Call Agent with Session Continuity -POST {{baseUrl}}/api/mcp/v1/call -Content-Type: application/json - -{ - "name": "WeatherAgent", - "arguments": { - "message": "What about New York?", - "sessionId": "test-session-123" - } -} - -### MCP JSON-RPC: List Tools -POST {{baseUrl}}/api/mcp/v1 -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 2, - "method": "tools/list", - "params": {} -} - -### MCP JSON-RPC: Call Tool with Session -POST {{baseUrl}}/api/mcp/v1 -Content-Type: application/json - -{ - "jsonrpc": "2.0", - "id": 3, - "method": "tools/call", - "params": { - "name": "WeatherAgent", - "arguments": { - "message": "Compare the weather in Tokyo and Paris", - "sessionId": "multi-city-session" - } - } -} 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 index 93542d82a4..0b157bee56 100644 --- 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 @@ -1,85 +1,64 @@ -"""Expose Azure OpenAI agents as MCP (Model Context Protocol) tools. - -Components used in this sample: -- AzureOpenAIChatClient to create an agent with the Azure OpenAI deployment. -- AgentFunctionApp to expose standard HTTP endpoints via Durable Functions. -- MCPServerExtension to automatically generate MCP-compliant endpoints for agent tools. - -Prerequisites: set `AZURE_OPENAI_ENDPOINT` and `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, plus either -`AZURE_OPENAI_API_KEY` or authenticate with Azure CLI before starting the Functions host.""" - -import logging -from typing import Any - -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.azurefunctions import AgentFunctionApp -from agent_framework.azurefunctions.mcp import MCPServerExtension - -logger = logging.getLogger(__name__) - - -def get_weather(location: str) -> dict[str, Any]: - """Get current weather for a location.""" - - logger.info(f"🔧 [TOOL CALLED] get_weather(location={location})") - result = { - "location": location, - "temperature": 72, - "conditions": "Sunny", - "humidity": 45, - "wind_speed": 5, - } - logger.info(f"✓ [TOOL RESULT] {result}") - return result - - -# 1. Create the weather agent with a tool function. -def _create_weather_agent() -> Any: - """Create the WeatherAgent with get_weather tool.""" +""" +Example showing how to configure AI agents with different trigger configurations. - return AzureOpenAIChatClient().create_agent( - name="WeatherAgent", - instructions="You are a helpful weather assistant. Use the get_weather tool to provide weather information.", - tools=[get_weather], - ) +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 -# 2. Register the agent with AgentFunctionApp to expose standard HTTP endpoints. -app = AgentFunctionApp(agents=[_create_weather_agent()], enable_health_check=True) +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 -# 3. Enable MCP protocol support with 2 additional lines. -mcp = MCPServerExtension(app) -app.register_mcp_server(mcp) +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). """ -Expected output when invoking `POST /api/agents/WeatherAgent/run`: -HTTP/1.1 202 Accepted -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "What is the weather in Seattle?", - "conversation_id": "", - "correlation_id": "" -} - -Expected output when invoking `GET /api/mcp/v1/tools`: +from agent_framework.azurefunctions import AgentFunctionApp +from agent_framework.azure import AzureOpenAIChatClient -HTTP/1.1 200 OK -{ - "tools": [ - { - "name": "WeatherAgent", - "description": "You are a helpful weather assistant...", - "inputSchema": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "sessionId": {"type": "string"} - }, - "required": ["message"] - } - } - ] -} -""" +# 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) From 40104e4bf22ab57540c6a7a735fff432ec5bd567 Mon Sep 17 00:00:00 2001 From: Gavin Aguiar Date: Tue, 11 Nov 2025 17:36:16 -0600 Subject: [PATCH 3/3] Addressed copilot feedback --- .../agent_framework_azurefunctions/_app.py | 128 +---------- .../agent_framework_azurefunctions/_state.py | 18 +- .../azure_functions/08_mcp_server/README.md | 213 ++++-------------- 3 files changed, 58 insertions(+), 301 deletions(-) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 48b7d31c3a..11e28ddcae 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -437,7 +437,8 @@ def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None 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 = f"mcptool_{agent_name}" + # Match .NET naming convention: mcptool-{agentName} + mcp_function_name = f"mcptool-{agent_name}" # Define tool properties as JSON (MCP tool parameters) tool_properties = json.dumps( @@ -468,7 +469,7 @@ def _setup_mcp_tool_trigger(self, agent_name: str, agent_description: str | None data_type=func.DataType.UNDEFINED, ) @self.durable_client_input(client_name="client") - async def mcp_tool_handler(context: Any, client: df.DurableOrchestrationClient) -> str: + async def mcp_tool_handler(context: str, client: df.DurableOrchestrationClient) -> str: """Handle MCP tool invocation for the agent.""" return await self._handle_mcp_tool_invocation(agent_name=agent_name, context=context, client=client) @@ -566,109 +567,6 @@ async def _handle_mcp_tool_invocation( logger.error(f"[MCP Tool] Error invoking agent '{agent_name}': {str(exc)}", exc_info=True) raise - async def _get_response_from_entity( - self, - client: df.DurableOrchestrationClient, - entity_instance_id: df.EntityId, - correlation_id: str, - message: str, - session_key: str, - ) -> dict[str, Any]: - """Poll the entity state until a response is available or timeout occurs.""" - import asyncio - - max_retries = 120 - retry_count = 0 - result: dict[str, Any] | None = None - - logger.debug(f"[Polling] Waiting for response with correlation ID: {correlation_id}") - - while retry_count < max_retries: - await asyncio.sleep(0.5) - - result = await self._poll_entity_for_response( - client=client, - entity_instance_id=entity_instance_id, - correlation_id=correlation_id, - message=message, - session_key=session_key, - ) - if result is not None: - break - - logger.debug(f"[Polling] Response not available yet (retry {retry_count})") - retry_count += 1 - - if result is not None: - return result - - logger.warning( - f"[Polling] Response with correlation ID {correlation_id} " - f"not found in time (waited {max_retries * 0.5} seconds)" - ) - return await self._build_timeout_result(message=message, session_key=session_key, correlation_id=correlation_id) - - async def _poll_entity_for_response( - self, - client: df.DurableOrchestrationClient, - entity_instance_id: df.EntityId, - correlation_id: str, - message: str, - session_key: str, - ) -> dict[str, Any] | None: - """Poll entity once for a response matching the correlation ID.""" - result: dict[str, Any] | None = None - try: - state = await self._read_cached_state(client, entity_instance_id) - - if state is None: - return None - - agent_response = state.try_get_agent_response(correlation_id) - if agent_response: - result = self._build_success_result( - response_data=agent_response, - message=message, - session_key=session_key, - correlation_id=correlation_id, - state=state, - ) - logger.debug(f"[Polling] Found response for correlation ID: {correlation_id}") - - except Exception as exc: - logger.warning(f"[Polling] Error reading entity state: {exc}") - - return result - - async def _build_timeout_result(self, message: str, session_key: str, correlation_id: str) -> dict[str, Any]: - """Create the timeout response.""" - return { - "response": "Agent is still processing or timed out...", - "message": message, - SESSION_ID_FIELD: session_key, - "status": "timeout", - "correlationId": correlation_id, - } - - def _build_success_result( - self, - response_data: dict[str, Any], - message: str, - session_key: str, - correlation_id: str, - state: AgentState, - ) -> dict[str, Any]: - """Build the success result returned to the caller.""" - return { - "response": response_data.get("response", ""), - "message": message, - SESSION_ID_FIELD: session_key, - "status": "success", - "correlationId": correlation_id, - "message_count": state.message_count, - "timestamp": response_data.get("timestamp"), - } - def _setup_health_route(self) -> None: """Register the optional health check route.""" @@ -705,25 +603,6 @@ def _build_function_name(agent_name: str, suffix: str) -> str: return f"{sanitized}_{suffix}" - async def _read_cached_state( - self, - client: df.DurableOrchestrationClient, - entity_instance_id: df.EntityId, - ) -> AgentState | None: - state_response = await client.read_entity_state(entity_instance_id) - if not state_response or not state_response.entity_exists: - return None - - state_payload = state_response.entity_state - if not isinstance(state_payload, dict): - return None - - typed_state_payload = cast(dict[str, Any], state_payload) - - agent_state = AgentState() - agent_state.restore_state(typed_state_payload) - return agent_state - async def _get_response_from_entity( self, client: df.DurableOrchestrationClient, @@ -819,6 +698,7 @@ def _build_success_result( "status": "success", "message_count": response_data.get("message_count", state.message_count), "correlationId": correlation_id, + "timestamp": response_data.get("timestamp"), } def _build_request_data( diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py index c15d9fe96d..0f54cd8a37 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_state.py @@ -89,8 +89,22 @@ def add_assistant_message( ) def get_chat_messages(self) -> list[ChatMessage]: - """Return a copy of the full conversation history.""" - return list(self.conversation_history) + """Return a copy of the full conversation history. + + Note: additional_properties are stripped from the returned messages to avoid + sending metadata to the LLM API, which doesn't support it in the messages array. + The original messages in conversation_history retain their additional_properties. + """ + # Return messages without additional_properties to avoid API errors + # Azure OpenAI doesn't support metadata in the messages array + return [ + ChatMessage( + role=msg.role, + contents=msg.contents, + # Intentionally omit additional_properties + ) + for msg in self.conversation_history + ] def try_get_agent_response(self, correlation_id: str) -> dict[str, Any] | None: """Get an agent response by correlation ID. 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 index 61a997bca7..53ed11b7c0 100644 --- a/python/samples/getting_started/azure_functions/08_mcp_server/README.md +++ b/python/samples/getting_started/azure_functions/08_mcp_server/README.md @@ -134,192 +134,55 @@ Expected response: } ``` -## Learn More - -- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) -- [Microsoft Agent Framework Documentation](https://github.com/Azure/agent-framework) -- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/) Sample (Python) - -This sample demonstrates how to expose AI agents as MCP (Model Context Protocol) tools using the Durable Extension for Agent Framework. The sample creates a weather agent and exposes it via both standard HTTP endpoints and MCP protocol endpoints for use with MCP-compatible clients like Claude Desktop, Cursor, or VSCode. - -## Key Concepts Demonstrated - -- Defining an AI agent with the Microsoft Agent Framework and exposing it through MCP protocol. -- Using `MCPServerExtension` to automatically generate MCP-compliant endpoints. -- Supporting both direct HTTP API access and MCP JSON-RPC protocol for the same agent. -- Session-based conversation management compatible with MCP clients. -- Dual-mode access: standard HTTP triggers (`/api/agents/{agent_name}/run`) and MCP endpoints (`/api/mcp/v1/*`). - -## Environment Setup - -### 1. Create and activate a virtual environment - -**Windows (PowerShell):** -```powershell -python -m venv .venv -.venv\Scripts\Activate.ps1 -``` - -**Linux/macOS:** -```bash -python -m venv .venv -source .venv/bin/activate -``` - -### 2. Install dependencies - -See the [README.md](../README.md) file in the parent directory for more information on how to configure the environment, including how to install and run common sample dependencies. - -### 3. Configure local settings - -Copy `local.settings.json.template` to `local.settings.json`, then set the Azure OpenAI values (`AZURE_OPENAI_ENDPOINT`, `AZURE_OPENAI_CHAT_DEPLOYMENT_NAME`, and optionally `AZURE_OPENAI_API_KEY`) to match your environment. - -## Running the Sample - -With the environment setup and function app running, you can test the sample using either standard HTTP endpoints or MCP protocol endpoints. - -You can use the `demo.http` file to send requests, or command line tools as shown below. - -### Test via Standard HTTP Endpoint - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/agents/WeatherAgent/run \ - -H "Content-Type: application/json" \ - -d '{"message": "What is the weather in Seattle?"}' -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/agents/WeatherAgent/run ` - -ContentType application/json ` - -Body '{"message": "What is the weather in Seattle?"}' -``` - -Expected response: -```json -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "What is the weather in Seattle?", - "conversation_id": "", - "correlation_id": "" -} -``` - -### Test MCP Endpoints - -List available MCP tools: - -Bash (Linux/macOS/WSL): - -```bash -curl http://localhost:7071/api/mcp/v1/tools -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Uri http://localhost:7071/api/mcp/v1/tools -``` - -Call agent via MCP protocol: - -Bash (Linux/macOS/WSL): - -```bash -curl -X POST http://localhost:7071/api/mcp/v1/call \ - -H "Content-Type: application/json" \ - -d '{"name": "WeatherAgent", "arguments": {"message": "What is the weather in Seattle?"}}' -``` - -PowerShell: - -```powershell -Invoke-RestMethod -Method Post ` - -Uri http://localhost:7071/api/mcp/v1/call ` - -ContentType application/json ` - -Body '{"name": "WeatherAgent", "arguments": {"message": "What is the weather in Seattle?"}}' -``` - -## Using with MCP Clients - -Once the function app is running, you can connect MCP-compatible clients to interact with your agents. +## Code Structure -### Claude Desktop +The sample shows how to enable MCP tool triggers with flexible agent configuration: -Add to your Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json` on macOS or `%APPDATA%\Claude\claude_desktop_config.json` on Windows): +```python +from agent_framework.azurefunctions import AgentFunctionApp +from agent_framework.azure import AzureOpenAIChatClient -```json -{ - "mcpServers": { - "weather-agent": { - "url": "http://localhost:7071/api/mcp/v1" - } - } -} -``` +# Create Azure OpenAI Chat Client +chat_client = AzureOpenAIChatClient() -### VSCode/Cursor +# Define agents with different roles +joker_agent = chat_client.create_agent( + name="Joker", + instructions="You are good at telling jokes.", +) -Configure your MCP client extension settings to connect to `http://localhost:7071/api/mcp/v1`. +stock_agent = chat_client.create_agent( + name="StockAdvisor", + instructions="Check stock prices.", +) -## Code Structure +plant_agent = chat_client.create_agent( + name="PlantAdvisor", + instructions="Recommend plants.", + description="Get plant recommendations.", +) -The sample shows how to enable MCP protocol support with minimal code changes: +# Create the AgentFunctionApp +app = AgentFunctionApp(enable_health_check=True) -```python -# Create your agent as usual -weather_agent = _create_weather_agent() +# Configure agents with different trigger combinations: +# HTTP trigger only (default) +app.add_agent(joker_agent) -# Initialize the Function app -app = AgentFunctionApp(agents=[weather_agent]) +# MCP tool trigger only (HTTP disabled) +app.add_agent(stock_agent, enable_http_endpoint=False, enable_mcp_tool_trigger=True) -# Enable MCP with just 2 lines -mcp = MCPServerExtension(app, [weather_agent]) -app.register_mcp_server(mcp) +# 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: -- `POST /api/agents/WeatherAgent/run` - Standard HTTP endpoint -- `POST /api/mcp/v1` - MCP JSON-RPC handler -- `GET /api/mcp/v1/tools` - List available MCP tools -- `POST /api/mcp/v1/call` - Direct MCP tool invocation - -## Expected Output +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 -When you call the agent via the standard HTTP endpoint, you receive a 202 Accepted response: - -```json -{ - "status": "accepted", - "response": "Agent request accepted", - "message": "What is the weather in Seattle?", - "conversation_id": "", - "correlation_id": "" -} -``` - -When you list MCP tools, you receive the agent's metadata: +## Learn More -```json -{ - "tools": [ - { - "name": "WeatherAgent", - "description": "A helpful weather assistant", - "inputSchema": { - "type": "object", - "properties": { - "message": {"type": "string"}, - "sessionId": {"type": "string"} - }, - "required": ["message"] - } - } - ] -} -``` +- [Model Context Protocol Documentation](https://modelcontextprotocol.io/) +- [Microsoft Agent Framework Documentation](https://github.com/Azure/agent-framework) +- [Azure Functions Documentation](https://learn.microsoft.com/azure/azure-functions/)