Multi-agent AI systems are moving from proof-of-concept demos into real production architecture discussions. Once a system has more than one specialised agent, you need a way for those agents to discover each other, negotiate tasks and exchange results, without every team rewriting the same integration code from scratch. Microsoft’s Semantic Kernel and Google’s Agent-to-Agent (A2A) protocol solve two different halves of that problem, and combining them gives you an orchestration layer that can talk to agents built on entirely different frameworks.
Kinfey Lo, a Senior Cloud Advocate at Microsoft, published a guest post on the Microsoft Agent Framework devblog walking through exactly this combination. This article recreates that implementation, adds context on where each piece fits, and flags a couple of places where the sample code needs a fix before it will actually run.
Understanding the A2A protocol
Google introduced the Agent-to-Agent protocol in April 2025, with backing from more than fifty technology partners. It addresses a gap that tool-calling protocols such as MCP were never designed to fill: letting one AI agent talk to another AI agent as a peer, rather than treating it as just another function to call.
Core capabilities of A2A
A2A is built around four ideas. Agent discovery works through Agent Cards, which are JSON documents that every A2A-compliant agent exposes describing its capabilities, endpoints, supported message types and authentication requirements. Any client that fetches an Agent Card immediately knows what an agent can do and how to reach it, with no hardcoded integration needed.
Task management gives every interaction a defined lifecycle. A task can complete instantly, or it can run for minutes with status updates streamed back, which matters for anything involving browser automation, batch processing or long-running tool calls.
Message exchange happens through structured messages built from typed parts, so agents can negotiate text, JSON, files or other payloads instead of being locked into plain strings. Security is inherited from familiar web standards, HTTP, JSON-RPC 2.0 and Server-Sent Events, with support for standard OpenAPI authentication schemes rather than a protocol-specific auth model.
A2A versus MCP
It is easy to assume A2A competes with Anthropic’s Model Context Protocol, but the two sit at different layers. MCP connects an agent to tools and data, things like APIs, databases and file systems. A2A connects agents to other agents, so one agent can hand off a task to another and get results back.
A useful comparison is a technician working alone with a toolbox versus a technician who can also call in other specialists when a job needs different expertise. Most production systems end up needing both protocols rather than choosing one over the other.
Semantic Kernel as the orchestration layer
Semantic Kernel is Microsoft’s open source SDK for building AI agents, and it is a natural fit for the orchestration side of an A2A system. It gives you a plugin architecture for extending an agent’s capabilities, support for multiple model providers so you are not locked into a single backend, and connectors for the enterprise systems most .NET and Python teams already run on.
Putting Semantic Kernel behind an A2A front door buys you framework-agnostic interoperability. An SK-based agent can talk to agents built on LangGraph, CrewAI, Google’s own ADK, or anything else that speaks A2A, while you keep SK’s plugin ecosystem and prompt engineering tools on your own side of the conversation.
The architecture pattern used in this implementation
Lo’s implementation uses a central routing agent as the front door for the whole system. Azure AI Foundry powers this routing agent, and its job is to look at an incoming request, decide which specialised remote agent should handle it, and hand the task off over A2A.

Below the routing agent sit specialised remote agents, each exposing its own Agent Card so the router can discover what it does. In this sample there are two, a Playwright agent for browser automation and a tool agent for development tasks like cloning repositories and opening projects in an editor.
The system actually runs three communication patterns side by side, and this is worth calling out because it is the part people usually get wrong when they assume A2A replaces everything else. General purpose tool agents talk over A2A using HTTP and JSON-RPC, discovered through their Agent Cards. Process-based agents such as the Playwright agent connect through MCP over STDIO, which is the right choice for a tool that needs to spawn and control a local process, while serverless MCP functions running in Azure use Server-Sent Events instead, since SSE handles connection lifecycle better for functions that scale to zero.
MCP and A2A are not competing here, they are stacked. MCP handles the agent-to-tool boundary, A2A handles the agent-to-agent boundary, and Semantic Kernel sits in the middle bridging both.
Setting up the project
Before writing any orchestration code you need the right dependencies installed. The sample uses uv, the fast Python package manager, to keep the environment reproducible.
# Initialize Python project with uv (recommended)
uv init multi_agent_system
cd multi_agent_system
# Core dependencies for Semantic Kernel and Azure integration
uv add semantic-kernel[azure]
uv add azure-identity
uv add azure-ai-agents
uv add python-dotenv
# A2A protocol dependencies
uv add a2a-client
uv add httpx
# MCP integration dependencies
uv add semantic-kernel[mcp]
# Web interface dependencies
uv add gradio
# Development dependencies
uv add --dev pytest pytest-asyncio
This installs Semantic Kernel with its Azure extras, the Azure Identity and AI Agents SDKs for talking to Azure AI Foundry, the A2A client library for agent discovery, and Gradio for the chat interface built later. Running uv add repeatedly like this is slower than a single dependency file, but it does mean each package’s purpose is documented in your shell history, which is a reasonable trade-off for a sample project. For a real deployment I would collapse this into a pinned pyproject.toml, since both a2a-client and semantic-kernel are still moving fast, and an unpinned uv add on a fresh clone can quietly pull in a breaking change.
Next you need environment variables for the Azure AI Foundry endpoint and the addresses of your remote agents.
# Azure AI Foundry configuration
AZURE_AI_AGENT_ENDPOINT=https://your-ai-foundry-endpoint.azure.com
AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME=Your AI Foundry Model Deployment Name
# Remote agent endpoints
PLAYWRIGHT_AGENT_URL=http://localhost:10001
TOOL_AGENT_URL=http://localhost:10002
# Optional: MCP server configuration
MCP_SSE_URL=http://localhost:7071/runtime/webhooks/mcp/sse
AZURE_AI_AGENT_ENDPOINT and AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME point the routing agent at your Azure AI Foundry project and the model deployment it should use for routing decisions. The two agent URLs are just local ports here since everything runs on one machine for development, but in production these should be internal service addresses resolved through service discovery rather than hardcoded environment variables. MCP_SSE_URL is optional and only needed if you are wiring in serverless MCP functions.
Building the central routing agent
The routing agent is the piece that makes the whole system feel coherent to a user. It does not do any of the actual work itself, it just figures out which specialist should.
import json
import os
import time
import uuid
from typing import Any, Dict, List
import httpx
from a2a.client import A2ACardResolver
from azure.ai.agents import AgentsClient
from azure.identity import DefaultAzureCredential
from dotenv import load_dotenv
class RoutingAgent:
"""Central routing agent powered by Azure AI Foundry."""
def __init__(self):
self.remote_agent_connections = {}
self.cards = {}
self.agents_client = AgentsClient(
endpoint=os.environ["AZURE_AI_AGENT_ENDPOINT"],
credential=DefaultAzureCredential(),
)
self.azure_agent = None
self.current_thread = None
async def initialize(self, remote_agent_addresses: list[str]):
"""Initialize with A2A agent discovery."""
# Discover remote agents via A2A protocol
async with httpx.AsyncClient(timeout=30) as client:
for address in remote_agent_addresses:
try:
card_resolver = A2ACardResolver(client, address)
card = await card_resolver.get_agent_card()
from remote_agent_connection import RemoteAgentConnections
remote_connection = RemoteAgentConnections(
agent_card=card, agent_url=address
)
self.remote_agent_connections[card.name] = remote_connection
self.cards[card.name] = card
except Exception as e:
print(f'Failed to connect to agent at {address}: {e}')
# Create Azure AI agent for intelligent routing
await self._create_azure_agent()
async def _create_azure_agent(self):
"""Create Azure AI agent with function calling capabilities."""
instructions = self._get_routing_instructions()
# Define function for task delegation
tools = [{
"type": "function",
"function": {
"name": "send_message",
"description": "Delegate task to specialized remote agent",
"parameters": {
"type": "object",
"properties": {
"agent_name": {"type": "string"},
"task": {"type": "string"}
},
"required": ["agent_name", "task"]
}
}
}]
model_name = os.environ.get("AZURE_AI_AGENT_MODEL_DEPLOYMENT_NAME", "gpt-4")
self.azure_agent = self.agents_client.create_agent(
model=model_name,
name="routing-agent",
instructions=instructions,
tools=tools
)
self.current_thread = self.agents_client.threads.create()
print(f"Routing agent initialized: {self.azure_agent.id}")
The initialize method is where A2A earns its keep. For every remote agent address you give it, the code creates an A2ACardResolver, fetches that agent’s Agent Card, and stores the card alongside a connection object. This is agent discovery happening in practice, the router never needs to know in advance what a remote agent can do, it reads that from the card at startup.
Once discovery finishes, _create_azure_agent builds an Azure AI agent and gives it a single function tool called send_message, which is how the model actually delegates a task once it decides who should handle it. The routing instructions are built dynamically from the discovered agent cards, so adding a third remote agent tomorrow updates the router’s prompt automatically, with no code change needed.
One thing worth watching in production is the try and except block around each card fetch. If a remote agent is down, the loop swallows the error and moves on, which means the router silently routes around a dead agent instead of failing loudly. That is fine for resilience, but you want proper alerting on those failures somewhere, otherwise an agent can be offline for days before anyone notices.
Building specialised agents with MCP integration
The remote agents are regular Semantic Kernel agents, and MCP is what gives them their actual capabilities.
from semantic_kernel.agents import AzureAIAgent, AzureAIAgentSettings
from semantic_kernel.connectors.mcp import MCPStdioPlugin, MCPSsePlugin
from azure.identity.aio import DefaultAzureCredential
class SemanticKernelMCPAgent:
"""Specialized agent with MCP plugin integration."""
def __init__(self):
self.agent = None
self.client = None
self.credential = None
self.plugins = []
async def initialize_playwright_agent(self):
"""Initialize with Playwright automation via MCP STDIO."""
try:
self.credential = DefaultAzureCredential()
self.client = await AzureAIAgent.create_client(
credential=self.credential
).__aenter__()
# Create Playwright MCP plugin
playwright_plugin = MCPStdioPlugin(
name="Playwright",
command="npx",
args=["@playwright/mcp@latest"],
)
await playwright_plugin.__aenter__()
self.plugins.append(playwright_plugin)
# Create specialized agent
agent_definition = await self.client.agents.create_agent(
model=AzureAIAgentSettings().model_deployment_name,
name="PlaywrightAgent",
instructions=(
"You are a web automation specialist. Use Playwright to "
"navigate websites, take screenshots, interact with elements, "
"and perform browser automation tasks."
),
)
self.agent = AzureAIAgent(
client=self.client,
definition=agent_definition,
plugins=self.plugins,
)
except Exception as e:
await self.cleanup()
raise
async def initialize_tools_agent(self, mcp_url: str):
"""Initialize with development tools via MCP SSE."""
try:
self.credential = DefaultAzureCredential()
self.client = AzureAIAgent.create_client(credential=self.credential)
# Create development tools MCP plugin
tools_plugin = MCPSsePlugin(
name="DevTools",
url=mcp_url,
)
await tools_plugin.__aenter__()
self.plugins.append(tools_plugin)
agent_definition = await self.client.agents.create_agent(
model=AzureAIAgentSettings().model_deployment_name,
name="DevAssistant",
instructions=(
"You are a development assistant. Help with repository "
"management, file operations, opening projects in VS Code, "
"and other development tasks."
),
)
self.agent = AzureAIAgent(
client=self.client,
definition=agent_definition,
plugins=self.plugins,
)
except Exception as e:
await self.cleanup()
raise
async def invoke(self, user_input: str) -> dict[str, Any]:
"""Process tasks through the specialized agent."""
if not self.agent:
return {
'is_task_complete': False,
'content': 'Agent not initialized.',
}
try:
responses = []
async for response in self.agent.invoke(
messages=user_input,
thread=self.thread,
):
responses.append(str(response))
self.thread = response.thread
return {
'is_task_complete': True,
'content': "\n".join(responses) or "No response received.",
}
except Exception as e:
return {
'is_task_complete': False,
'content': f'Error: {str(e)}',
}
initialize_playwright_agent wires up an MCPStdioPlugin that launches the Playwright MCP server as a child process using npx, then attaches it to an Azure AI agent as a plugin. Once that plugin is attached, the agent can call Playwright’s tools, navigate pages, take screenshots and click elements, the same way it would call any Semantic Kernel plugin.
initialize_tools_agent does the same thing but connects to an MCP server over Server-Sent Events instead of spawning a process, which is the right shape for a server-hosted MCP endpoint like an Azure Function. The invoke method is what the routing agent’s send_message call actually triggers, and it streams responses from the underlying SK agent, joining them into a single string.
There is a bug worth flagging before you copy this code as-is. invoke references self.thread, but the constructor never sets it, only self.current_thread appears on the routing agent’s class elsewhere in the sample. Running this unmodified throws an AttributeError the first time invoke runs, so add self.thread = None to __init__ before relying on it.
Adding a chat interface with Gradio
None of this is useful without a way for a user to actually talk to the system, and the sample uses Gradio for a quick web chat interface.
import asyncio
import gradio as gr
from routing_agent import RoutingAgent
async def get_response_from_agent(
message: str, history: list[gr.ChatMessage]
) -> gr.ChatMessage:
"""Process user messages through the routing system."""
global ROUTING_AGENT
try:
response = await ROUTING_AGENT.process_user_message(message)
return gr.ChatMessage(role="assistant", content=response)
except Exception as e:
return gr.ChatMessage(
role="assistant",
content=f"Error: {str(e)}"
)
async def main():
"""Launch the multi-agent system."""
# Initialize routing agent
global ROUTING_AGENT
ROUTING_AGENT = await RoutingAgent.create([
os.getenv('PLAYWRIGHT_AGENT_URL', 'http://localhost:10001'),
os.getenv('TOOL_AGENT_URL', 'http://localhost:10002'),
])
ROUTING_AGENT.create_agent()
# Create Gradio interface
with gr.Blocks(theme=gr.themes.Ocean()) as demo:
gr.Markdown("# Azure AI Multi-Agent System")
gr.ChatInterface(
get_response_from_agent,
title="Chat with AI Agents",
examples=[
"Navigate to github.com/microsoft and take a screenshot",
"Clone repository https://github.com/microsoft/semantic-kernel",
"Open the cloned project in VS Code",
]
)
demo.launch(server_name="0.0.0.0", server_port=8083)
get_response_from_agent is the callback Gradio invokes on every message, and it simply forwards the text to the routing agent and returns whatever comes back. The main function initialises the routing agent with the two remote agent URLs from environment variables, builds a Gradio Blocks interface with a themed chat panel, and launches it on port 8083 bound to all interfaces.
Keep in mind that binding to 0.0.0.0 is meant for local development or a container sitting behind a reverse proxy. If you expose this directly to the internet, you need authentication in front of it, Gradio’s built-in auth parameter is the minimum bar, not a full solution.
Running the system
Because this architecture has four independent processes, starting it locally means opening four terminals.
# Start MCP server (Azure Functions)
cd mcp_sse_server/MCPAzureFunc
func start
# Start remote agents in separate terminals
cd remote_agents/playwright_agent && uv run .
cd remote_agents/tool_agent && uv run .
# Start the host agent with web interface
cd host_agent && uv run .
The MCP server, an Azure Functions app, starts first since the tool agent depends on it, followed by both remote agents, with the host agent starting last since it needs the remote agent URLs to already be reachable for the A2A discovery step in initialize to succeed. If you start the host agent before the remote agents are listening, the card fetch fails silently as mentioned earlier, and you end up with a working chat interface that cannot actually delegate anything.
It is worth scripting this startup order with a Procfile or a simple shell script once you move past local testing. Manual four-terminal startups do not scale to a team, and they are the kind of thing that works fine for the person who wrote it and breaks for everyone else.
How requests flow through the system
Two examples show how a request moves from the chat box to a specialised agent and back. A request like navigating to a GitHub page and taking a screenshot gets analysed by the host agent, recognised as a web automation task, and delegated to the Playwright agent over A2A. The Playwright agent then talks to its MCP server over STDIO to actually drive the browser, and the screenshot comes back up the same chain.
User: "Navigate to github.com/microsoft and take a screenshot"
Flow:
1. Host Agent (Azure AI) analyzes the request
2. Identifies this as a web automation task
3. Delegates to Playwright Agent via A2A protocol
4. Playwright Agent uses MCP STDIO to execute browser automation
5. Returns screenshot and navigation details to user
User: "Clone https://github.com/microsoft/semantic-kernel and open it in VS Code"
Flow:
1. Host Agent recognizes repository management + IDE operation
2. Delegates to Tool Agent via A2A protocol
3. Tool Agent uses MCP SSE connection to Azure Functions
4. Executes git clone and VS Code launch commands
5. Reports success status back to user
The second request, cloning a repository and opening it in an editor, follows the same shape but lands on the tool agent instead, which reaches its MCP server over SSE rather than STDIO since that agent’s tools are backed by Azure Functions rather than a local process. The routing decision itself comes down to the instructions and description text you give each agent, so the quality of your delegation depends entirely on how precisely you describe what each specialist does. Vague agent descriptions are the most common reason a routing agent picks the wrong specialist.
Production considerations
The original post lists a few production notes, and they are worth taking seriously rather than treating as a checklist to skim. Deploying each agent as its own microservice on something like Azure Container Apps keeps failure domains isolated, if the Playwright agent crashes, it should not take the tool agent down with it.
Azure Service Bus is a better fit than direct HTTP calls for agent discovery and communication at scale, since it handles retries and backpressure that raw A2A over HTTP does not give you for free. Application Insights or an equivalent is not optional here either, a multi-agent system with several hops between services is nearly impossible to debug from logs on a single machine, you need distributed tracing across the whole request path.
Authentication needs to be enforced at every hop, not just at the router. An internal agent that trusts any caller is a soft spot the moment your network boundary gets crossed, and multi-agent systems tend to have more internal boundaries than a typical monolith.
Where this is headed
The A2A ecosystem is still young, and a few things are worth watching. Streaming support between agents is expected to improve, along with multimodal message parts for audio and video rather than just text and JSON. Azure AI Foundry is likely to get native A2A support rather than requiring the manual card resolution shown here, and Copilot Studio integration would open A2A up to low-code builders who are not writing Python.
My take
This pattern makes the most sense once an organisation actually has more than one team building agents independently, which is not every organisation. If you control both sides of every agent in your system, a simpler internal RPC layer is less code to maintain than standing up A2A discovery and Agent Cards for services that will never talk to anyone outside your team. Where A2A earns its complexity is exactly the scenario in this post, agents built on different frameworks by different teams that need to interoperate without agreeing on a shared internal API.
The other trade-off worth naming is operational. Every additional protocol boundary, A2A between agents and MCP between an agent and its tools, is another place a request can silently fail or add latency. Before adopting this shape wholesale, measure the round-trip cost of a routed request against a simpler single-agent design with more tools attached directly, since the flexibility A2A buys you is not free.
Semantic Kernel handles orchestration well, and A2A gives it a standard way to talk outside its own walls. For teams building agents that genuinely need to interoperate across frameworks, this combination is a reasonable starting point, provided you pin your dependencies, add logging across every hop, and confirm you actually need the interoperability before you pay for it.
Leave a Reply