fix(supervise): provision MCP via claude mcp add #25

Merged
didericis merged 9 commits from supervise-mcp-add-via-cli into main 2026-05-25 08:31:17 -04:00
2 changed files with 53 additions and 71 deletions
Showing only changes of commit 0e2fc97aa8 - Show all commits
@@ -1,10 +1,15 @@
"""Supervise sidecar provisioning inside a running Docker bottle """Supervise sidecar provisioning inside a running Docker bottle
(PRD 0013). (PRD 0013).
Writes ~/.claude/settings.json with an `mcpServers.supervise` entry Registers the per-bottle supervise sidecar as an HTTP MCP server in
pointing at the per-bottle supervise sidecar so the in-bottle Claude the agent's claude-code config so the agent discovers the three
Code discovers the three stuck-recovery MCP tools (cred-proxy-block, stuck-recovery MCP tools (cred-proxy-block, pipelock-block,
pipelock-block, capability-block) at startup. capability-block) at startup.
Uses `claude mcp add` rather than writing JSON directly. claude-code
owns the on-disk config format (`~/.claude.json` `mcpServers` shape,
field names, scope semantics) and changes it between versions; the
official command handles whatever the installed version expects.
No-op when bottle.supervise is False — bottles that haven't opted No-op when bottle.supervise is False — bottles that haven't opted
into the supervise sidecar shouldn't get an MCP entry pointing at a into the supervise sidecar shouldn't get an MCP entry pointing at a
@@ -13,63 +18,48 @@ sidecar that isn't running.
from __future__ import annotations from __future__ import annotations
import json
import os
import subprocess import subprocess
from ....log import info from ....log import info, warn
from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
from .. import util as docker_mod
from ..bottle_plan import DockerBottlePlan from ..bottle_plan import DockerBottlePlan
_AGENT_HOME_DEFAULT = "/home/node"
_SETTINGS_REL_PATH = ".claude/settings.json"
_SUPERVISE_MCP_NAME = "supervise" _SUPERVISE_MCP_NAME = "supervise"
def render_settings() -> str: def supervise_mcp_url() -> str:
"""The settings.json content the agent reads on startup. Stable return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
shape — only the URL is parameterized in case CLAUDE_BOTTLE_*
env overrides change the supervise hostname/port someday."""
cfg = {
"mcpServers": {
_SUPERVISE_MCP_NAME: {
"type": "http",
"url": f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/",
},
},
}
return json.dumps(cfg, indent=2) + "\n"
def provision_supervise(plan: DockerBottlePlan, target: str) -> None: def provision_supervise(plan: DockerBottlePlan, target: str) -> None:
"""Drop ~/.claude/settings.json into the running agent container """Run `claude mcp add` inside the agent container to register
when bottle.supervise is True. No-op otherwise.""" the supervise sidecar in claude-code's user config. No-op when
bottle.supervise is False.
Failure is logged but not fatal: the bottle still works (you
just can't call supervise tools from the agent until the entry
is added manually). The operator sees the warning at launch."""
if plan.supervise_plan is None: if plan.supervise_plan is None:
return return
url = supervise_mcp_url()
container_home = os.environ.get( argv = [
"CLAUDE_BOTTLE_CONTAINER_HOME", _AGENT_HOME_DEFAULT, "docker", "exec", "-u", "node", target,
) "claude", "mcp", "add",
settings_in_container = f"{container_home}/{_SETTINGS_REL_PATH}" "--scope", "user",
settings_dir_in_container = settings_in_container.rsplit("/", 1)[0] "--transport", "http",
_SUPERVISE_MCP_NAME,
host_path = plan.stage_dir / "agent_claude_settings.json" url,
host_path.write_text(render_settings()) ]
host_path.chmod(0o644) info(f"registering supervise MCP server in agent claude config → {url}")
r = subprocess.run(argv, capture_output=True, text=True, check=False)
info(f"writing {settings_in_container} (supervise MCP server entry)") if r.returncode != 0:
# The Dockerfile creates ~/.claude.json at the top of HOME but warn(
# not the ~/.claude/ subdir, so make sure it exists before cp. f"`claude mcp add supervise` failed (exit {r.returncode}): "
docker_mod.docker_exec_root(target, ["mkdir", "-p", settings_dir_in_container]) f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, "
subprocess.run( f"register manually with: "
["docker", "cp", str(host_path), f"{target}:{settings_in_container}"], f"claude mcp add --scope user --transport http supervise {url}"
stdout=subprocess.DEVNULL, )
check=True,
)
docker_mod.docker_exec_root(target, ["chown", "-R", "node:node", settings_dir_in_container])
docker_mod.docker_exec_root(target, ["chmod", "644", settings_in_container])
__all__ = ["provision_supervise", "render_settings"] __all__ = ["provision_supervise", "supervise_mcp_url"]
+15 -23
View File
@@ -1,37 +1,29 @@
"""Unit: supervise MCP settings renderer (PRD 0013 follow-up). """Unit: supervise MCP provisioning (PRD 0013 follow-up).
The docker cp / chown side of provision_supervise is exercised by The real provisioning runs `claude mcp add` inside the agent
the existing supervise integration test once the agent container is container — exercised by the existing supervise integration test
brought up; here we cover the pure render path so a settings.json chain once the agent container is brought up. Here we just cover
shape regression would surface in unit-level CI.""" the URL computation so a regression in SUPERVISE_HOSTNAME / PORT
plumbing surfaces in unit CI."""
import json
import unittest import unittest
from claude_bottle.backend.docker.provision.supervise import render_settings from claude_bottle.backend.docker.provision.supervise import supervise_mcp_url
from claude_bottle.supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT from claude_bottle.supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT
class TestRenderSettings(unittest.TestCase): class TestSuperviseMcpUrl(unittest.TestCase):
def test_output_is_valid_json(self): def test_url_matches_sidecar_constants(self):
json.loads(render_settings())
def test_has_mcp_servers_supervise_http_entry(self):
cfg = json.loads(render_settings())
servers = cfg["mcpServers"]
self.assertIn("supervise", servers)
sv = servers["supervise"]
self.assertEqual("http", sv["type"])
self.assertEqual( self.assertEqual(
f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/", f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/",
sv["url"], supervise_mcp_url(),
) )
def test_only_supervise_server_is_emitted(self): def test_url_is_http_not_https(self):
cfg = json.loads(render_settings()) # The agent dials the sidecar on the internal docker network;
# Keep the provisioner narrowly scoped — it owns just the # no TLS termination, no CA trust juggling. If this ever
# supervise entry, no other tools/servers. # needs HTTPS, the sidecar's listener side has to change too.
self.assertEqual({"supervise"}, set(cfg["mcpServers"].keys())) self.assertTrue(supervise_mcp_url().startswith("http://"))
if __name__ == "__main__": if __name__ == "__main__":