KubeMQ
AiwayTutorial

5. Orchestrate from an LLM (MCP)

Connect over MCP, discover the agent with agent_list, and invoke it with agent_send, or drive a queue and an agent from one LLM prompt.

In Step 2 you built and registered research-agent-01, and in Steps 3 and 4 you discovered and invoked it as an A2A caller. Now you'll put an LLM in the driver's seat: an MCP client connects to the same KubeMQ server, discovers your agent, and calls it — all through the Model Context Protocol, with no new glue code on your side. This is the MCP→A2A bridge: the LLM reaches the exact agent you built in Step 2.

This step assumes KubeMQ is running on port 9090 and research-agent-01 is registered and listening (Steps 1 and 2). The MCP connector is enabled by default — POST /mcp is already live on the shared HTTP server. For the Python path, install the MCP SDK with pip install mcp. Arriving from another page? Any registered agent works; Fastest path: wrap a plain HTTP service registers one in ten minutes.

Connect and list the tools

An MCP client opens a session against POST http://localhost:9090/mcp, negotiates the protocol version (2025-11-25), then calls tools/list. KubeMQ exposes 15 tools — 11 core messaging tools plus 4 agent-bridge tools (agent_list, agent_info, agent_send, agent_query) that appear when the A2A agent registry is available. The agent-bridge tools are what let the LLM reach your agent.

The MCP session version 2025-11-25 is a different version from the A2A Agent Card's protocolVersions: ["1.0"] you registered in Step 2 — one is the MCP handshake, the other is the agent's A2A card version. They are not interchangeable.

First open a session with initialize, then list the tools with the returned session ID:

# 1. Initialize a session — note the protocol version is the MCP one.
curl -X POST http://localhost:9090/mcp \
  -H "Content-Type: application/json" \
  -d '{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-11-25",
      "capabilities": {},
      "clientInfo": {"name": "tutorial", "version": "1.0.0"}
    }
  }'

# 2. List the tools (substitute the session ID from the response above).
curl -X POST http://localhost:9090/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Session-Id: <session-id>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/list"
  }'

The mcp SDK runs the initialize handshake and tracks the session ID for you over the Streamable HTTP transport — you only call tools/list:

import asyncio

from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client

KUBEMQ_MCP_URL = "http://localhost:9090"


async def main() -> None:
    async with streamablehttp_client(f"{KUBEMQ_MCP_URL}/mcp") as (read, write, _):
        async with ClientSession(read, write) as session:
            await session.initialize()

            tools = await session.list_tools()
            names = [t.name for t in tools.tools]
            print(f"{len(names)} tools available")  # 15: 11 core + 4 agent-bridge
            print("agent-bridge tools:", [n for n in names if n.startswith("agent_")])


if __name__ == "__main__":
    asyncio.run(main())

The tool list includes the four agent-bridge tools alongside the 11 core messaging tools:

["agent_list", "agent_info", "agent_send", "agent_query"]

Discover the agent with agent_list

agent_list reads the same A2A registry you queried in Step 3 — but now through an MCP tool call. Pass skill_tags to filter by capability, exactly as before, and the LLM gets back research-agent-01 without ever hard-coding its ID.

curl -X POST http://localhost:9090/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Session-Id: <session-id>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 3,
    "method": "tools/call",
    "params": {
      "name": "agent_list",
      "arguments": {"skill_tags": ["research"]}
    }
  }'
result = await session.call_tool("agent_list", {"skill_tags": ["research"]})

print(f"IsError: {result.isError}")
for content in result.content:
    print(f"Result: {content.text}")

The result text is a JSON array of agent cards — your agent is in it:

{
  "jsonrpc": "2.0",
  "id": 3,
  "result": {
    "content": [{"type": "text", "text": "[{\"agent_id\":\"research-agent-01\",\"name\":\"Research Agent\",\"skills\":[{\"id\":\"research\",\"name\":\"Research\",\"tags\":[\"research\",\"summarize\"]}]}]"}]
  }
}

Invoke the agent with agent_send

agent_send wraps your message in an A2A message/send envelope and forwards it through the per-agent Agent Bridge — the same path the direct A2A call took in Step 3, just initiated from MCP. By default the call is blocking: it waits up to timeout_seconds for the agent's reply (default 60, max 300). Pass blocking: false for fire-and-forget, or context_id to thread the message into an existing conversation.

curl -X POST http://localhost:9090/mcp \
  -H "Content-Type: application/json" \
  -H "MCP-Session-Id: <session-id>" \
  -d '{
    "jsonrpc": "2.0",
    "id": 4,
    "method": "tools/call",
    "params": {
      "name": "agent_send",
      "arguments": {
        "agent_id": "research-agent-01",
        "message": "Summarize the history of message queues",
        "timeout_seconds": 60
      }
    }
  }'
result = await session.call_tool("agent_send", {
    "agent_id": "research-agent-01",
    "message": "Summarize the history of message queues",
    "timeout_seconds": 60,
})

print(f"IsError: {result.isError}")
for content in result.content:
    print(f"Result: {content.text}")

The agent's reply comes back in the content text: your Step 2 agent's full JSON-RPC response, with the id the bridge generated. The call returns:

{
  "jsonrpc": "2.0",
  "id": 4,
  "result": {
    "content": [{"type": "text", "text": "{\"jsonrpc\": \"2.0\", \"id\": \"<bridge-request-id>\", \"result\": {\"summary\": \"Research summary for: Summarize the history of message queues\", \"sources\": 3}}"}]
  }
}

An unknown agent_id returns isError: true with the text error: agent <id> not found — a tool-level error in the content block, not a JSON-RPC protocol error. The bridge also adds a gateway timeout buffer on top of your timeout_seconds. See Agent-bridge tools for agent_info, agent_query, and the full argument and error reference.

One prompt, two tool calls

With an MCP host connected (see Connect an MCP host), one prompt can drive a queue and an agent. The example uses order-status-agent from Fastest path: wrap a plain HTTP service; with research-agent-01, ask for a summary instead.

Ask your host:

"Enqueue order 1042 for the fulfillment worker, then ask the order-status agent for an ETA."

The host turns that into two MCP tool calls on the same /mcp session: one to the durable queue, one to the agent bridge.

queue_send
{ "name": "queue_send", "arguments": { "channel": "fulfillment", "body": "{\"id\":1042}" } }
agent_send
{ "name": "agent_send", "arguments": { "agent_id": "order-status-agent", "message": "What is the ETA for order 1042?" } }

You should see two results in the conversation. queue_send returns the new message's ID:

queue_send result
{
  "content": [
    { "type": "text", "text": "{\"is_error\":false,\"message_id\":\"<message-id>\",\"sent_at\":\"<sent-at>\"}" }
  ]
}

agent_send returns the agent's full JSON-RPC response as text:

agent_send result
{
  "content": [
    { "type": "text", "text": "{\"jsonrpc\": \"2.0\", \"id\": \"<bridge-request-id>\", \"result\": {\"status\": \"in-transit\", \"eta\": \"2 days\"}}" }
  ]
}

One fabric, no glue code

Step back and notice what just happened. The LLM discovered your agent by capability and invoked it — and you wrote no integration code to make that possible. The same research-agent-01 you built in Step 2 (a plain-HTTP service, no KubeMQ SDK) is now reachable by an A2A caller and by any MCP-speaking LLM, over the same message broker. That's the Aiway thesis: one fabric, two doors — register once, reach it from both.

To wire this into a real LLM host such as Claude Desktop, see Connect an MCP host. The host then sees all 15 tools (including the four agent-bridge tools) and can call your agent from a conversation.

Troubleshooting

  • The host lists no KubeMQ tools: restart it fully, then check it against Connect an MCP host.
  • agent_send returns isError: true with error: agent <id> not found: the agent is not registered. Registrations expire about 5 minutes after the last heartbeat, so register it again.
  • queue_send returns isError: true with error: reserved channel name or error: channel and body are required: the channel starts with a reserved prefix such as _AGENTS_., or channel or body is missing.

Where to go next

You've built the full fabric end-to-end: started KubeMQ, registered a zero-SDK agent, discovered and invoked it, streamed live task events, and orchestrated it from an LLM over MCP. From here, go deeper:

Was this page helpful?

On this page