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
This commit is contained in:
Rudimar Ronsoni 2026-07-06 15:22:15 +02:00 committed by GitHub
parent 88f935a1eb
commit 4bd3ddfaa5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 171 additions and 254 deletions

View file

@ -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)).

View file

@ -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:<port>/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:<port>/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-<provider>
--anyllm-provider ... \ # AnyLLM provider selection
--region ... \ # Provider region
-- <opencode args> # 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 ... \
-- <opencode args>
```
## 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:<port>/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.

View file

@ -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:

View file

@ -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.

View file

@ -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,

View file

@ -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 == []

View file

@ -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,

View file

@ -349,10 +349,7 @@ async def test_ws_first_frame_output_shaper_rewrites_without_compression(monkeyp
payload = sent["response"]
assert "<headroom_output_shaping>" 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
# ---------------------------------------------------------------------------

View file

@ -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 = [