Agent Mesh Interop: MCP for Tools + A2A for Agent-to-Agent (Without Building a Hairball)
Teams are moving from one big agent to many small agents. That’s the trend. But it creates two problems: how do agents share tools, and how do they talk to each other?
The answer isn’t “just call tools directly” or “pass JSON between agents.” That breaks at scale. You need standards.
This article covers how to use MCP (Model Context Protocol) for tool connectivity and A2A (Agent-to-Agent) for agent communication. Together, they form an agent mesh that doesn’t become a hairball.
The actual problem
The N×M integration mess
You have 5 agents. Each agent needs access to 10 tools. That’s 50 integrations to build and maintain. Add 5 more agents. Now it’s 100 integrations. Add 10 more tools. Now it’s 200.
This is the N×M problem. Every agent talks to every tool. Every tool talks to every agent. The connections multiply.
Why “just call tools directly” breaks
At first, calling tools directly works. Agent A calls Tool X. Agent B calls Tool Y. Simple.
Then you need Agent A to also use Tool Y. You add the integration. Then Agent B needs Tool X. You add another integration. Then you need to version tools. Then you need to handle permissions. Then you need to audit who called what.
Before you know it, every agent has its own way of calling tools. Some use HTTP. Some use gRPC. Some use custom protocols. You can’t change a tool without breaking half your agents.
Ad-hoc JSON contracts don’t scale
Agents need to talk to each other. So you define a JSON format. Agent A sends a message to Agent B. It works.
Then Agent C needs to talk to Agent B. But Agent C has different needs. So you add optional fields. Then Agent D needs different fields. You add more optional fields. Then you need versioning. Then you need backward compatibility.
Six months later, you have 20 different message formats. Some are compatible. Some aren’t. Nobody knows which is which.
What changes with 5–20 agents
With 1–2 agents, you can get away with ad-hoc solutions. Direct tool calls work. Simple JSON messages work.
With 5–20 agents, you need:
- Discovery: How do agents find each other? How do they find tools?
- Delegation: How does Agent A ask Agent B to do something?
- Policy: Who can call what? What are the rate limits? What’s the budget?
- Coordination: How do agents avoid stepping on each other?
Without standards, you build a hairball. Every agent connects to every other agent. Every agent connects to every tool. The connections are unmanaged. The policies are scattered. The failures cascade.
Define your mesh: control plane vs data plane
An agent mesh has two planes: control and data. Keep them separate.
Control plane: identity, policy, discovery, routing
The control plane manages the mesh. It doesn’t handle messages or tool calls. It handles:
- Identity: Who is Agent A? How do we authenticate it?
- Policy: What can Agent A do? What tools can it access? What’s its rate limit?
- Discovery: Where is Agent B? What can it do?
- Routing: How do messages get from Agent A to Agent B?
Think of it like DNS or a service registry. It’s metadata about the mesh.
Data plane: messages, tool calls, results
The data plane handles actual work. It carries:
- Messages between agents
- Tool calls and responses
- Results and errors
The data plane is fast. It doesn’t do policy checks. It doesn’t do discovery. It just moves data.
What an agent gateway does
An agent gateway sits between agents and the rest of the system. It handles:
- Authentication: Is this request from a valid agent?
- Authorization: Can this agent call this tool? Can it send messages to that agent?
- Rate limiting: Has this agent exceeded its quota?
- Budget tracking: How many tokens has this agent used?
- Routing: Where should this message go?
- Observability: Logging, tracing, metrics
The gateway is the single point of control. All traffic goes through it. All policies are enforced there.
Use MCP for tool connectivity
What MCP is
MCP (Model Context Protocol) is a standard for connecting agents to tools. It defines how tools expose their capabilities, how agents discover tools, and how tool calls work.
Think of it like REST for tools. Instead of every tool having its own API, tools implement MCP. Agents use MCP clients to call tools. The protocol handles discovery, versioning, and contracts.
Why tool servers matter
With MCP, tools run as servers. They expose their capabilities as a catalog. Agents query the catalog to see what’s available.
This solves several problems:
- Tool catalogs: Agents can discover tools without hardcoding URLs
- Permissions: Tools can declare what permissions they need
- Isolation: Tools run in separate processes. A crash doesn’t take down agents
- Versioning: Tools can expose multiple versions. Agents can choose which to use
Tool versioning and contract drift
Tools change. New versions add features. Old versions get deprecated. Without versioning, you break agents when you update a tool.
MCP handles this:
- Tools expose version information
- Agents can request specific versions
- Tools can support multiple versions simultaneously
- Breaking changes are explicit
You still need to manage contract drift. But MCP makes it visible. You can see which agents use which versions. You can plan migrations.
Use A2A for agent-to-agent
What A2A standardizes
A2A (Agent-to-Agent) is a protocol for agent communication. It defines:
- Secure exchange: How agents authenticate and encrypt messages
- Coordination: How agents delegate tasks and share results
- Message formats: Standard envelopes and payloads
A2A doesn’t replace MCP. They work together. MCP is for tools. A2A is for agents.
When to use A2A messages vs tool calls
Use A2A messages when:
- One agent needs another agent to do something
- Agents need to coordinate (e.g., “wait for me to finish”)
- Agents need to share context or state
Use tool calls (via MCP) when:
- An agent needs to call an external service
- An agent needs to access a database or API
- The operation is stateless and doesn’t require agent coordination
The rule: agents talk to agents via A2A. Agents talk to tools via MCP.
A practical interop blueprint
Here’s how to build an agent mesh that doesn’t become a hairball.
Agent registry (service discovery)
Agents register themselves in a registry. They declare:
- Their identity
- Their capabilities (what they can do)
- Their location (how to reach them)
Other agents query the registry to find agents. The registry is the control plane.
interface AgentRegistry {
register(agent: AgentInfo): Promise<void>;
discover(capability: string): Promise<AgentInfo[]>;
get(agentId: string): Promise<AgentInfo | null>;
}
Message envelope
All messages use a standard envelope. It includes:
trace_id: For distributed tracingtenant_id: For multi-tenancyauth: Authentication tokensbudget: Token/time budget for this requestsource_agent: Who sent thistarget_agent: Who should receive this
interface MessageEnvelope {
trace_id: string;
tenant_id: string;
auth: AuthToken;
budget: {
max_tokens?: number;
max_time_ms?: number;
};
source_agent: string;
target_agent: string;
payload: unknown;
timestamp: number;
}
Policy checks at the gateway
The gateway enforces policies before routing messages or tool calls:
- RBAC/ABAC: Role-based or attribute-based access control
- Data scopes: What data can this agent access?
- Rate limits: How many requests per second?
- Budget limits: How many tokens per hour?
If a request violates policy, the gateway rejects it. The agent never sees the tool or the other agent.
Timeouts, retries, and idempotency
Agent tasks need reliability:
- Timeouts: How long should an agent wait for a response?
- Retries: Should failed requests be retried?
- Idempotency: Can this request be safely repeated?
These belong in the gateway and the message envelope. Not in individual agents.
Failure modes you will hit
Tool overload
Agents get access to many tools. They see all tool schemas in their context. With 50 tools, that’s a lot of context.
The solution: tool allowlists. Agents only see tools they’re allowed to use. The gateway filters the tool catalog based on policy.
Prompt injection through tool outputs
Tools return data. That data might contain prompts. If an agent uses tool output directly in a prompt, it’s vulnerable to injection.
The gateway should sanitize tool outputs. Strip control characters. Validate formats. Escape special sequences.
Infinite delegation loops
Agent A asks Agent B to do something. Agent B asks Agent C. Agent C asks Agent A. Loop.
Prevent this with:
- Maximum delegation depth (e.g., 5 hops)
- A seen-task hash set (track what’s been requested)
- Timeouts on delegation chains
”Looks successful” but wrong
An agent completes a task. It returns success. But the result is wrong. Nobody verifies it.
Add a verification step. Another agent checks the result. Or the gateway validates against a schema. Or you use a consensus mechanism for critical operations.
Reference checklist
A2A/MCP fit: what they solve, what they don’t
MCP solves:
- Tool discovery and versioning
- Standard tool call protocol
- Tool isolation and permissions
MCP doesn’t solve:
- Agent-to-agent communication
- Multi-agent coordination
- Policy enforcement
A2A solves:
- Agent-to-agent messaging
- Secure agent communication
- Task delegation between agents
A2A doesn’t solve:
- Tool connectivity
- Tool versioning
- Tool permissions
What you still need to implement
A2A and MCP are protocols. You still need to build:
- Auth: How do agents authenticate? How do you issue tokens?
- Quotas: How do you track and enforce rate limits?
- Audit logs: Who called what, when, and why?
- Tracing: How do you trace a request across agents and tools?
These are implementation details. The protocols don’t define them. You choose how to implement them.
Code samples
Sample 1: Message envelope schema
All agents use this envelope format:
// src/types.ts
export interface MessageEnvelope {
trace_id: string;
tenant_id: string;
auth: {
agent_id: string;
token: string;
expires_at: number;
};
budget: {
max_tokens?: number;
max_time_ms?: number;
spent_tokens?: number;
spent_time_ms?: number;
};
source_agent: string;
target_agent: string;
payload: unknown;
timestamp: number;
delegation_depth: number;
}
export interface AgentMessage {
type: 'request' | 'response' | 'error';
task_id: string;
action: string;
parameters: Record<string, unknown>;
context?: Record<string, unknown>;
}
Sample 2: Gateway middleware
The gateway enforces policies:
// src/gateway/middleware.ts
import { MessageEnvelope, AgentMessage } from '../types';
export class GatewayMiddleware {
constructor(
private agentRegistry: AgentRegistry,
private policyEngine: PolicyEngine,
private budgetTracker: BudgetTracker
) {}
async processRequest(envelope: MessageEnvelope): Promise<MessageEnvelope> {
// 1. Validate trace_id propagation
if (!envelope.trace_id) {
envelope.trace_id = this.generateTraceId();
}
// 2. Check per-agent tool allowlist
const agentInfo = await this.agentRegistry.get(envelope.auth.agent_id);
if (!agentInfo) {
throw new Error('Agent not found');
}
const message = envelope.payload as AgentMessage;
if (message.type === 'request' && message.action.startsWith('tool:')) {
const toolName = message.action.replace('tool:', '');
if (!agentInfo.allowed_tools.includes(toolName)) {
throw new Error(`Tool ${toolName} not allowed for agent ${envelope.auth.agent_id}`);
}
}
// 3. Check per-request token/time budget
if (envelope.budget.max_tokens) {
const usage = await this.budgetTracker.getUsage(envelope.auth.agent_id);
if (usage.tokens_used >= envelope.budget.max_tokens) {
throw new Error('Token budget exceeded');
}
}
if (envelope.budget.max_time_ms) {
const startTime = Date.now();
envelope.budget.spent_time_ms = startTime - envelope.timestamp;
if (envelope.budget.spent_time_ms > envelope.budget.max_time_ms) {
throw new Error('Time budget exceeded');
}
}
// 4. Check delegation depth
if (envelope.delegation_depth > 5) {
throw new Error('Maximum delegation depth exceeded');
}
return envelope;
}
private generateTraceId(): string {
return `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
}
}
Sample 3: MCP tool server stub
A simple MCP tool server:
// src/mcp/tool-server.ts
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
export class MCPToolServer {
private server: Server;
private tools: Map<string, ToolDefinition> = new Map();
constructor() {
this.server = new Server({
name: 'example-tool-server',
version: '1.0.0',
}, {
capabilities: {
tools: {},
},
});
this.setupHandlers();
}
private setupHandlers() {
// List available tools
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: Array.from(this.tools.values()),
};
});
// Call a tool
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
const tool = this.tools.get(request.params.name);
if (!tool) {
throw new Error(`Tool ${request.params.name} not found`);
}
// Execute the tool
const result = await this.executeTool(tool, request.params.arguments);
return {
content: [
{
type: 'text',
text: JSON.stringify(result),
},
],
};
});
}
registerTool(tool: ToolDefinition) {
this.tools.set(tool.name, tool);
}
private async executeTool(tool: ToolDefinition, args: unknown): Promise<unknown> {
// Tool execution logic
return tool.handler(args);
}
async start() {
const transport = new StdioServerTransport();
await this.server.connect(transport);
}
}
// Example tool
const calculatorTool: ToolDefinition = {
name: 'calculator',
description: 'Performs basic arithmetic operations',
inputSchema: {
type: 'object',
properties: {
operation: { type: 'string', enum: ['add', 'subtract', 'multiply', 'divide'] },
a: { type: 'number' },
b: { type: 'number' },
},
required: ['operation', 'a', 'b'],
},
handler: async (args: any) => {
const { operation, a, b } = args;
switch (operation) {
case 'add': return a + b;
case 'subtract': return a - b;
case 'multiply': return a * b;
case 'divide': return b !== 0 ? a / b : { error: 'Division by zero' };
default: throw new Error(`Unknown operation: ${operation}`);
}
},
};
MCP client call:
// src/mcp/tool-client.ts
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
export class MCPToolClient {
private client: Client;
constructor(transport: Transport) {
this.client = new Client({
name: 'agent-client',
version: '1.0.0',
}, {
capabilities: {},
});
this.client.connect(transport);
}
async listTools(): Promise<ToolDefinition[]> {
const response = await this.client.request({
method: 'tools/list',
}, ListToolsRequestSchema);
return response.tools;
}
async callTool(name: string, args: unknown): Promise<unknown> {
const response = await this.client.request({
method: 'tools/call',
params: {
name,
arguments: args,
},
}, CallToolRequestSchema);
return JSON.parse(response.content[0].text);
}
}
Sample 4: A2A message handler
An A2A-style agent message router:
// src/a2a/message-handler.ts
import { MessageEnvelope, AgentMessage } from '../types';
export class A2AMessageHandler {
constructor(
private agentRegistry: AgentRegistry,
private gateway: GatewayMiddleware
) {}
async handleMessage(envelope: MessageEnvelope): Promise<MessageEnvelope> {
// Process through gateway
const processed = await this.gateway.processRequest(envelope);
// Route to target agent
const targetAgent = await this.agentRegistry.get(processed.target_agent);
if (!targetAgent) {
throw new Error(`Target agent ${processed.target_agent} not found`);
}
// Dispatch to agent
const message = processed.payload as AgentMessage;
const response = await this.dispatchToAgent(targetAgent, message, processed);
// Create response envelope
return {
...processed,
source_agent: processed.target_agent,
target_agent: processed.source_agent,
payload: response,
timestamp: Date.now(),
};
}
private async dispatchToAgent(
agent: AgentInfo,
message: AgentMessage,
envelope: MessageEnvelope
): Promise<AgentMessage> {
// Call agent's handler
const handler = this.getAgentHandler(agent);
return await handler(message, envelope);
}
private getAgentHandler(agent: AgentInfo): (msg: AgentMessage, env: MessageEnvelope) => Promise<AgentMessage> {
// Return agent's message handler
// This would typically be loaded from the agent's implementation
return async (msg, env) => {
// Agent-specific logic
return {
type: 'response',
task_id: msg.task_id,
action: msg.action,
parameters: { result: 'success' },
};
};
}
}
Sample 5: Loop guard
Prevent infinite delegation loops:
// src/gateway/loop-guard.ts
import { MessageEnvelope } from '../types';
export class LoopGuard {
private seenTasks: Map<string, Set<string>> = new Map();
private readonly MAX_DEPTH = 5;
private readonly TTL_MS = 60000; // 1 minute
checkLoop(envelope: MessageEnvelope): void {
// Check delegation depth
if (envelope.delegation_depth > this.MAX_DEPTH) {
throw new Error(`Maximum delegation depth ${this.MAX_DEPTH} exceeded`);
}
// Check for circular delegation
const taskKey = this.getTaskKey(envelope);
const seenSet = this.seenTasks.get(taskKey) || new Set();
const delegationPath = `${envelope.source_agent}->${envelope.target_agent}`;
if (seenSet.has(delegationPath)) {
throw new Error(`Circular delegation detected: ${delegationPath}`);
}
// Track this delegation
seenSet.add(delegationPath);
this.seenTasks.set(taskKey, seenSet);
// Cleanup old entries
setTimeout(() => {
this.seenTasks.delete(taskKey);
}, this.TTL_MS);
}
private getTaskKey(envelope: MessageEnvelope): string {
const message = envelope.payload as AgentMessage;
return `${envelope.trace_id}:${message.task_id}`;
}
incrementDepth(envelope: MessageEnvelope): MessageEnvelope {
return {
...envelope,
delegation_depth: (envelope.delegation_depth || 0) + 1,
};
}
}
Close
Building an agent mesh doesn’t have to create a hairball. Use MCP for tools. Use A2A for agents. Keep the control plane separate from the data plane. Enforce policies at the gateway.
Standards prevent the hairball
Without standards, every agent connects to every tool. Every agent connects to every other agent. The connections multiply. The policies scatter. The failures cascade.
With MCP and A2A, you have:
- Standard tool connectivity
- Standard agent communication
- Clear separation of concerns
- Manageable complexity
Start with the gateway
The gateway is your control point. It enforces policies. It routes messages. It tracks usage. Build it first. Make all traffic go through it.
Add reliability early
Timeouts, retries, idempotency—add them early. Don’t wait until you have 20 agents and everything is breaking. Build them into the message envelope and the gateway.
Monitor and iterate
Watch for the failure modes. Tool overload. Prompt injection. Infinite loops. Wrong results. Add guards. Add verification. Add limits.
An agent mesh is a distributed system. It will fail. Plan for it. Handle it. Make it observable.
The goal isn’t perfection. It’s manageable complexity. MCP and A2A get you there without building a hairball.
Discussion
Loading comments...