From 0e2fc97aa84a758471965483e9b01d5e4db86b5b Mon Sep 17 00:00:00 2001 From: didericis Date: Mon, 25 May 2026 06:40:47 -0400 Subject: [PATCH] fix(supervise): provision MCP via `claude mcp add`, not raw settings.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous provisioner wrote ~/.claude/settings.json with an mcpServers entry — but claude-code doesn't read its mcpServers from that path. Inside a bottle, /mcp showed "No MCP servers configured" even though the sidecar was running. Switch to the official `claude mcp add` command run via docker exec: docker exec -u node \ claude mcp add --scope user --transport http supervise claude-code owns its config file format (~/.claude.json shape, key names, scope semantics) and has changed it between versions. The official command writes to the right place in the right shape for whatever version is installed. Failure is logged but not fatal — the bottle still works; you just have to register the server manually with the command surfaced in the warning. Worst case is a bad agent claude-code version, not a bad bottle. To fix an already-running bottle without restarting, the user can run the same `docker exec` command directly. Co-Authored-By: Claude Opus 4.7 --- .../backend/docker/provision/supervise.py | 86 ++++++++----------- tests/unit/test_provision_supervise.py | 38 ++++---- 2 files changed, 53 insertions(+), 71 deletions(-) diff --git a/claude_bottle/backend/docker/provision/supervise.py b/claude_bottle/backend/docker/provision/supervise.py index 7618b12..5e121c6 100644 --- a/claude_bottle/backend/docker/provision/supervise.py +++ b/claude_bottle/backend/docker/provision/supervise.py @@ -1,10 +1,15 @@ """Supervise sidecar provisioning inside a running Docker bottle (PRD 0013). -Writes ~/.claude/settings.json with an `mcpServers.supervise` entry -pointing at the per-bottle supervise sidecar so the in-bottle Claude -Code discovers the three stuck-recovery MCP tools (cred-proxy-block, -pipelock-block, capability-block) at startup. +Registers the per-bottle supervise sidecar as an HTTP MCP server in +the agent's claude-code config so the agent discovers the three +stuck-recovery MCP tools (cred-proxy-block, pipelock-block, +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 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 -import json -import os import subprocess -from ....log import info +from ....log import info, warn from ....supervise import SUPERVISE_HOSTNAME, SUPERVISE_PORT -from .. import util as docker_mod from ..bottle_plan import DockerBottlePlan -_AGENT_HOME_DEFAULT = "/home/node" -_SETTINGS_REL_PATH = ".claude/settings.json" _SUPERVISE_MCP_NAME = "supervise" -def render_settings() -> str: - """The settings.json content the agent reads on startup. Stable - 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 supervise_mcp_url() -> str: + return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/" def provision_supervise(plan: DockerBottlePlan, target: str) -> None: - """Drop ~/.claude/settings.json into the running agent container - when bottle.supervise is True. No-op otherwise.""" + """Run `claude mcp add` inside the agent container to register + 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: return - - container_home = os.environ.get( - "CLAUDE_BOTTLE_CONTAINER_HOME", _AGENT_HOME_DEFAULT, - ) - settings_in_container = f"{container_home}/{_SETTINGS_REL_PATH}" - settings_dir_in_container = settings_in_container.rsplit("/", 1)[0] - - host_path = plan.stage_dir / "agent_claude_settings.json" - host_path.write_text(render_settings()) - host_path.chmod(0o644) - - info(f"writing {settings_in_container} (supervise MCP server entry)") - # The Dockerfile creates ~/.claude.json at the top of HOME but - # not the ~/.claude/ subdir, so make sure it exists before cp. - docker_mod.docker_exec_root(target, ["mkdir", "-p", settings_dir_in_container]) - subprocess.run( - ["docker", "cp", str(host_path), f"{target}:{settings_in_container}"], - 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]) + url = supervise_mcp_url() + argv = [ + "docker", "exec", "-u", "node", target, + "claude", "mcp", "add", + "--scope", "user", + "--transport", "http", + _SUPERVISE_MCP_NAME, + url, + ] + info(f"registering supervise MCP server in agent claude config → {url}") + r = subprocess.run(argv, capture_output=True, text=True, check=False) + if r.returncode != 0: + warn( + f"`claude mcp add supervise` failed (exit {r.returncode}): " + f"{(r.stderr or r.stdout or '').strip()}. Inside the bottle, " + f"register manually with: " + f"claude mcp add --scope user --transport http supervise {url}" + ) -__all__ = ["provision_supervise", "render_settings"] +__all__ = ["provision_supervise", "supervise_mcp_url"] diff --git a/tests/unit/test_provision_supervise.py b/tests/unit/test_provision_supervise.py index f5109c7..64f1b5e 100644 --- a/tests/unit/test_provision_supervise.py +++ b/tests/unit/test_provision_supervise.py @@ -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 existing supervise integration test once the agent container is -brought up; here we cover the pure render path so a settings.json -shape regression would surface in unit-level CI.""" +The real provisioning runs `claude mcp add` inside the agent +container — exercised by the existing supervise integration test +chain once the agent container is brought up. Here we just cover +the URL computation so a regression in SUPERVISE_HOSTNAME / PORT +plumbing surfaces in unit CI.""" -import json 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 -class TestRenderSettings(unittest.TestCase): - def test_output_is_valid_json(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"]) +class TestSuperviseMcpUrl(unittest.TestCase): + def test_url_matches_sidecar_constants(self): self.assertEqual( f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/", - sv["url"], + supervise_mcp_url(), ) - def test_only_supervise_server_is_emitted(self): - cfg = json.loads(render_settings()) - # Keep the provisioner narrowly scoped — it owns just the - # supervise entry, no other tools/servers. - self.assertEqual({"supervise"}, set(cfg["mcpServers"].keys())) + def test_url_is_http_not_https(self): + # The agent dials the sidecar on the internal docker network; + # no TLS termination, no CA trust juggling. If this ever + # needs HTTPS, the sidecar's listener side has to change too. + self.assertTrue(supervise_mcp_url().startswith("http://")) if __name__ == "__main__":