bus-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bus_mcp-0.1.0/LICENSE +21 -0
- bus_mcp-0.1.0/PKG-INFO +160 -0
- bus_mcp-0.1.0/README.md +146 -0
- bus_mcp-0.1.0/bus_mcp/__init__.py +0 -0
- bus_mcp-0.1.0/bus_mcp/client.py +120 -0
- bus_mcp-0.1.0/bus_mcp/config.py +50 -0
- bus_mcp-0.1.0/bus_mcp/routes.py +106 -0
- bus_mcp-0.1.0/bus_mcp/server.py +84 -0
- bus_mcp-0.1.0/bus_mcp.egg-info/PKG-INFO +160 -0
- bus_mcp-0.1.0/bus_mcp.egg-info/SOURCES.txt +18 -0
- bus_mcp-0.1.0/bus_mcp.egg-info/dependency_links.txt +1 -0
- bus_mcp-0.1.0/bus_mcp.egg-info/requires.txt +6 -0
- bus_mcp-0.1.0/bus_mcp.egg-info/top_level.txt +1 -0
- bus_mcp-0.1.0/pyproject.toml +27 -0
- bus_mcp-0.1.0/setup.cfg +4 -0
- bus_mcp-0.1.0/tests/test_client.py +176 -0
- bus_mcp-0.1.0/tests/test_config.py +59 -0
- bus_mcp-0.1.0/tests/test_live_smoke.py +31 -0
- bus_mcp-0.1.0/tests/test_routes.py +178 -0
- bus_mcp-0.1.0/tests/test_server.py +114 -0
bus_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jaimen Bell
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
bus_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bus-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ergonomic MCP server fronting the self-hosted AlphaHive coordination-bus HTTP API (backend/coordination_bus.py) -- post/read/claim/release/heartbeat/status as MCP tools instead of hand-rolled curl. Fast-follow to the coordination-bus arc, github-mcp-shaped (MCP over an HTTP API).
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Dist: fastmcp==3.4.2
|
|
9
|
+
Requires-Dist: httpx==0.28.1
|
|
10
|
+
Provides-Extra: test
|
|
11
|
+
Requires-Dist: pytest==9.0.3; extra == "test"
|
|
12
|
+
Requires-Dist: respx==0.23.1; extra == "test"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# bus-mcp
|
|
16
|
+
|
|
17
|
+
An ergonomic MCP server fronting the self-hosted **AlphaHive coordination
|
|
18
|
+
bus** (`backend/coordination_bus.py` in the `alphahive` repo) -- so a Claude
|
|
19
|
+
agent calls `claim_lane("feeds-refactor", owner="session-A")` instead of
|
|
20
|
+
hand-rolling `curl -X POST .../lanes/feeds-refactor/claim -d '{...}'`. Built
|
|
21
|
+
to the [desktop-mcp](https://github.com)/[github-mcp](https://github.com)
|
|
22
|
+
standard (own pyproject, fastmcp server, honest README, real test suite) --
|
|
23
|
+
this is that exact "MCP over an HTTP API" pattern turned on our own
|
|
24
|
+
self-hosted API.
|
|
25
|
+
|
|
26
|
+
## What this is / is not
|
|
27
|
+
|
|
28
|
+
This fronts a **private, localhost-only, no-auth v1 coordination substrate**
|
|
29
|
+
-- not a public service. The bus itself is a blackboard (append-only
|
|
30
|
+
messages) + a lane-claim registry (task-queue leases with steal-on-expiry) +
|
|
31
|
+
a status rollup for a command-center panel. It **executes nothing
|
|
32
|
+
outward-facing**: `action_flag` on a message is recorded and displayed only,
|
|
33
|
+
never acted on by the bus. bus-mcp adds zero new capability over what the
|
|
34
|
+
bus already does via `curl` -- it only makes the six routes ergonomic MCP
|
|
35
|
+
tools with typed inputs and typed errors instead of raw HTTP.
|
|
36
|
+
|
|
37
|
+
## Tools
|
|
38
|
+
|
|
39
|
+
| Tool | Bus route | Purpose |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `post_message` | `POST /api/bus/message` | Append one message to the blackboard (topic, sender, body, action_flag) |
|
|
42
|
+
| `read_messages` | `GET /api/bus/messages` | Recent messages, newest first, optional topic filter |
|
|
43
|
+
| `claim_lane` | `POST /api/bus/lanes/{lane}/claim` | Claim-if-free / steal-if-lease-expired / renew-if-own; 409 if held live by another |
|
|
44
|
+
| `release_lane` | `POST /api/bus/lanes/{lane}/release` | Free a held lane; 409 if held live by another |
|
|
45
|
+
| `heartbeat_lane` | `POST /api/bus/lanes/{lane}/heartbeat` | Renew the lease; 409 if you don't hold it live |
|
|
46
|
+
| `get_bus_status` | `GET /api/bus/status` | Rollup: active lanes, orphaned claims, recent messages, pending action flags |
|
|
47
|
+
|
|
48
|
+
No write-safety *knob* here the way github-mcp has one for real external
|
|
49
|
+
writes -- every bus route is coordination-only (store/display/claim). As of
|
|
50
|
+
coordination-bus **v1.1**, the bus MAY optionally require a shared secret on
|
|
51
|
+
its 4 write routes (default off); this client mirrors that with zero new
|
|
52
|
+
config surface of its own -- see "Write-secret auth (v1.1)" below.
|
|
53
|
+
|
|
54
|
+
## Typed errors, never a raw crash
|
|
55
|
+
|
|
56
|
+
Every tool returns `{"ok": true, ...}` on success or `{"ok": false, "error":
|
|
57
|
+
{...}}` on failure -- never an unhandled exception or stack trace.
|
|
58
|
+
|
|
59
|
+
- **`bus_unreachable`** -- connection refused, timeout, or DNS failure. Means
|
|
60
|
+
the AlphaHive backend isn't running, or is running without the bus routes
|
|
61
|
+
loaded (`backend/coordination_bus.py` mounted on `:8100`).
|
|
62
|
+
- **`bus_api_error`** -- the bus responded with a 4xx/5xx. Carries
|
|
63
|
+
`status_code` + the bus's own `detail` text -- e.g. a `409` lane-conflict
|
|
64
|
+
message telling you who holds the lane and for how long.
|
|
65
|
+
|
|
66
|
+
Internally, `bus_mcp/client.py` raises typed `BusUnreachable` / `BusApiError`
|
|
67
|
+
exceptions; `bus_mcp/routes.py` catches both and normalizes to the dict
|
|
68
|
+
shape above before a tool ever returns. Tests exercise both layers.
|
|
69
|
+
|
|
70
|
+
## Env vars
|
|
71
|
+
|
|
72
|
+
| Var | Default | Purpose |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `BUS_MCP_BASE_URL` | `http://127.0.0.1:8100/api/bus` | Base URL of the coordination bus |
|
|
75
|
+
| `BUS_MCP_TIMEOUT_S` | `10.0` | Per-request timeout (seconds) |
|
|
76
|
+
| `BUS_MCP_LIVE` | unset | Set to `1` to run the real-network smoke test (see Testing) |
|
|
77
|
+
| `BUS_WRITE_SECRET` | unset | Same var the bus itself reads to arm write-auth (v1.1). When set here, every write tool call sends `X-Bus-Secret: <value>` automatically. Unset = no header sent, matching an unarmed bus byte-for-byte. |
|
|
78
|
+
|
|
79
|
+
## Write-secret auth (v1.1)
|
|
80
|
+
|
|
81
|
+
The coordination bus can optionally gate its 4 write routes (`post_message`,
|
|
82
|
+
`claim_lane`, `release_lane`, `heartbeat_lane`) behind a shared secret header
|
|
83
|
+
(`X-Bus-Secret`), read from `BUS_WRITE_SECRET` on the bus side. This client
|
|
84
|
+
reads the **same env var name** from its own process and, when set,
|
|
85
|
+
`bus_mcp/client.py`'s `post()` attaches the header to every write call --
|
|
86
|
+
`bus_mcp/routes.py` and every tool caller stay unaware of arming state
|
|
87
|
+
entirely. `client.get()` never attaches the header (GET routes are never
|
|
88
|
+
gated bus-side).
|
|
89
|
+
|
|
90
|
+
**To use with an armed bus:** set `BUS_WRITE_SECRET` to the same value in
|
|
91
|
+
both the AlphaHive backend's environment and this MCP server's environment
|
|
92
|
+
(e.g. in the config that launches `run_server.py`), then restart both
|
|
93
|
+
processes. If the value is missing or wrong, a write tool call returns the
|
|
94
|
+
normal `{"ok": false, "error": {"type": "bus_api_error", "status_code": 401,
|
|
95
|
+
...}}` shape -- no special-casing needed, it flows through the same typed
|
|
96
|
+
`BusApiError` path as any other 4xx.
|
|
97
|
+
|
|
98
|
+
**Unset (default):** no header is sent, identical to talking to a bus that
|
|
99
|
+
has never been armed -- zero behavior change from pre-v1.1.
|
|
100
|
+
|
|
101
|
+
## Usage examples
|
|
102
|
+
|
|
103
|
+
Once connected in a Claude session, an agent can:
|
|
104
|
+
|
|
105
|
+
```
|
|
106
|
+
claim_lane(lane="feeds-refactor", owner="session-A", lease_s=300)
|
|
107
|
+
heartbeat_lane(lane="feeds-refactor", owner="session-A")
|
|
108
|
+
post_message(topic="converge", sender="session-A", body="lane merged to master")
|
|
109
|
+
release_lane(lane="feeds-refactor", owner="session-A")
|
|
110
|
+
get_bus_status()
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Testing
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
.venv/Scripts/python.exe -m pytest -q
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
All HTTP is mocked via [respx](https://lundberg.github.io/respx/) -- the
|
|
120
|
+
full suite never depends on a live bus. One additional test,
|
|
121
|
+
`tests/test_live_smoke.py::test_live_get_bus_status_returns_rollup`, is
|
|
122
|
+
gated behind `BUS_MCP_LIVE=1` and calls a real running bus's `get_bus_status`
|
|
123
|
+
route. **As of this writing the bus routes are dormant/404 on the live
|
|
124
|
+
`:8100` AlphaHive backend** until the operator restarts it with
|
|
125
|
+
`coordination_bus.py`'s router mounted -- so that one gated test is expected
|
|
126
|
+
to skip (or fail if forced) until that restart happens. That is correct
|
|
127
|
+
behavior, not a bug in this repo.
|
|
128
|
+
|
|
129
|
+
## Install / connect
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python -m venv .venv
|
|
133
|
+
.venv/Scripts/python.exe -m pip install -e ".[test]"
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Registered in `~/.claude.json` under `mcpServers.bus-mcp` as a stdio server
|
|
137
|
+
invoking `run_server.py` by absolute path (no `cwd` needed -- the entrypoint
|
|
138
|
+
adds its own directory to `sys.path`).
|
|
139
|
+
|
|
140
|
+
## Handshake check
|
|
141
|
+
|
|
142
|
+
```bash
|
|
143
|
+
.venv/Scripts/python.exe scripts/list_tools.py
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Prints the six registered tool names with no transport started -- pure
|
|
147
|
+
introspection, useful for verifying the server wires up cleanly after any
|
|
148
|
+
change.
|
|
149
|
+
|
|
150
|
+
## Out of scope
|
|
151
|
+
|
|
152
|
+
- Authenticating *who* `owner`/`sender` claims to be -- the shared secret
|
|
153
|
+
(v1.1) proves possession of a value, not identity; that stays client-
|
|
154
|
+
asserted the same as before. See the bus's own README for that boundary.
|
|
155
|
+
- Restarting the AlphaHive backend to bring the live bus routes up
|
|
156
|
+
(operator, elevated -- not something this MCP does)
|
|
157
|
+
- Bus v2 execution/approval features (a separate, not-yet-built arc)
|
|
158
|
+
|
|
159
|
+
<!-- MCP registry ownership marker -->
|
|
160
|
+
mcp-name: io.github.jaimenbell/bus-mcp
|
bus_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# bus-mcp
|
|
2
|
+
|
|
3
|
+
An ergonomic MCP server fronting the self-hosted **AlphaHive coordination
|
|
4
|
+
bus** (`backend/coordination_bus.py` in the `alphahive` repo) -- so a Claude
|
|
5
|
+
agent calls `claim_lane("feeds-refactor", owner="session-A")` instead of
|
|
6
|
+
hand-rolling `curl -X POST .../lanes/feeds-refactor/claim -d '{...}'`. Built
|
|
7
|
+
to the [desktop-mcp](https://github.com)/[github-mcp](https://github.com)
|
|
8
|
+
standard (own pyproject, fastmcp server, honest README, real test suite) --
|
|
9
|
+
this is that exact "MCP over an HTTP API" pattern turned on our own
|
|
10
|
+
self-hosted API.
|
|
11
|
+
|
|
12
|
+
## What this is / is not
|
|
13
|
+
|
|
14
|
+
This fronts a **private, localhost-only, no-auth v1 coordination substrate**
|
|
15
|
+
-- not a public service. The bus itself is a blackboard (append-only
|
|
16
|
+
messages) + a lane-claim registry (task-queue leases with steal-on-expiry) +
|
|
17
|
+
a status rollup for a command-center panel. It **executes nothing
|
|
18
|
+
outward-facing**: `action_flag` on a message is recorded and displayed only,
|
|
19
|
+
never acted on by the bus. bus-mcp adds zero new capability over what the
|
|
20
|
+
bus already does via `curl` -- it only makes the six routes ergonomic MCP
|
|
21
|
+
tools with typed inputs and typed errors instead of raw HTTP.
|
|
22
|
+
|
|
23
|
+
## Tools
|
|
24
|
+
|
|
25
|
+
| Tool | Bus route | Purpose |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `post_message` | `POST /api/bus/message` | Append one message to the blackboard (topic, sender, body, action_flag) |
|
|
28
|
+
| `read_messages` | `GET /api/bus/messages` | Recent messages, newest first, optional topic filter |
|
|
29
|
+
| `claim_lane` | `POST /api/bus/lanes/{lane}/claim` | Claim-if-free / steal-if-lease-expired / renew-if-own; 409 if held live by another |
|
|
30
|
+
| `release_lane` | `POST /api/bus/lanes/{lane}/release` | Free a held lane; 409 if held live by another |
|
|
31
|
+
| `heartbeat_lane` | `POST /api/bus/lanes/{lane}/heartbeat` | Renew the lease; 409 if you don't hold it live |
|
|
32
|
+
| `get_bus_status` | `GET /api/bus/status` | Rollup: active lanes, orphaned claims, recent messages, pending action flags |
|
|
33
|
+
|
|
34
|
+
No write-safety *knob* here the way github-mcp has one for real external
|
|
35
|
+
writes -- every bus route is coordination-only (store/display/claim). As of
|
|
36
|
+
coordination-bus **v1.1**, the bus MAY optionally require a shared secret on
|
|
37
|
+
its 4 write routes (default off); this client mirrors that with zero new
|
|
38
|
+
config surface of its own -- see "Write-secret auth (v1.1)" below.
|
|
39
|
+
|
|
40
|
+
## Typed errors, never a raw crash
|
|
41
|
+
|
|
42
|
+
Every tool returns `{"ok": true, ...}` on success or `{"ok": false, "error":
|
|
43
|
+
{...}}` on failure -- never an unhandled exception or stack trace.
|
|
44
|
+
|
|
45
|
+
- **`bus_unreachable`** -- connection refused, timeout, or DNS failure. Means
|
|
46
|
+
the AlphaHive backend isn't running, or is running without the bus routes
|
|
47
|
+
loaded (`backend/coordination_bus.py` mounted on `:8100`).
|
|
48
|
+
- **`bus_api_error`** -- the bus responded with a 4xx/5xx. Carries
|
|
49
|
+
`status_code` + the bus's own `detail` text -- e.g. a `409` lane-conflict
|
|
50
|
+
message telling you who holds the lane and for how long.
|
|
51
|
+
|
|
52
|
+
Internally, `bus_mcp/client.py` raises typed `BusUnreachable` / `BusApiError`
|
|
53
|
+
exceptions; `bus_mcp/routes.py` catches both and normalizes to the dict
|
|
54
|
+
shape above before a tool ever returns. Tests exercise both layers.
|
|
55
|
+
|
|
56
|
+
## Env vars
|
|
57
|
+
|
|
58
|
+
| Var | Default | Purpose |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
| `BUS_MCP_BASE_URL` | `http://127.0.0.1:8100/api/bus` | Base URL of the coordination bus |
|
|
61
|
+
| `BUS_MCP_TIMEOUT_S` | `10.0` | Per-request timeout (seconds) |
|
|
62
|
+
| `BUS_MCP_LIVE` | unset | Set to `1` to run the real-network smoke test (see Testing) |
|
|
63
|
+
| `BUS_WRITE_SECRET` | unset | Same var the bus itself reads to arm write-auth (v1.1). When set here, every write tool call sends `X-Bus-Secret: <value>` automatically. Unset = no header sent, matching an unarmed bus byte-for-byte. |
|
|
64
|
+
|
|
65
|
+
## Write-secret auth (v1.1)
|
|
66
|
+
|
|
67
|
+
The coordination bus can optionally gate its 4 write routes (`post_message`,
|
|
68
|
+
`claim_lane`, `release_lane`, `heartbeat_lane`) behind a shared secret header
|
|
69
|
+
(`X-Bus-Secret`), read from `BUS_WRITE_SECRET` on the bus side. This client
|
|
70
|
+
reads the **same env var name** from its own process and, when set,
|
|
71
|
+
`bus_mcp/client.py`'s `post()` attaches the header to every write call --
|
|
72
|
+
`bus_mcp/routes.py` and every tool caller stay unaware of arming state
|
|
73
|
+
entirely. `client.get()` never attaches the header (GET routes are never
|
|
74
|
+
gated bus-side).
|
|
75
|
+
|
|
76
|
+
**To use with an armed bus:** set `BUS_WRITE_SECRET` to the same value in
|
|
77
|
+
both the AlphaHive backend's environment and this MCP server's environment
|
|
78
|
+
(e.g. in the config that launches `run_server.py`), then restart both
|
|
79
|
+
processes. If the value is missing or wrong, a write tool call returns the
|
|
80
|
+
normal `{"ok": false, "error": {"type": "bus_api_error", "status_code": 401,
|
|
81
|
+
...}}` shape -- no special-casing needed, it flows through the same typed
|
|
82
|
+
`BusApiError` path as any other 4xx.
|
|
83
|
+
|
|
84
|
+
**Unset (default):** no header is sent, identical to talking to a bus that
|
|
85
|
+
has never been armed -- zero behavior change from pre-v1.1.
|
|
86
|
+
|
|
87
|
+
## Usage examples
|
|
88
|
+
|
|
89
|
+
Once connected in a Claude session, an agent can:
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
claim_lane(lane="feeds-refactor", owner="session-A", lease_s=300)
|
|
93
|
+
heartbeat_lane(lane="feeds-refactor", owner="session-A")
|
|
94
|
+
post_message(topic="converge", sender="session-A", body="lane merged to master")
|
|
95
|
+
release_lane(lane="feeds-refactor", owner="session-A")
|
|
96
|
+
get_bus_status()
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Testing
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
.venv/Scripts/python.exe -m pytest -q
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
All HTTP is mocked via [respx](https://lundberg.github.io/respx/) -- the
|
|
106
|
+
full suite never depends on a live bus. One additional test,
|
|
107
|
+
`tests/test_live_smoke.py::test_live_get_bus_status_returns_rollup`, is
|
|
108
|
+
gated behind `BUS_MCP_LIVE=1` and calls a real running bus's `get_bus_status`
|
|
109
|
+
route. **As of this writing the bus routes are dormant/404 on the live
|
|
110
|
+
`:8100` AlphaHive backend** until the operator restarts it with
|
|
111
|
+
`coordination_bus.py`'s router mounted -- so that one gated test is expected
|
|
112
|
+
to skip (or fail if forced) until that restart happens. That is correct
|
|
113
|
+
behavior, not a bug in this repo.
|
|
114
|
+
|
|
115
|
+
## Install / connect
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
python -m venv .venv
|
|
119
|
+
.venv/Scripts/python.exe -m pip install -e ".[test]"
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Registered in `~/.claude.json` under `mcpServers.bus-mcp` as a stdio server
|
|
123
|
+
invoking `run_server.py` by absolute path (no `cwd` needed -- the entrypoint
|
|
124
|
+
adds its own directory to `sys.path`).
|
|
125
|
+
|
|
126
|
+
## Handshake check
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
.venv/Scripts/python.exe scripts/list_tools.py
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Prints the six registered tool names with no transport started -- pure
|
|
133
|
+
introspection, useful for verifying the server wires up cleanly after any
|
|
134
|
+
change.
|
|
135
|
+
|
|
136
|
+
## Out of scope
|
|
137
|
+
|
|
138
|
+
- Authenticating *who* `owner`/`sender` claims to be -- the shared secret
|
|
139
|
+
(v1.1) proves possession of a value, not identity; that stays client-
|
|
140
|
+
asserted the same as before. See the bus's own README for that boundary.
|
|
141
|
+
- Restarting the AlphaHive backend to bring the live bus routes up
|
|
142
|
+
(operator, elevated -- not something this MCP does)
|
|
143
|
+
- Bus v2 execution/approval features (a separate, not-yet-built arc)
|
|
144
|
+
|
|
145
|
+
<!-- MCP registry ownership marker -->
|
|
146
|
+
mcp-name: io.github.jaimenbell/bus-mcp
|
|
File without changes
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""bus_mcp.client -- thin, typed httpx wrapper over the coordination-bus HTTP
|
|
2
|
+
API (backend/coordination_bus.py in the alphahive repo).
|
|
3
|
+
|
|
4
|
+
Every bus route is a plain function here returning a parsed JSON dict on
|
|
5
|
+
success. Failure NEVER raises a raw httpx/requests exception up to a tool
|
|
6
|
+
caller and never lets a stack trace reach the MCP transport -- it raises one
|
|
7
|
+
of the two typed exceptions below, which server.py tool wrappers catch and
|
|
8
|
+
turn into a clean `{"ok": False, "error": {...}}` payload.
|
|
9
|
+
|
|
10
|
+
Typed error taxonomy:
|
|
11
|
+
BusUnreachable -- connect failed / timed out / DNS failure / bad URL. The
|
|
12
|
+
bus process (AlphaHive backend on :8100) is not up or not routable.
|
|
13
|
+
BusApiError -- the bus responded but with a 4xx/5xx (e.g. 409 lane
|
|
14
|
+
conflict, 422 validation). Carries status_code + the bus's detail text.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
|
|
22
|
+
from . import config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BusUnreachable(Exception):
|
|
26
|
+
"""The coordination bus is not reachable (connection refused, timeout,
|
|
27
|
+
DNS failure, or a malformed base URL). Typically means the AlphaHive
|
|
28
|
+
backend isn't running, or isn't running with the bus routes loaded."""
|
|
29
|
+
|
|
30
|
+
def __init__(self, message: str, *, tool: str) -> None:
|
|
31
|
+
super().__init__(message)
|
|
32
|
+
self.tool = tool
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BusApiError(Exception):
|
|
36
|
+
"""The bus responded with an HTTP error status (4xx/5xx). Carries the
|
|
37
|
+
status code and the bus's own detail message (e.g. a 409 lane-conflict
|
|
38
|
+
detail string from coordination_bus.py)."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, message: str, *, tool: str, status_code: int) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.tool = tool
|
|
43
|
+
self.status_code = status_code
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _url(path: str) -> str:
|
|
47
|
+
return f"{config.get_base_url()}{path}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _unreachable_message(tool: str) -> str:
|
|
51
|
+
return (
|
|
52
|
+
f"[{tool}] the coordination bus isn't reachable at {config.get_base_url()} "
|
|
53
|
+
"-- is the AlphaHive backend running with the bus routes loaded "
|
|
54
|
+
"(backend/coordination_bus.py mounted on :8100)?"
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _handle_response(tool: str, response: httpx.Response) -> dict[str, Any]:
|
|
59
|
+
if response.status_code >= 400:
|
|
60
|
+
try:
|
|
61
|
+
body = response.json()
|
|
62
|
+
detail = body.get("detail", response.text)
|
|
63
|
+
except (ValueError, AttributeError):
|
|
64
|
+
detail = response.text or f"HTTP {response.status_code}"
|
|
65
|
+
raise BusApiError(
|
|
66
|
+
f"[{tool}] bus returned {response.status_code}: {detail}",
|
|
67
|
+
tool=tool,
|
|
68
|
+
status_code=response.status_code,
|
|
69
|
+
)
|
|
70
|
+
if not response.content:
|
|
71
|
+
return {}
|
|
72
|
+
try:
|
|
73
|
+
return response.json()
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise BusApiError(
|
|
76
|
+
f"[{tool}] bus returned non-JSON content: {exc}",
|
|
77
|
+
tool=tool,
|
|
78
|
+
status_code=response.status_code,
|
|
79
|
+
) from exc
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def request(
|
|
83
|
+
tool: str,
|
|
84
|
+
method: str,
|
|
85
|
+
path: str,
|
|
86
|
+
*,
|
|
87
|
+
params: dict[str, Any] | None = None,
|
|
88
|
+
json: dict[str, Any] | None = None,
|
|
89
|
+
headers: dict[str, str] | None = None,
|
|
90
|
+
) -> dict[str, Any]:
|
|
91
|
+
"""Make one request against the bus. Raises BusUnreachable on a network
|
|
92
|
+
failure, BusApiError on a 4xx/5xx, else returns the parsed JSON body."""
|
|
93
|
+
try:
|
|
94
|
+
with httpx.Client(timeout=config.get_timeout_s()) as http_client:
|
|
95
|
+
response = http_client.request(
|
|
96
|
+
method, _url(path), params=params, json=json, headers=headers
|
|
97
|
+
)
|
|
98
|
+
except httpx.HTTPError as exc:
|
|
99
|
+
raise BusUnreachable(_unreachable_message(tool), tool=tool) from exc
|
|
100
|
+
except httpx.InvalidURL as exc:
|
|
101
|
+
# httpx.InvalidURL does NOT subclass httpx.HTTPError -- must be
|
|
102
|
+
# listed explicitly or a malformed BUS_MCP_BASE_URL crashes instead
|
|
103
|
+
# of surfacing a clean typed error.
|
|
104
|
+
raise BusUnreachable(_unreachable_message(tool), tool=tool) from exc
|
|
105
|
+
return _handle_response(tool, response)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def get(tool: str, path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
109
|
+
return request(tool, "GET", path, params=params)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def post(tool: str, path: str, *, json: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
113
|
+
"""POST to a write route. When BUS_WRITE_SECRET is set in this process's
|
|
114
|
+
env, automatically attaches the X-Bus-Secret header the coordination-bus
|
|
115
|
+
v1.1 write-auth dependency expects -- callers (routes.py) never need to
|
|
116
|
+
know or care whether the bus is armed. Unset (default) sends no header,
|
|
117
|
+
identical to pre-v1.1 behavior."""
|
|
118
|
+
secret = config.get_write_secret()
|
|
119
|
+
headers = {"X-Bus-Secret": secret} if secret is not None else None
|
|
120
|
+
return request(tool, "POST", path, json=json, headers=headers)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""bus_mcp.config -- environment configuration for the bus-mcp server.
|
|
2
|
+
|
|
3
|
+
The coordination bus is a self-hosted, localhost-only v1 service
|
|
4
|
+
(backend/coordination_bus.py in the alphahive repo). v1 "executes nothing
|
|
5
|
+
outward-facing" (action_flag is store+display only) -- every route is safe to
|
|
6
|
+
call. As of coordination-bus v1.1, the 4 write routes MAY be gated behind an
|
|
7
|
+
optional shared secret (BUS_WRITE_SECRET, header X-Bus-Secret); this client
|
|
8
|
+
mirrors that same env var so arming the bus and arming this MCP is one env
|
|
9
|
+
var set in both processes, not a bus-mcp-specific config surface. Unset (the
|
|
10
|
+
default) means the bus is unarmed -- this client sends no header, matching
|
|
11
|
+
the bus's own default-open behavior byte-for-byte.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:8100/api/bus"
|
|
18
|
+
DEFAULT_TIMEOUT_S = 10.0
|
|
19
|
+
DEFAULT_LEASE_S = 300
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_base_url() -> str:
|
|
23
|
+
"""Base URL for the coordination bus API, e.g. http://127.0.0.1:8100/api/bus.
|
|
24
|
+
Overridable via BUS_MCP_BASE_URL for a non-default host/port."""
|
|
25
|
+
return os.environ.get("BUS_MCP_BASE_URL", DEFAULT_BASE_URL).rstrip("/")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_timeout_s() -> float:
|
|
29
|
+
"""Per-request timeout in seconds. Overridable via BUS_MCP_TIMEOUT_S."""
|
|
30
|
+
raw = os.environ.get("BUS_MCP_TIMEOUT_S")
|
|
31
|
+
if not raw:
|
|
32
|
+
return DEFAULT_TIMEOUT_S
|
|
33
|
+
try:
|
|
34
|
+
return float(raw)
|
|
35
|
+
except ValueError:
|
|
36
|
+
return DEFAULT_TIMEOUT_S
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_live_smoke_enabled() -> bool:
|
|
40
|
+
"""True when BUS_MCP_LIVE=1 -- gates the real-network smoke test."""
|
|
41
|
+
return os.environ.get("BUS_MCP_LIVE") == "1"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_write_secret() -> str | None:
|
|
45
|
+
"""Reads BUS_WRITE_SECRET from the environment on every call (not cached),
|
|
46
|
+
mirroring the bus server's own per-request read -- so tests can
|
|
47
|
+
monkeypatch it and an operator can arm/disarm without a code change.
|
|
48
|
+
Empty string counts as unset. None means: send no X-Bus-Secret header,
|
|
49
|
+
matching the bus's own default-open behavior."""
|
|
50
|
+
return os.environ.get("BUS_WRITE_SECRET") or None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""bus_mcp.routes -- one function per coordination-bus route (backend/
|
|
2
|
+
coordination_bus.py), 1:1. Each function calls bus_mcp.client, which raises
|
|
3
|
+
BusUnreachable / BusApiError on failure; every function here catches both and
|
|
4
|
+
normalizes to `{"ok": False, "error": {...}}` so a tool caller never sees a
|
|
5
|
+
raw exception or stack trace -- only server.py's @mcp.tool wrappers call
|
|
6
|
+
these, and they return whatever these functions return, unmodified.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from . import client, config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _error_payload(exc: client.BusUnreachable | client.BusApiError) -> dict[str, Any]:
|
|
16
|
+
if isinstance(exc, client.BusUnreachable):
|
|
17
|
+
return {
|
|
18
|
+
"ok": False,
|
|
19
|
+
"error": {
|
|
20
|
+
"type": "bus_unreachable",
|
|
21
|
+
"message": str(exc),
|
|
22
|
+
"tool": exc.tool,
|
|
23
|
+
},
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
"ok": False,
|
|
27
|
+
"error": {
|
|
28
|
+
"type": "bus_api_error",
|
|
29
|
+
"message": str(exc),
|
|
30
|
+
"tool": exc.tool,
|
|
31
|
+
"status_code": exc.status_code,
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def post_message(topic: str, sender: str, body: str, action_flag: bool = False) -> dict[str, Any]:
|
|
37
|
+
"""Append one message to the bus blackboard (append-only). v1 stores
|
|
38
|
+
action_flag but performs no action -- it is display-only, seen by a human
|
|
39
|
+
watching the command-center panel."""
|
|
40
|
+
try:
|
|
41
|
+
result = client.post(
|
|
42
|
+
"post_message",
|
|
43
|
+
"/message",
|
|
44
|
+
json={"topic": topic, "sender": sender, "body": body, "action_flag": action_flag},
|
|
45
|
+
)
|
|
46
|
+
except (client.BusUnreachable, client.BusApiError) as exc:
|
|
47
|
+
return _error_payload(exc)
|
|
48
|
+
return {"ok": True, **result}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def read_messages(topic: str | None = None, limit: int = 50) -> dict[str, Any]:
|
|
52
|
+
"""Recent bus messages, newest first, optionally filtered by topic."""
|
|
53
|
+
params: dict[str, Any] = {"limit": limit}
|
|
54
|
+
if topic is not None:
|
|
55
|
+
params["topic"] = topic
|
|
56
|
+
try:
|
|
57
|
+
result = client.get("read_messages", "/messages", params=params)
|
|
58
|
+
except (client.BusUnreachable, client.BusApiError) as exc:
|
|
59
|
+
return _error_payload(exc)
|
|
60
|
+
return {"ok": True, **result}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def claim_lane(lane: str, owner: str, lease_s: int = config.DEFAULT_LEASE_S) -> dict[str, Any]:
|
|
64
|
+
"""Claim a coordination lane before starting work in it: claim-if-free,
|
|
65
|
+
steal-if-lease-expired, renew-if-you-already-own-it. A 409 (lane held
|
|
66
|
+
live by another owner) surfaces as a clean ok=False conflict, not a
|
|
67
|
+
crash -- check `error.type == "bus_api_error"` and `status_code == 409`."""
|
|
68
|
+
try:
|
|
69
|
+
result = client.post(
|
|
70
|
+
"claim_lane", f"/lanes/{lane}/claim", json={"owner": owner, "lease_s": lease_s}
|
|
71
|
+
)
|
|
72
|
+
except (client.BusUnreachable, client.BusApiError) as exc:
|
|
73
|
+
return _error_payload(exc)
|
|
74
|
+
return {"ok": True, **result}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def release_lane(lane: str, owner: str) -> dict[str, Any]:
|
|
78
|
+
"""Release a lane you hold. A 409 (held live by another owner) surfaces
|
|
79
|
+
as a clean ok=False conflict, not a crash."""
|
|
80
|
+
try:
|
|
81
|
+
result = client.post("release_lane", f"/lanes/{lane}/release", json={"owner": owner})
|
|
82
|
+
except (client.BusUnreachable, client.BusApiError) as exc:
|
|
83
|
+
return _error_payload(exc)
|
|
84
|
+
return {"ok": True, **result}
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def heartbeat_lane(lane: str, owner: str, lease_s: int = config.DEFAULT_LEASE_S) -> dict[str, Any]:
|
|
88
|
+
"""Renew the lease on a lane you hold live. A 409 (not held live by you)
|
|
89
|
+
surfaces as a clean ok=False conflict telling you to (re)claim instead."""
|
|
90
|
+
try:
|
|
91
|
+
result = client.post(
|
|
92
|
+
"heartbeat_lane", f"/lanes/{lane}/heartbeat", json={"owner": owner, "lease_s": lease_s}
|
|
93
|
+
)
|
|
94
|
+
except (client.BusUnreachable, client.BusApiError) as exc:
|
|
95
|
+
return _error_payload(exc)
|
|
96
|
+
return {"ok": True, **result}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_bus_status() -> dict[str, Any]:
|
|
100
|
+
"""Roll-up for the command-center panel: active lanes, orphaned/stale
|
|
101
|
+
claims, recent messages, pending display-only action flags."""
|
|
102
|
+
try:
|
|
103
|
+
result = client.get("get_bus_status", "/status")
|
|
104
|
+
except (client.BusUnreachable, client.BusApiError) as exc:
|
|
105
|
+
return _error_payload(exc)
|
|
106
|
+
return {"ok": True, **result}
|