fix(supervise): provision MCP via claude mcp add, not raw settings.json
test / unit (pull_request) Successful in 17s
test / integration (pull_request) Successful in 1m34s

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 <agent> \
    claude mcp add --scope user --transport http supervise <url>

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-25 06:40:47 -04:00
parent 8e6ed278d0
commit 0e2fc97aa8
2 changed files with 53 additions and 71 deletions
@@ -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__":