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 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
@@ -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}