mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
88f935a1eb
commit
4bd3ddfaa5
9 changed files with 171 additions and 254 deletions
|
|
@ -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)).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue