headroom/headroom/mcp_registry/claude.py
Rod Boev 7550efb68f
fix(mcp): add explicit Serena reconciliation (#3222)
## Description

Headroom repeatedly warns about user-managed Serena drift but has no
scoped remediation command. Add a Claude-only read-only mcp reconcile
command with explicit --adopt consent, using the canonical Serena spec
and existing Claude registrar. Adoption validates every relevant ledger
and Claude config root before mutation, writes only the Serena entry,
and records ownership after the config write succeeds. Automatic wrap
migration and ordinary install remain unchanged. Closes #3054

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (feature that would cause existing behavior to
change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Add Claude-only `headroom mcp reconcile`, read-only by default, with
`--adopt` as its only mutation action.
- Reuse the shared `CLAUDE_SERENA_CONTEXT` and canonical Claude Serena
spec builder.
- Fail closed on malformed or unreadable ledger/config state before
adoption.
- Preserve automatic wrap recovery, user-managed warnings, ordinary `mcp
install --force`, unrelated Claude config, and corrupt-ledger tolerance
outside explicit adoption.
- Record Headroom ownership only after a successful registrar write.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_cli/test_mcp_reconcile.py
tests/test_cli/test_serena_reconcile.py
tests/test_mcp_registry/test_ledger.py`)
- [x] Linting passes (`uv run ruff check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed through the file-backed Claude registrar

### Test Output

```text
uv run pytest tests/test_cli/test_mcp_reconcile.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_mcp_registry/test_claude_registrar.py tests/test_mcp_registry/test_install.py -q
102 passed in 0.70s
uv run ruff check headroom/mcp_registry/ledger.py headroom/cli/wrap.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_cli/test_mcp_reconcile.py
All checks passed!
uv run ruff format --check headroom/mcp_registry/ledger.py headroom/cli/wrap.py tests/test_mcp_registry/test_ledger.py tests/test_cli/test_serena_reconcile.py tests/test_cli/test_mcp_reconcile.py
5 files already formatted
git diff --check
```

## Real Behavior Proof

- Environment: Windows, file-backed Claude configuration and isolated
MCP ledger.
- Exact command / steps: run the stale user-managed Serena fixture from
`tests/fixtures/headroom-issue-3054.json`; run read-only reconcile; run
`mcp reconcile --adopt`; rerun wrap and ordinary `mcp install --force`;
exercise malformed JSON, non-dict `mcpServers`, null ledger agents, and
unreadable-ledger adoption.
- Observed result: read-only reconciliation leaves config and ledger
bytes unchanged; adoption updates only Claude Serena and records
ownership after a successful write; automatic wrap remains lenient;
unsafe adoption inputs leave all files unchanged; ordinary install does
not adopt Serena.
- Not tested: live Claude CLI acceptance and Serena stdio handshake

## Runtime Rollout Safety

- Rollout-managed feature(s): None; explicit `mcp reconcile --adopt` is
the only mutation path.
- Minimum rollout channel: Stable; no staged rollout mechanism exists
for this command.
- Stable/default behavior changed: No, read-only reconcile is the
default and automatic wrap plus ordinary install remain unchanged.
- Kill switch / disable path: Do not invoke `--adopt` or revert the
release commit.
- Unsafe override required: No.
- Qualification impact: None.
- Rollback path: Revert the release commit.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Additional Notes

The changelog is generated by the release pipeline. This change is
limited to Claude Serena reconciliation and does not add a new
persistent acknowledgement state or a multi-provider adoption route.
2026-08-23 11:52:50 -07:00

390 lines
15 KiB
Python

"""Claude Code MCP registrar.
Claude Code 2.x stores user-scope MCP server configuration in
``~/.claude.json``, directly under the home directory. The ``claude`` CLI
(``claude mcp add/remove/list/get``) owns this file; setting
``CLAUDE_CONFIG_DIR`` relocates it to ``$CLAUDE_CONFIG_DIR/.claude.json``.
Older Claude Code releases (and the Claude Desktop app) read
``~/.claude/mcp.json`` instead. This registrar prefers the CLI for writes
when available, and reads the underlying JSON files directly for compare /
``get_server`` so it is robust to CLI output format changes.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
from pathlib import Path
from typing import Any
from headroom._subprocess import run
from .base import MCPRegistrar, RegisterResult, RegisterStatus, ServerSpec
logger = logging.getLogger(__name__)
class ClaudeConfigMutationError(ValueError):
"""Raised when a Claude config cannot be safely changed."""
class ClaudeRegistrar(MCPRegistrar):
"""Register MCP servers with Claude Code."""
name = "claude"
display_name = "Claude Code"
def __init__(
self,
*,
claude_cli: str | None | object = ...,
home_dir: Path | None = None,
config_dir: Path | None = None,
) -> None:
"""Allow overrides for testing.
``claude_cli`` defaults to :func:`shutil.which` lookup. Pass
``None`` to force the file-based fallback path. Pass an explicit
path to point at a specific binary. ``CLAUDE_CONFIG_DIR`` is honored
for real user sessions; ``home_dir`` isolates file-based reads and
writes from the caller's real home directory, unless ``config_dir``
is passed explicitly. It does not isolate CLI subprocess calls (see
``claude_cli``) — pass ``claude_cli=None`` alongside ``home_dir`` to
keep a test fully off the real ``claude`` binary.
"""
home = home_dir if home_dir is not None else Path.home()
modern_dir = _resolve_claude_config_dir(home, config_dir, honor_env=home_dir is None)
# Legacy config lives under the real ``.claude`` directory regardless
# of where the modern config resolved to (CLAUDE_CONFIG_DIR only
# relocates the modern file, per Claude Code's own behavior).
self._claude_dir = home / ".claude"
self._modern_dir = modern_dir
self._isolated_cli_env = home_dir is not None or config_dir is not None
self._modern_config = modern_dir / ".claude.json"
self._legacy_config = self._claude_dir / "mcp.json"
if claude_cli is ...:
self._claude_cli = shutil.which("claude")
else:
# ``...`` sentinel preserves "not set"; explicit None disables CLI.
self._claude_cli = claude_cli # type: ignore[assignment]
# ------------------------------------------------------------------
# MCPRegistrar interface
# ------------------------------------------------------------------
def detect(self) -> bool:
if self._claude_cli:
return True
return self._claude_dir.is_dir() or self._modern_config.exists()
def get_server(self, server_name: str) -> ServerSpec | None:
# Read from disk regardless of whether the CLI is present — the file
# format is stable and easier to compare than CLI output.
for config_path in (self._modern_config, self._legacy_config):
entry = self._read_server_entry(config_path, server_name)
if entry is not None:
return entry
return None
def validate_configs_for_mutation(self) -> None:
"""Validate every Claude config root before an explicit mutation."""
seen: set[Path] = set()
for config_path in (self._modern_config, self._legacy_config):
if config_path in seen or not config_path.exists():
continue
seen.add(config_path)
try:
raw = config_path.read_text(encoding="utf-8")
except OSError as exc:
raise ClaudeConfigMutationError(
f"could not read Claude config {config_path}: {exc}"
) from exc
try:
config = json.loads(raw)
except json.JSONDecodeError as exc:
raise ClaudeConfigMutationError(
f"Claude config {config_path} is not valid JSON; refusing to mutate"
) from exc
if not isinstance(config, dict):
raise ClaudeConfigMutationError(
f"Claude config {config_path} must contain a JSON object"
)
if "mcpServers" in config and not isinstance(config["mcpServers"], dict):
raise ClaudeConfigMutationError(
f"Claude config {config_path} has a non-object mcpServers; refusing to mutate"
)
def register_server(self, spec: ServerSpec, *, force: bool = False) -> RegisterResult:
existing = self.get_server(spec.name)
if existing is not None:
if _specs_equivalent(existing, spec):
return RegisterResult(RegisterStatus.ALREADY, "matches current configuration")
if not force:
return RegisterResult(
RegisterStatus.MISMATCH,
_diff_specs(existing, spec),
)
# force=True: remove first, then write fresh below.
self.unregister_server(spec.name)
if self._claude_cli:
return self._register_via_cli(spec)
return self._register_via_file(spec)
def unregister_server(self, server_name: str) -> bool:
removed = False
if self._claude_cli:
result = run(
[str(self._claude_cli), "mcp", "remove", server_name, "-s", "user"],
capture_output=True,
text=True,
env=self._claude_cli_env(),
)
if result.returncode == 0:
removed = True
else:
logger.debug("claude mcp remove failed: %s", result.stderr.strip())
# Always clean up both files too — the CLI only touches the modern
# config, so a legacy entry (or one it didn't know about) can remain.
for config_path in (self._modern_config, self._legacy_config):
removed = self._remove_from_file(config_path, server_name) or removed
return removed
# ------------------------------------------------------------------
# CLI-backed implementation
# ------------------------------------------------------------------
def _register_via_cli(self, spec: ServerSpec) -> RegisterResult:
cmd = [str(self._claude_cli), "mcp", "add", spec.name, "-s", "user"]
for k, v in spec.env.items():
cmd += ["-e", f"{k}={v}"]
cmd += ["--", spec.command, *spec.args]
result = run(
cmd,
capture_output=True,
text=True,
env=self._claude_cli_env(),
)
if result.returncode == 0:
return RegisterResult(RegisterStatus.REGISTERED, "via `claude mcp add` (scope: user)")
# CLI failed — try the file fallback rather than giving up.
logger.warning("claude mcp add failed: %s", result.stderr.strip())
file_result = self._register_via_file(spec)
if file_result.status == RegisterStatus.REGISTERED:
return RegisterResult(
RegisterStatus.REGISTERED,
f"via file fallback after CLI failed: {result.stderr.strip()}",
)
return RegisterResult(
RegisterStatus.FAILED,
f"CLI: {result.stderr.strip()}; file: {file_result.detail}",
)
# ------------------------------------------------------------------
# File-backed implementation (CLI absent / older clients)
# ------------------------------------------------------------------
def _register_via_file(self, spec: ServerSpec) -> RegisterResult:
# Prefer the modern config path. If only the legacy file exists,
# write to that to avoid surprising older clients.
target = self._modern_config
if not self._modern_config.exists() and self._legacy_config.exists():
target = self._legacy_config
try:
target.parent.mkdir(parents=True, exist_ok=True)
config = _read_json_for_write(target)
servers = config.get("mcpServers")
if not isinstance(servers, dict):
config["mcpServers"] = servers = {}
servers[spec.name] = _spec_to_entry(spec)
_write_json(target, config)
except _MalformedConfigError as exc:
# Refuse to overwrite: the file holds unrelated Claude state
# (projects, oauthAccount, history) that a blind rewrite would wipe.
return RegisterResult(
RegisterStatus.FAILED,
f"{target} exists but is not valid JSON ({exc}); refusing to overwrite. "
"Fix or remove the file, then re-run.",
)
except OSError as exc:
return RegisterResult(RegisterStatus.FAILED, f"could not write {target}: {exc}")
return RegisterResult(RegisterStatus.REGISTERED, f"wrote to {target}")
def _remove_from_file(self, path: Path, server_name: str) -> bool:
if not path.exists():
return False
try:
config = _read_json(path)
except OSError:
return False
servers = config.get("mcpServers")
if not isinstance(servers, dict) or server_name not in servers:
return False
del servers[server_name]
try:
_write_json(path, config)
except OSError:
return False
return True
def _read_server_entry(self, path: Path, server_name: str) -> ServerSpec | None:
if not path.exists():
return None
try:
config = _read_json(path)
except OSError:
return None
servers = config.get("mcpServers")
if not isinstance(servers, dict):
return None
entry = servers.get(server_name)
if not isinstance(entry, dict):
return None
return _entry_to_spec(server_name, entry)
# ----------------------------------------------------------------------
# Helpers
# ----------------------------------------------------------------------
def _claude_cli_env(self) -> dict[str, str] | None:
if not self._isolated_cli_env:
return None
env = os.environ.copy()
# Point the CLI at the directory holding the modern ``.claude.json``
# (CLAUDE_CONFIG_DIR relocates that file), not the legacy ``.claude`` dir.
env["CLAUDE_CONFIG_DIR"] = str(self._modern_dir)
return env
def _resolve_claude_config_dir(
home: Path,
config_dir: Path | None,
*,
honor_env: bool,
) -> Path:
"""Resolve the directory holding the *modern* ``.claude.json`` config.
Defaults to ``home`` itself, since Claude Code's modern config lives at
``~/.claude.json`` directly under the home directory. ``CLAUDE_CONFIG_DIR``,
when set, relocates it to ``$CLAUDE_CONFIG_DIR/.claude.json``.
"""
if config_dir is not None:
return config_dir
if honor_env:
env_dir = os.environ.get("CLAUDE_CONFIG_DIR", "").strip()
if env_dir:
return Path(env_dir).expanduser()
return home
def _read_json(path: Path) -> dict[str, Any]:
"""Read a JSON file, returning empty dict if absent or unparseable.
Safe for READ-ONLY callers. Do NOT use before a full-file rewrite: an
unparseable existing file returns ``{}`` here, and writing that back would
destroy the user's other config. Use :func:`_read_json_for_write` on the
write path instead.
"""
if not path.exists():
return {}
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
return {}
if not isinstance(data, dict):
return {}
return data
class _MalformedConfigError(Exception):
"""The target config file exists but is not a parseable JSON object.
Raised on the write path so we refuse to clobber a file we can't safely
merge into, rather than silently overwriting the user's state.
"""
def _read_json_for_write(path: Path) -> dict[str, Any]:
"""Read a JSON object for a subsequent full-file rewrite.
Returns ``{}`` only when the file is absent or empty (safe to start fresh).
If the file exists with content but does not parse as a JSON object, raise
:class:`_MalformedConfigError` so the caller aborts instead of overwriting
unrelated user config (e.g. ``~/.claude/.claude.json`` holds ``projects``,
``oauthAccount``, and session history alongside ``mcpServers``).
"""
if not path.exists():
return {}
raw = path.read_text(encoding="utf-8") # OSError propagates to the caller
if not raw.strip():
return {}
try:
data = json.loads(raw)
except json.JSONDecodeError as exc:
raise _MalformedConfigError(str(exc)) from exc
if not isinstance(data, dict):
raise _MalformedConfigError("top-level JSON is not an object")
return data
def _write_json(path: Path, data: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
f.write("\n")
def _spec_to_entry(spec: ServerSpec) -> dict[str, Any]:
entry: dict[str, Any] = {"command": spec.command}
if spec.args:
entry["args"] = list(spec.args)
if spec.env:
entry["env"] = dict(spec.env)
return entry
def _entry_to_spec(name: str, entry: dict[str, Any]) -> ServerSpec:
args_value = entry.get("args", [])
if isinstance(args_value, list):
args = tuple(str(x) for x in args_value)
else:
args = ()
env_value = entry.get("env", {})
env: dict[str, str] = {}
if isinstance(env_value, dict):
env = {str(k): str(v) for k, v in env_value.items()}
return ServerSpec(
name=name,
command=str(entry.get("command", "")),
args=args,
env=env,
)
def _specs_equivalent(a: ServerSpec, b: ServerSpec) -> bool:
"""Two specs match when every field is equal."""
return (
a.name == b.name
and a.command == b.command
and tuple(a.args) == tuple(b.args)
and dict(a.env) == dict(b.env)
)
def _diff_specs(existing: ServerSpec, requested: ServerSpec) -> str:
"""Render the difference between two specs for human consumption."""
parts: list[str] = []
if existing.command != requested.command:
parts.append(f"command {existing.command!r} -> {requested.command!r}")
if tuple(existing.args) != tuple(requested.args):
parts.append(f"args {list(existing.args)} -> {list(requested.args)}")
if dict(existing.env) != dict(requested.env):
parts.append(f"env {dict(existing.env)} -> {dict(requested.env)}")
if not parts:
return "spec differs in unidentified field(s)"
return "; ".join(parts)