MCP·Engineering
← All case studies

bus-mcp: coordination tools for a fleet of AI agents

// public repo · 24 tools · 352 passing tests · writes off by default
Python fastmcp 24 tools 352 passing tests writes off by default operator routes never wrapped

The problem

Put several AI agents to work on the same machine at once and the hard part stops being any single agent. It becomes coordination: who owns which piece of work right now, what has already been said about it, and whether a claim like "the suite is green" was checked by anyone other than the agent that made it. A shared HTTP coordination service answers those questions, but an agent that talks to it by hand-building curl commands is composing URLs and JSON bodies from strings, with no types, no consistent errors, and nothing stopping a crafted value from landing in a request path.

What I built

bus-mcp is an MCP server that turns a self-hosted coordination bus into typed tools. An agent calls claim_lane(lane="feeds-refactor", owner="session-A") instead of hand-rolling a POST. It adds no capability the HTTP API does not already have; it makes that API safe and ergonomic for an agent to hold. 24 tools in four groups:

  • Messages and lanes (6): an append-only message board, plus lane leases that an agent claims, renews with a heartbeat, and releases. A lane held live by someone else comes back as a clean conflict, not a crash.
  • Threads (5): open, reply, read and resolve a conversation with a beginning and an end, so two agents (or an agent and a human) can settle one question instead of scattering it across a topic.
  • Validations and dispatches (7): request that a claim be refuted, register a dispatch, and vote under it with a pointer to evidence. A claim gets checked, not just believed.
  • Task board, worker, events (6): read the shared task board, claim and finish board work, read a background worker's last heartbeat, and poll an append-only event log.

Every tool returns {"ok": true, ...} or a typed {"ok": false, "error": {...}}: bus_unreachable when the service is down, bus_api_error carrying the status code and the bus's own detail text otherwise. The HTTP layer raises typed exceptions and the route layer normalizes them, so no tool ever hands an agent a stack trace. bus_mcp/client.py, bus_mcp/routes.py

How a tool call reaches the coordination bus. An agent session calls one of 24 MCP tools. The 10 read tools go straight to the bus as GET requests with no auth header and are never gated. The 14 write tools first have any lane name checked, then pass a write gate that is off by default. Three of them, the task claim, heartbeat and finish tools, then pass a second task-claim gate, also off by default. A gate that is off returns a typed refusal and makes no HTTP call. Three operator-only bus routes, validation decide, task mint and board sweep, have no tool at all, and a test suite pins their absence. agent session 24 MCP tools 10 read tools never gated GET, no auth header 14 write tools lane name checked write gate default off 3 task tools task-claim gate default off gate off: typed refusal, no HTTP coordination bus HTTP API no tool at all: validation decide, task mint, board sweep absence pinned by tests/test_rails_pins.py
Reads and writes take different paths on purpose. The 10 read tools are never gated, so an agent can always see the board. The 14 write tools pass a write gate that is off until a deployment turns it on, and the three task-claiming tools pass a second, narrower gate after it. A gate that is off answers with a typed refusal and never makes the HTTP call. The three operator-only routes are not behind a gate at all: there is no tool for them to be behind.

The rails

A coordination service is where one agent can quietly step on another, so most of the work in this server is in what it refuses to do.

  • Writes are off by default. Every mutating tool refuses with a typed policy_refusal until BUS_MCP_ENABLE_WRITE is set for that deployment. The gate is enforced at the route function, one hop below the tool wrapper, so there is no second path around it. It landed in 186fc1f for the four write tools that existed then; every write tool added since carries the same decorator. bus_mcp/config.py
  • Task claiming has its own gate. claim_task, heartbeat_task and finish_task also need BUS_MCP_ENABLE_TASK_CLAIM. The two gates stack with the write gate outermost, so turning on the narrow gate can never route around the broad one.
  • Lane names can't reach the URL path unchecked. The lane parameter of the three lane tools was once interpolated straight into the request path, so a value like feeds/../status could aim a call at a different endpoint. Since 45a4f1a it is validated against ^[A-Za-z0-9_-]+$ first; a bad name returns an invalid_lane error and no HTTP call is made.
  • Operator authority is never wrapped. Deciding a validation, minting executable tasks and sweeping the board stay with a human. None of them has a tool, and tests/test_rails_pins.py checks their absence two ways, by source grep and by listing the registered tools, so the rule is a failing test rather than a promise. resolve_thread also refuses resolved_by="operator" in any casing.
  • One credential per write, never two. A write sends a per-caller machine token when one is configured, otherwise the shared secret, otherwise no header at all. Sending both would imply a fallback the bus never takes, so the client sends at most one. Reads never send either. bus_mcp/client.py

Evidence you can check yourself

Public on purpose. Every claim on this page maps to a file or a commit in the repo:

  • Repo: github.com/jaimenbell/bus-mcp, installable with pip install bus-mcp (0.2.2 on PyPI) and listed on the MCP registry as io.github.jaimenbell/bus-mcp
  • Tests: 352 passed, 1 skipped, 0 failed, measured 2026-09-23 at commit 19f4945 and counted from the junit report. The skip is a live smoke test that needs a running bus and is gated behind BUS_MCP_LIVE=1.
  • No test touches the network: all HTTP is mocked with respx, and an autouse fixture makes any unmocked request raise instead of leaving the process. That matters most on refusal tests, which assert that a call does not happen.
  • Tools: scripts/list_tools.py prints all 24 registered tools with no transport started, and tests/test_check_readme_counts.py fails if a registered tool has no row in the README.
  • CI runs the suite on every push to master and every pull request, and fails if the README's test badge drifts from what the suite reports. scripts/check_readme_counts.py

What it is not, stated in the README too: bus-mcp fronts a private, self-hosted coordination service, not a public one. The identity it puts on a write (BUS_MCP_AGENT_ID) is asserted, not authenticated, and every tool result echoes it back so a caller can see exactly what went on the wire. read_messages accepts three filters that the current backend ignores, and the tool's own description says so instead of implying they work.

What it shows about how I work

Multi-agent systems fail at the seams between agents, not inside any one of them. bus-mcp is the seam made explicit: shared state an agent can read freely, writes it has to be allowed to make, and decisions it can never make on a human's behalf, each enforced in code and pinned by a test. It is the same "MCP over an HTTP API" pattern as my github-mcp build, turned on my own coordination layer, and it is the pattern I'd bring to yours.

Book a scoping call  Next case study →