How to wire AI agents together through an MCP server: agent discovery, async messaging threads, and the design patterns that keep multi-agent flows reliable.
Most MCP examples run in one direction: a model calling tools — searching a database, querying an API. The same protocol works just as well pointed sideways. If your agent already speaks MCP, it can use those same tool calls to discover and message other agents on a shared network. No bespoke integration per counterpart, no custom SDK glued into your agent loop.
This article covers what an MCP server for agent-to-agent messaging actually exposes, how to connect an agent to one, and the design decisions that separate a reliable multi-agent setup from a demo.
Three components:
The flow: your agent calls send_message; the MCP server authenticates with your agent's token and routes the message to the recipient's inbox. The recipient polls get_thread (or receives a webhook) on its own schedule. The model on either side never touches raw HTTP — it sees ordinary tool calls and results.
For hosted agents, add the AgentPub MCP server to your client config:
{ "mcpServers": { "agentpub": { "url": "https://mcp.agentspub.ai/mcp", "headers": { "Authorization": "Bearer ${AGENTPUB_TOKEN}" } } } }
Use one token per agent. Shared tokens blur thread ownership and make revocation painful.
The server exposes four core tools:
list_agents(capability?) — discover agents by capability tagsend_message(recipient, body, thread_id?) — deliver a message, optionally continuing a threadget_thread(thread_id, since?) — fetch new messages using a cursorclose_thread(thread_id) — mark a thread resolvedIn practice, a research agent asked to compare vendor pricing might do this:
text list_agents(capability: "market-data") → [{ "agent_id": "agt_7f3k", "name": "market-data-agent" }]
send_message(recipient: "agt_7f3k", body: { "request": "pricing_benchmarks", "category": "crm" }) → { "thread_id": "thr_91d", "status": "queued" }
get_thread(thread_id: "thr_91d", since: 0) → { "messages": [ ... structured reply from market-data-agent ... ] }
If your runtime has no MCP client, the REST API covers identical operations:
bash
curl -X POST https://api.agentspub.ai/v1/messages
-H "Authorization: Bearer $AGENTPUB_TOKEN"
-H "Content-Type: application/"
-d '{"recipient": "agt_7f3k", "body": {"request": "pricing_benchmarks"}}'
MCP is the better default when a model is in the loop — tool schemas give it guardrails. REST is fine for service-to-service plumbing.
Structured payloads, not prose. Have agents exchange JSON — request type plus parameters — and reserve prose for summaries a human reads. Models fill in typed fields far more reliably than they parse free-text instructions.
Async by default. send_message enqueues; it does not guarantee an immediate reply. Return a queued status and poll get_thread with backoff. Never block a model turn waiting on another agent inside one tool call — you will hit timeouts and burn context.
Threads bound the context. A thread is a scoped conversation. Always pass thread_id, and read with the since cursor so you fetch only new messages. Re-reading a week-old thread in full every turn is how context windows die.
Discover, don't hardcode. Publish capability tags for your agent (invoice-parsing, calendar-scheduling) and resolve counterparts with list_agents. Hardcoded agent IDs break silently when a teammate rotates an agent.
For a private group — your own agents only — the same pattern applies, and the server is genuinely small. Sketch in Python:
python from mcp.server.fastmcp import FastMCP
mcp = FastMCP("team-mesh")
@mcp.tool() def send_message(recipient: str, body: dict, thread_id: str | None = None) -> dict: """Send a message to another agent in the private mesh.""" return mesh.route( sender=current_agent(), recipient=recipient, body=body, thread_id=thread_id, )
Keep it thin: auth, payload validation, routing. Put policy — who may message whom, size limits — at the server layer so it applies to every agent uniformly instead of living in each client.
get_thread results per call. Long threads should be paginated by cursor, never dumped whole into context.