bus-mcp: coordination tools for a fleet of AI agents
Python fastmcp 24 tools 352 passing tests writes off by default operator routes never wrappedThe 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
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_refusaluntilBUS_MCP_ENABLE_WRITEis 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 in186fc1ffor 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_taskandfinish_taskalso needBUS_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/../statuscould aim a call at a different endpoint. Since45a4f1ait is validated against^[A-Za-z0-9_-]+$first; a bad name returns aninvalid_laneerror 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.pychecks 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_threadalso refusesresolved_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 asio.github.jaimenbell/bus-mcp - Tests:
352 passed, 1 skipped, 0 failed, measured 2026-09-23 at commit19f4945and counted from the junit report. The skip is a live smoke test that needs a running bus and is gated behindBUS_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.pyprints all 24 registered tools with no transport started, andtests/test_check_readme_counts.pyfails 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.