From 4bd3ddfaa5c5655540494b96e4f5d47724460c7d Mon Sep 17 00:00:00 2001 From: Rudimar Ronsoni Date: Mon, 6 Jul 2026 15:22:15 +0200 Subject: [PATCH] fix(opencode): use local MCP config (#1383) ## Description Fixes OpenCode Headroom MCP configuration across wrap, MCP install/status/uninstall, and persistent install docs/CLI. OpenCode was being configured to use a remote HTTP MCP endpoint at `/mcp`, but the Headroom proxy does not expose MCP there. The correct OpenCode configuration is a local stdio MCP server that runs `headroom mcp serve`. Closes #1380 ## Type of Change - [x] Bug fix - [ ] New feature - [ ] Breaking change - [x] Documentation update - [x] Tests ## Changes Made - Changed OpenCode MCP registration to emit `type: "local"` with `command: ["headroom", "mcp", "serve"]`. - Changed OpenCode MCP environment serialization from `env` to OpenCode's `environment` key, while still reading legacy `env` entries. - Removed generated remote `/mcp` entries from OpenCode wrap/runtime config. - Made `wrap opencode --no-mcp` skip persistent `mcp.headroom` injection. - Kept provider-only OpenCode config injection from writing MCP; MCP persistence is owned by the registrar path. - Made `headroom mcp status` and `headroom mcp uninstall` use the registrar lifecycle so OpenCode is covered. - Added `opencode` to persistent install `--target` choices. - Clarified OpenCode persistent install docs to use `--scope provider` for direct `opencode.json` edits. - Added regression coverage for registrar serialization, wrap behavior, runtime config, provider-scope install, MCP CLI lifecycle, and install target parsing. ## Testing - [x] `rtk .venv/bin/python -m pytest tests/test_mcp_registry tests/test_cli/test_mcp.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_install -q` - [x] Result after absorbing #1381 overlap: `263 passed, 1 skipped` - [x] Targeted Ruff check passed for the changed Python/test files. - [x] Targeted Ruff format check passed for the changed Python/test files. - [x] Isolated HOME smoke tests with real `opencode mcp list --pure`. ## Real Behavior Proof - `headroom mcp install --agent opencode --proxy-url http://127.0.0.1:9000 --force` against an isolated HOME wrote a valid local OpenCode MCP entry with `environment.HEADROOM_PROXY_URL`. - `opencode mcp list --pure` against that isolated HOME connected to `headroom mcp serve`. - `headroom wrap opencode --prepare-only --no-rtk --no-serena --port 9001` wrote local MCP plus provider config. - `headroom wrap opencode --prepare-only --no-rtk --no-serena --no-mcp --port 9002` wrote provider config without `mcp.headroom`. - Generated runtime `OPENCODE_CONFIG_CONTENT` was accepted by `opencode mcp list --pure`; `include_mcp=False` reported no MCP servers. - `headroom mcp status` detected the isolated OpenCode config and read the custom proxy URL. - `headroom mcp uninstall` removed `mcp.headroom` from the isolated OpenCode config while leaving provider config intact. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review --- CHANGELOG.md | 1 + docs/content/docs/opencode.mdx | 52 ++++---- headroom/cli/mcp.py | 113 +++++----------- headroom/providers/opencode/config.py | 22 ---- headroom/proxy/handlers/openai.py | 42 +++--- tests/test_cli/test_mcp.py | 163 ++++++++++-------------- tests/test_cli/test_wrap_opencode.py | 20 +++ tests/test_openai_codex_ws_lifecycle.py | 7 +- tests/test_output_shaper.py | 5 +- 9 files changed, 171 insertions(+), 254 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9438e55f5..82061ee47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **rtk:** stop `rtk` hook registration from spuriously timing out during `headroom wrap`. Output is captured to a temp file instead of pipes, and `stdin` is closed, so a background process forked by `rtk init` can no longer hold the pipe open and block `subprocess.run` past its 10s timeout after the hooks were already registered. * **ccr:** stop re-compressing `headroom_retrieve` output, which created an infinite retrieval loop, and stop emitting retrieval markers when the `headroom_retrieve` tool is not injected, which silently dropped data ([#1077](https://github.com/chopratejas/headroom/issues/1077), [#1006](https://github.com/chopratejas/headroom/issues/1006)). * **dashboard:** include RTK stats in the Historical tab; `/stats-history` now attaches live RTK/CLI-filtering stats the same way the Session tab does, so they survive a proxy restart ([#1177](https://github.com/chopratejas/headroom/issues/1177)). +* **opencode:** write Headroom MCP config as a local stdio server instead of a remote `/mcp` URL, keep provider-only installs from adding MCP config, and allow `install apply --target opencode` ([#1380](https://github.com/headroomlabs-ai/headroom/issues/1380)). * **proxy:** stop discarding a finished compression on very large requests. After the transform pipeline completed, a telemetry-only waste-signal re-parse of the *original* messages ran on the critical path; on huge Claude Code transcripts (~400k tokens) that parse could exceed the Anthropic compression timeout, so the proxy failed open and forwarded the uncompressed request despite "Pipeline complete" logging real savings (`tokens_saved: 0`, `transforms_applied: []`, ~31s latency). Waste-signal detection is now skipped above `MAX_WASTE_SIGNAL_DETECTION_TOKENS` (100k) so the compression result stays on the critical path ([#296](https://github.com/chopratejas/headroom/issues/296)). * **codex:** retag existing Codex threads when `headroom init` injects the `headroom` provider, so Codex Desktop history stays visible. Codex filters its sidebar/search by the active `model_provider`; the init path set `model_provider = "headroom"` without retagging, so existing native `openai` threads disappeared from the menu (data was never deleted, only hidden). `_ensure_codex_provider` now reconciles thread tags openai→headroom, matching what the install and `wrap` paths already do; `headroom unwrap codex` handles the revert direction ([#961](https://github.com/chopratejas/headroom/issues/961)). * **install:** stop duplicating the container ENTRYPOINT in the `persistent-docker` runtime command. The published image already runs `headroom proxy` as its ENTRYPOINT, but `build_runtime_command` re-added `headroom proxy` after the image name, so the container ran `headroom proxy headroom proxy --host 0.0.0.0 …` and Click aborted with "Got unexpected extra arguments (headroom proxy)" — the deployment never became ready and rollback left nothing running. The runtime command now appends only the proxy flags ([#833](https://github.com/chopratejas/headroom/issues/833)). diff --git a/docs/content/docs/opencode.mdx b/docs/content/docs/opencode.mdx index 8617a03cf..f25c64d94 100644 --- a/docs/content/docs/opencode.mdx +++ b/docs/content/docs/opencode.mdx @@ -24,31 +24,31 @@ headroom unwrap opencode | Step | What happens | |---|---| | Proxy | Starts the Headroom proxy unless `--no-proxy` is set | -| Provider config | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json` and `OPENCODE_CONFIG_CONTENT`, pointing at `http://127.0.0.1:/v1` | -| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` so OpenCode reads provider, model, plugin, and local MCP config at launch | +| Provider injection | Writes a `headroom` provider using `@ai-sdk/openai-compatible` into `opencode.json`, pointing at `http://127.0.0.1:/v1` | +| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` with provider, plugin, and optional local MCP config so OpenCode picks up Headroom at launch | | Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so OpenCode `/connect` providers keep their own routing | -| Context tool | Injects RTK or `lean-ctx` instructions into `~/.config/opencode/AGENTS.md` and project `AGENTS.md` | -| MCP setup | Registers Headroom MCP tools through a local stdio server: `headroom_compress`, `headroom_retrieve`, and `headroom_stats` | -| Serena MCP | Optionally registers Serena code graph tools. Use `--no-serena` to skip it | -| Backup | Snapshots `opencode.json` to `opencode.json.headroom-backup` before changing it | -| Launch | Starts the `opencode` binary with the generated config | +| Context tool | Injects RTK (or `lean-ctx`) instructions into `~/.config/opencode/AGENTS.md` and project `AGENTS.md` | +| MCP setup | Registers the Headroom MCP server (`headroom_compress`, `headroom_retrieve`, `headroom_stats`) | +| Serena MCP | Optionally registers Serena code graph tools (`--no-serena` to skip) | +| Backup | Snapshots `opencode.json` to `opencode.json.headroom-backup` before making any changes | +| Launch | Starts the `opencode` binary through the proxy | ## Options ```bash headroom wrap opencode \ - --port 8787 \ # Proxy port (default: 8787) - --no-rtk \ # Skip RTK context tool injection - --no-mcp \ # Skip Headroom MCP registration - --no-serena \ # Skip Serena code graph MCP - --code-graph \ # Include code graph in context - --no-proxy \ # Use an existing proxy instead of starting one - --learn \ # Enable memory and live learning - --memory \ # Enable persistent memory - --backend anthropic \ # Backend: anthropic, anyllm, litellm- - --anyllm-provider ... \ # AnyLLM provider selection - --region ... \ # Provider region - -- # Arguments passed to the opencode binary + --port 8787 \ + --no-rtk \ + --no-mcp \ + --no-serena \ + --code-graph \ + --no-proxy \ + --learn \ + --memory \ + --backend anthropic \ + --anyllm-provider ... \ + --region ... \ + -- ``` ## Provider Model Mapping @@ -69,8 +69,8 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json` | Variable | Description | |---|---| -| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, model, plugin, and optional local MCP config injected by `wrap` | -| `HEADROOM_PROXY_URL` | Proxy URL passed to the native `headroom-opencode` plugin. Defaults to `http://127.0.0.1:8787` inside the plugin | +| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, plugin, and optional local MCP config injected by `wrap` | +| `HEADROOM_PROXY_URL` | Proxy URL passed to Headroom MCP when a non-default port is used, and to the native plugin when configured | | `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK | ## Failure Learning @@ -85,7 +85,7 @@ See [Failure Learning](/docs/failure-learning) for details on the learn system. ## Persistent Installs -`headroom install` supports OpenCode as a target for persistent provider wiring: +`headroom install` supports OpenCode as a target for persistent provider wiring. Use provider scope when you want Headroom to edit `opencode.json` directly: ```bash headroom install apply --preset persistent-service --scope provider --providers manual --target opencode @@ -93,7 +93,7 @@ headroom install apply --preset persistent-service --scope provider --providers This writes the Headroom provider into `~/.config/opencode/opencode.json` and keeps the proxy running on port 8787. -Provider-only installs do not write Headroom MCP config. MCP tools are added by `headroom wrap opencode` unless `--no-mcp` is set. +The default user scope only writes shell environment configuration. For OpenCode, direct provider config requires `--scope provider`. ## Native OpenCode Plugin @@ -111,7 +111,7 @@ export default async function plugin(input) { } ``` -Use this plugin when OpenCode should intercept provider traffic in-process. Use `headroom wrap opencode` when you want the CLI to manage the proxy, config injection, local MCP registration, backups, and unwrap behavior. +Use this plugin when OpenCode should intercept provider traffic in-process. Use `headroom wrap opencode` when you want the CLI to manage the proxy, config injection, MCP registration, backups, and unwrap behavior. ## Programmatic Config Helpers @@ -137,8 +137,8 @@ const retrieve = createHeadroomRetrieveTool({ ## How It Works Under The Hood 1. **Config injection**. The wrapper writes a `provider.headroom` block into `opencode.json`. The provider uses `@ai-sdk/openai-compatible`, which OpenCode supports natively. Model mappings route requests through `http://127.0.0.1:/v1`. -2. **Runtime config**. `OPENCODE_CONFIG_CONTENT` is set as an env var containing the full provider, model, plugin, and local MCP JSON. OpenCode reads it at startup and merges it with on-disk config. -3. **MCP tools**. Headroom registers `headroom_compress`, `headroom_retrieve`, and `headroom_stats` through a local stdio MCP server unless `--no-mcp` is set. +2. **Runtime config**. `OPENCODE_CONFIG_CONTENT` is set as an env var containing provider, plugin, and optional local MCP JSON. OpenCode reads it at startup and merges it with on-disk config. +3. **MCP tools**. Headroom registers `headroom_compress`, `headroom_retrieve`, and `headroom_stats` through `headroom mcp serve` unless `--no-mcp` is set. 4. **Native plugin path**. `HeadroomPlugin` installs Headroom transport interception and uses `HEADROOM_PROXY_URL` or `http://127.0.0.1:8787` to reach the proxy. 5. **Unwrap**. `headroom unwrap opencode` restores `opencode.json` from the pre-wrap backup when present, strips Headroom marker blocks when no backup exists, and unregisters Headroom MCP servers. diff --git a/headroom/cli/mcp.py b/headroom/cli/mcp.py index b5aede3a5..bcef72e51 100644 --- a/headroom/cli/mcp.py +++ b/headroom/cli/mcp.py @@ -6,15 +6,11 @@ needing API key access. """ import json -import shutil -import subprocess from pathlib import Path from typing import Any import click -from headroom._subprocess import run - from .main import main # Default paths @@ -163,70 +159,31 @@ def mcp_install(proxy_url: str, agents: tuple[str, ...], force: bool) -> None: @mcp.command("uninstall") def mcp_uninstall() -> None: - """Remove Headroom MCP server from Claude Code config. + """Remove Headroom MCP server from detected agent configs. \b - Removes headroom from both the claude CLI registry (Claude Code CLI >=2.x) - and ~/.claude/mcp.json if present. Other MCP servers are preserved. + Removes headroom from every agent registrar known to Headroom. Other MCP + servers are preserved. """ + from headroom.mcp_registry import get_all_registrars + removed = False - # Remove from claude CLI registry (Claude Code CLI >=2.x) - claude_cli = shutil.which("claude") - if claude_cli: - check = subprocess.run( - [claude_cli, "mcp", "get", "headroom"], - capture_output=True, - ) - if check.returncode == 0: - rm = run( - [claude_cli, "mcp", "remove", "headroom", "-s", "user"], - capture_output=True, - text=True, - ) - if rm.returncode == 0: - click.echo("✓ Headroom MCP server removed (via claude mcp remove)") - removed = True - else: - click.echo( - f"Warning: 'claude mcp remove' failed ({rm.stderr.strip()}).", - err=True, - ) - - # Also remove codebase-memory-mcp if registered (installed by --code-graph) - if claude_cli: - cbm_check = subprocess.run( - [claude_cli, "mcp", "get", "codebase-memory-mcp"], - capture_output=True, - ) - if cbm_check.returncode == 0: - cbm_rm = run( - [claude_cli, "mcp", "remove", "codebase-memory-mcp", "-s", "user"], - capture_output=True, - text=True, - ) - if cbm_rm.returncode == 0: - click.echo("✓ codebase-memory-mcp MCP server removed") - removed = True - - # Also remove from mcp.json fallback config if present - if MCP_CONFIG_PATH.exists(): - config = load_mcp_config() - changed = False + for registrar in get_all_registrars(): + if not registrar.detect(): + continue + removed_names: list[str] = [] for server_name in ("headroom", "codebase-memory-mcp"): - if server_name in config.get("mcpServers", {}): - del config["mcpServers"][server_name] - changed = True - if changed: - save_mcp_config(config) - click.echo(f"✓ MCP servers removed from {MCP_CONFIG_PATH}") + if registrar.unregister_server(server_name): + removed_names.append(server_name) + if removed_names: + click.echo( + f"✓ {registrar.display_name}: removed {', '.join(removed_names)} MCP server(s)" + ) removed = True if not removed: - if MCP_CONFIG_PATH.exists(): - click.echo("Headroom MCP is not configured. Nothing to uninstall.") - else: - click.echo("No MCP config found. Nothing to uninstall.") + click.echo("Headroom MCP is not configured. Nothing to uninstall.") @mcp.command("status") @@ -249,33 +206,31 @@ def mcp_status() -> None: click.echo("MCP SDK: ✗ Not installed") click.echo(" pip install 'headroom-ai[mcp]'") - # Check config - if MCP_CONFIG_PATH.exists(): - config = load_mcp_config() - if "headroom" in config.get("mcpServers", {}): - server_config = config["mcpServers"]["headroom"] - click.echo("Claude Config: ✓ Configured") - click.echo(f" {MCP_CONFIG_PATH}") + from headroom.mcp_registry import get_all_registrars - # Show proxy URL - env = server_config.get("env", {}) - proxy_url = env.get("HEADROOM_PROXY_URL", DEFAULT_PROXY_URL) - click.echo(f"Proxy URL: {proxy_url}") - else: - click.echo("Claude Config: ✗ Not configured") - click.echo(" Run: headroom mcp install") - else: - click.echo("Claude Config: ✗ No config file") + proxy_url = DEFAULT_PROXY_URL + any_configured = False + click.echo("Agent Config:") + for registrar in get_all_registrars(): + if not registrar.detect(): + click.echo(f" {registrar.display_name}: ✗ Not detected") + continue + spec = registrar.get_server("headroom") + if spec is None: + click.echo(f" {registrar.display_name}: ✗ Not configured") + continue + any_configured = True + proxy_url = spec.env.get("HEADROOM_PROXY_URL", proxy_url) + click.echo(f" {registrar.display_name}: ✓ Configured") + + if not any_configured: click.echo(" Run: headroom mcp install") + click.echo(f"Proxy URL: {proxy_url}") # Check proxy connectivity try: import httpx - config = load_mcp_config() - env = config.get("mcpServers", {}).get("headroom", {}).get("env", {}) - proxy_url = env.get("HEADROOM_PROXY_URL", DEFAULT_PROXY_URL) - try: response = httpx.get(f"{proxy_url}/health", timeout=2.0) if response.status_code == 200: diff --git a/headroom/providers/opencode/config.py b/headroom/providers/opencode/config.py index 519ad5941..d0f321ef1 100644 --- a/headroom/providers/opencode/config.py +++ b/headroom/providers/opencode/config.py @@ -13,7 +13,6 @@ import click from headroom import fsutil from headroom.install.paths import opencode_config_path -from headroom.mcp_registry.install import DEFAULT_PROXY_URL # Headroom-managed JSON marker comments for idempotent block injection. _PROVIDER_MARKER_START = "// --- Headroom proxy provider ---" @@ -130,27 +129,6 @@ def _render_provider_block(port: int) -> str: return "\n".join(lines) -def _render_mcp_block(port: int) -> str: - """Render a Headroom MCP block as a JSON comment-wrapped snippet.""" - proxy_url = f"http://127.0.0.1:{port}" - mcp_entry: dict[str, Any] = { - "type": "local", - "command": ["headroom", "mcp", "serve"], - "enabled": True, - } - if proxy_url != DEFAULT_PROXY_URL: - mcp_entry["environment"] = {"HEADROOM_PROXY_URL": proxy_url} - mcp = { - "headroom": mcp_entry, - } - lines = [ - _MCP_MARKER_START, - f'"mcp": {json.dumps(mcp, indent=2)},', - _MCP_MARKER_END, - ] - return "\n".join(lines) - - def _parse_json_loose(text: str) -> dict[str, Any]: """Parse JSON text, stripping line comments (// ...) when needed. diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index ee39c07e7..78842672e 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -3689,20 +3689,20 @@ class OpenAIHandlerMixin: "http_upstream_request", request_id=request_id, transport="http", - direction="headroom_to_upstream", - method="POST", - url=url, - headers=headers, - body=body, - metadata={ - "path": request.url.path, - "stream": stream, - "auth_mode": auth_mode.value, - "is_chatgpt_auth": is_chatgpt_auth, - "tokens_saved": tokens_saved, - "transforms_applied": transforms_applied, - }, - ) + direction="headroom_to_upstream", + method="POST", + url=url, + headers=headers, + body=body, + metadata={ + "path": request.url.path, + "stream": stream, + "auth_mode": auth_mode.value, + "is_chatgpt_auth": is_chatgpt_auth, + "tokens_saved": tokens_saved, + "transforms_applied": transforms_applied, + }, + ) # Waste-signal detection for the Responses path (#820). The transform # pipeline never runs here (compression goes through CompressionUnits), @@ -5404,14 +5404,14 @@ class OpenAIHandlerMixin: _shape_modified, _shape_labels, _shape_reason, - ) = _shape_openai_response_create_frame( - msg, - input_tokens=_openai_response_create_frame_input_tokens( + ) = _shape_openai_response_create_frame( msg, - self.openai_provider, - ), - conversation_key=f"ws:{session_id}", - ) + input_tokens=_openai_response_create_frame_input_tokens( + msg, + self.openai_provider, + ), + conversation_key=f"ws:{session_id}", + ) _append_unique_transforms( transforms_applied, _shape_labels, diff --git a/tests/test_cli/test_mcp.py b/tests/test_cli/test_mcp.py index 7a65567db..7048c271a 100644 --- a/tests/test_cli/test_mcp.py +++ b/tests/test_cli/test_mcp.py @@ -19,6 +19,7 @@ from headroom.cli.mcp import ( load_mcp_config, save_mcp_config, ) +from headroom.mcp_registry.base import ServerSpec # Check if MCP SDK is available try: @@ -39,26 +40,11 @@ def temp_claude_dir(tmp_path): @pytest.fixture def mock_claude_config_path(temp_claude_dir): - """Patch the MCP config path to use temp directory. - - Also mocks the claude CLI as absent so tests exercise the mcp.json - fallback path rather than the `claude mcp add` path. - """ - import shutil as _shutil - + """Patch the MCP config path to use temp directory.""" config_path = temp_claude_dir / "mcp.json" - # Capture the original function reference before patching so we don't recurse. - _real_which = _shutil.which - - def which_no_claude(cmd): - if cmd == "claude": - return None - return _real_which(cmd) - with patch("headroom.cli.mcp.MCP_CONFIG_PATH", config_path): with patch("headroom.cli.mcp.CLAUDE_CONFIG_DIR", temp_claude_dir): - with patch("headroom.cli.mcp.shutil.which", side_effect=which_no_claude): - yield config_path + yield config_path @pytest.fixture @@ -69,6 +55,30 @@ def mock_mcp_available(): yield mock_mcp +class FakeRegistrar: + name = "fake" + display_name = "Fake Agent" + + def __init__(self, *, configured: bool = True) -> None: + self.configured = configured + self.removed: list[str] = [] + + def detect(self) -> bool: + return True + + def get_server(self, server_name: str) -> ServerSpec | None: + if self.configured and server_name == "headroom": + return ServerSpec(name="headroom", command="headroom", args=("mcp", "serve")) + return None + + def unregister_server(self, server_name: str) -> bool: + if self.configured and server_name == "headroom": + self.configured = False + self.removed.append(server_name) + return True + return False + + class TestMCPConfigFunctions: """Test config file handling functions.""" @@ -146,65 +156,45 @@ class TestMCPConfigFunctions: class TestMCPUninstallCommand: """Test 'headroom mcp uninstall' command.""" - def test_uninstall_removes_headroom(self, mock_claude_config_path, mock_mcp_available): - """Uninstall removes headroom from the legacy config file.""" - # Pre-populate the config directly rather than depending on - # `mcp install` plumbing — keeps the test focused on uninstall. - mock_claude_config_path.write_text( - json.dumps( - { - "mcpServers": { - "headroom": {"command": "headroom", "args": ["mcp", "serve"]}, - } - } - ) - ) + def test_uninstall_removes_headroom(self, mock_mcp_available): + """Uninstall removes headroom through detected registrars.""" + registrar = FakeRegistrar() runner = CliRunner() - result = runner.invoke(main, ["mcp", "uninstall"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "uninstall"]) assert result.exit_code == 0 assert "removed" in result.output.lower() + assert registrar.removed == ["headroom"] - config = json.loads(mock_claude_config_path.read_text()) - assert "headroom" not in config["mcpServers"] - - def test_uninstall_preserves_other_servers(self, mock_claude_config_path): - """Uninstall preserves other MCP servers.""" - # Create config with headroom and another server - config = { - "mcpServers": { - "headroom": {"command": "headroom", "args": ["mcp", "serve"]}, - "github": {"command": "github-mcp", "args": []}, - } - } - mock_claude_config_path.write_text(json.dumps(config)) + def test_uninstall_checks_only_headroom_servers(self): + """Uninstall only asks registrars to remove Headroom-owned servers.""" + registrar = FakeRegistrar() runner = CliRunner() - result = runner.invoke(main, ["mcp", "uninstall"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "uninstall"]) assert result.exit_code == 0 - - config = json.loads(mock_claude_config_path.read_text()) - assert "headroom" not in config["mcpServers"] - assert "github" in config["mcpServers"] + assert registrar.removed == ["headroom"] def test_uninstall_no_config_file(self, mock_claude_config_path): """Uninstall with no config file exits cleanly.""" runner = CliRunner() - result = runner.invoke(main, ["mcp", "uninstall"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[]): + result = runner.invoke(main, ["mcp", "uninstall"]) assert result.exit_code == 0 assert "nothing to uninstall" in result.output.lower() def test_uninstall_not_configured(self, mock_claude_config_path): """Uninstall when headroom not in config exits cleanly.""" - # Create config without headroom - config = {"mcpServers": {"other": {"command": "other"}}} - mock_claude_config_path.write_text(json.dumps(config)) + registrar = FakeRegistrar(configured=False) runner = CliRunner() - result = runner.invoke(main, ["mcp", "uninstall"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "uninstall"]) assert result.exit_code == 0 assert "not configured" in result.output.lower() @@ -216,7 +206,9 @@ class TestMCPStatusCommand: def test_status_not_configured(self, mock_claude_config_path): """Status shows not configured when no config.""" runner = CliRunner() - result = runner.invoke(main, ["mcp", "status"]) + registrar = FakeRegistrar(configured=False) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "status"]) assert result.exit_code == 0 assert "MCP SDK" in result.output @@ -228,21 +220,12 @@ class TestMCPStatusCommand: ) def test_status_configured(self, mock_claude_config_path, mock_mcp_available): - """Status reports configured when the legacy config has headroom.""" - # Pre-populate the legacy mcp.json directly. mcp_status() reads - # from MCP_CONFIG_PATH, which the fixture redirects here. - mock_claude_config_path.write_text( - json.dumps( - { - "mcpServers": { - "headroom": {"command": "headroom", "args": ["mcp", "serve"]}, - } - } - ) - ) + """Status reports configured when a registrar has headroom.""" + registrar = FakeRegistrar(configured=True) runner = CliRunner() - result = runner.invoke(main, ["mcp", "status"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "status"]) assert result.exit_code == 0 assert "✓ Configured" in result.output @@ -305,43 +288,27 @@ class TestMCPServerInitialization: # subprocess.run mocks at the registrar boundary — no module-level patches. -class TestMCPUninstallWithClaudeCLI: - """Test mcp_uninstall when the claude CLI is available.""" +class TestMCPUninstallWithRegistrars: + """Test mcp_uninstall delegates to registrars.""" - def test_uninstall_calls_claude_mcp_remove(self): - """Uninstall calls claude mcp remove when headroom is registered.""" - calls = [] - - def capturing_run(cmd, **kwargs): - calls.append(list(cmd)) - return MagicMock(returncode=0, stderr="") + def test_uninstall_reports_removed_registrar_server(self): + registrar = FakeRegistrar(configured=True) runner = CliRunner() - with patch("headroom.cli.mcp.shutil.which", return_value="/usr/bin/claude"): - with patch("headroom.cli.mcp.subprocess.run", side_effect=capturing_run): - result = runner.invoke(main, ["mcp", "uninstall"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "uninstall"]) assert result.exit_code == 0 - assert "removed" in result.output.lower() - subcommands = [c[2] for c in calls] - assert "remove" in subcommands + assert "Fake Agent" in result.output + assert registrar.removed == ["headroom"] - def test_uninstall_skips_remove_when_not_registered(self): - """Uninstall does not call remove when headroom is not registered via claude CLI.""" - calls = [] - - def capturing_run(cmd, **kwargs): - calls.append(list(cmd)) - # mcp get returns non-zero → not registered - if "get" in cmd: - return MagicMock(returncode=1, stderr="") - return MagicMock(returncode=0, stderr="") + def test_uninstall_skips_unconfigured_registrar(self): + registrar = FakeRegistrar(configured=False) runner = CliRunner() - with patch("headroom.cli.mcp.shutil.which", return_value="/usr/bin/claude"): - with patch("headroom.cli.mcp.subprocess.run", side_effect=capturing_run): - result = runner.invoke(main, ["mcp", "uninstall"]) + with patch("headroom.mcp_registry.get_all_registrars", return_value=[registrar]): + result = runner.invoke(main, ["mcp", "uninstall"]) assert result.exit_code == 0 - subcommands = [c[2] for c in calls] - assert "remove" not in subcommands + assert "nothing to uninstall" in result.output.lower() + assert registrar.removed == [] diff --git a/tests/test_cli/test_wrap_opencode.py b/tests/test_cli/test_wrap_opencode.py index acee3c478..31c512eea 100644 --- a/tests/test_cli/test_wrap_opencode.py +++ b/tests/test_cli/test_wrap_opencode.py @@ -136,6 +136,26 @@ def test_wrap_opencode_prepare_only_injects_config( assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:9000/v1" +def test_wrap_opencode_prepare_only_registers_serena_with_agent_context( + runner: CliRunner, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("HEADROOM_CONTEXT_TOOL", raising=False) + _set_test_home(monkeypatch, tmp_path) + + with patch.object(wrap_mod.shutil, "which", return_value="opencode"): + with patch.object(wrap_mod, "_ensure_rtk_binary", return_value=Path("/tmp/rtk")): + result = runner.invoke(main, ["wrap", "opencode", "--prepare-only"]) + + assert result.exit_code == 0, result.output + config_file = tmp_path / ".config" / "opencode" / "opencode.json" + config = json.loads(config_file.read_text()) + serena_command = config["mcp"]["serena"]["command"] + assert serena_command[serena_command.index("--context") + 1] == "agent" + + def test_wrap_opencode_no_mcp_skips_mcp_injection( runner: CliRunner, tmp_path: Path, diff --git a/tests/test_openai_codex_ws_lifecycle.py b/tests/test_openai_codex_ws_lifecycle.py index dda3203eb..a7ac5d915 100644 --- a/tests/test_openai_codex_ws_lifecycle.py +++ b/tests/test_openai_codex_ws_lifecycle.py @@ -349,10 +349,7 @@ async def test_ws_first_frame_output_shaper_rewrites_without_compression(monkeyp payload = sent["response"] assert "" in payload["instructions"] assert payload["text"]["verbosity"] == "low" - assert any( - t == "output_shaper:verbosity:L2" - for t in outcomes[-1].transforms_applied - ) + assert any(t == "output_shaper:verbosity:L2" for t in outcomes[-1].transforms_applied) @pytest.mark.asyncio @@ -455,6 +452,8 @@ async def test_ws_output_shaper_holdout_labels_without_rewrite(monkeypatch): transforms = outcomes[-1].transforms_applied assert any(t.startswith("output_shaper:control:") for t in transforms) assert not any(t == "output_shaper:verbosity:L2" for t in transforms) + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- diff --git a/tests/test_output_shaper.py b/tests/test_output_shaper.py index bff332c67..516478942 100644 --- a/tests/test_output_shaper.py +++ b/tests/test_output_shaper.py @@ -301,10 +301,7 @@ class TestOpenAIResponsesClassify: "output": "ok", } ] - assert ( - classify_openai_responses_input(input_data) - == TurnKind.MECHANICAL_CONTINUATION - ) + assert classify_openai_responses_input(input_data) == TurnKind.MECHANICAL_CONTINUATION def test_mixed_user_message_and_tool_output_is_new_ask(self): input_data = [