diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d55f45f3..3e08fad0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **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)). * **cli:** add `--rpm`/`--tpm` and `HEADROOM_RPM`/`HEADROOM_TPM` to the Click proxy command for rate-limit parity with the legacy CLI -- closes [#1350](https://github.com/headroomlabs-ai/headroom/issues/1350) (Problem 1). * **proxy:** register `ToolResultInterceptorTransform` in explicit transforms list when `HEADROOM_INTERCEPT_ENABLED` is set — closes [#829](https://github.com/headroomlabs-ai/headroom/issues/829). +* **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)). * **code:** keep Python `from __future__` imports before executable code during AST compression and validate compressed Python with `compile(..., "exec")` so compile-time syntax rules are enforced ([#1233](https://github.com/chopratejas/headroom/issues/1233)). * **proxy:** report real input tokens on the streaming `message_start` event for LiteLLM/Bedrock-backed requests. LiteLLM streaming never surfaces prompt tokens mid-stream, so `message_start.usage.input_tokens` was always `0`; Anthropic clients (e.g. Claude Code) read input-token metrics from that event, underreporting token usage by ~99% in OTel/CloudWatch dashboards. The Bedrock streamer now backfills `input_tokens` with the count Headroom actually sent upstream when the backend leaves it unset, preserving any non-zero value the backend genuinely reports ([#1132](https://github.com/chopratejas/headroom/issues/1132)). * **proxy:** give buffered Anthropic request paths their own longer read timeout, so long `/v1/messages` turns and Anthropic batch or passthrough reads no longer trip the generic proxy cap while unrelated request timeouts stay unchanged. diff --git a/docs/content/docs/opencode.mdx b/docs/content/docs/opencode.mdx index a5df0fc3a..9f98cc700 100644 --- a/docs/content/docs/opencode.mdx +++ b/docs/content/docs/opencode.mdx @@ -24,11 +24,11 @@ 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` | -| Runtime env | Sets `OPENCODE_CONFIG_CONTENT` so OpenCode reads provider, model, and MCP config at launch | -| Provider compatibility | Leaves `OPENAI_BASE_URL` and `ANTHROPIC_BASE_URL` untouched so `/connect` providers keep their own routing | -| Context tool | Injects RTK or `lean-ctx` instructions into OpenCode AGENTS files | -| MCP setup | Registers Headroom MCP tools: `headroom_compress`, `headroom_retrieve`, and `headroom_stats` | +| 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 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 | @@ -36,7 +36,8 @@ headroom unwrap opencode ## Options ```bash -headroom wrap opencode --port 8787 \ # Proxy port. Defaults to a random available port +headroom wrap opencode \ + --port 8787 \ # Proxy port. Defaults to a random available port --no-rtk \ # Skip RTK context tool injection --no-mcp \ # Skip Headroom MCP registration --no-serena \ # Skip Serena code graph MCP @@ -68,7 +69,7 @@ The default model is `headroom/claude-sonnet-4-6`. Change it in `opencode.json` | Variable | Description | |---|---| -| `OPENCODE_CONFIG_CONTENT` | JSON payload with provider, model, and MCP config injected by `wrap` | +| `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 | | `HEADROOM_CONTEXT_TOOL` | Set to `lean-ctx` to use lean-ctx instead of RTK | @@ -87,16 +88,12 @@ See [Failure Learning](/docs/failure-learning) for details on the learn system. `headroom install` supports OpenCode as a target for persistent provider wiring: ```bash -headroom install apply --preset persistent-service --providers manual --target opencode +headroom install apply --preset persistent-service --scope provider --providers manual --target opencode ``` This writes the Headroom provider into `~/.config/opencode/opencode.json` and keeps the proxy running on port 8787. -Provider scope is also supported: - -```bash -headroom install apply --preset persistent-service --scope provider --providers manual --target opencode -``` +Provider-only installs do not write Headroom MCP config. MCP tools are added by `headroom wrap opencode` unless `--no-mcp` is set. ## Native OpenCode Plugin @@ -114,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, 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, local MCP registration, backups, and unwrap behavior. ## Programmatic Config Helpers @@ -140,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, and 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` unless `--no-mcp` is set. +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. 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/install.py b/headroom/cli/install.py index ed9883d96..916a849e4 100644 --- a/headroom/cli/install.py +++ b/headroom/cli/install.py @@ -135,7 +135,7 @@ def _reject_task_lifecycle(manifest: DeploymentManifest, action: str) -> None: "--target", "targets", multiple=True, - type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw"]), + type=click.Choice(["claude", "copilot", "codex", "aider", "cursor", "openclaw", "opencode"]), help="Tool target to configure when --providers manual is used.", ) @click.option("--profile", default="default", show_default=True, help="Deployment profile name.") diff --git a/headroom/mcp_registry/opencode.py b/headroom/mcp_registry/opencode.py index 105c1ec6f..93dda3e6d 100644 --- a/headroom/mcp_registry/opencode.py +++ b/headroom/mcp_registry/opencode.py @@ -63,7 +63,7 @@ def _entry_to_spec(name: str, entry: dict[str, Any]) -> ServerSpec: else: command = str(command_value) if command_value else "" args = () - env_value = entry.get("env", {}) + env_value = entry.get("environment", entry.get("env", {})) env: dict[str, str] = {} if isinstance(env_value, dict): env = {str(k): str(v) for k, v in env_value.items()} @@ -72,16 +72,12 @@ def _entry_to_spec(name: str, entry: dict[str, Any]) -> ServerSpec: def _spec_to_entry(spec: ServerSpec) -> dict[str, Any]: entry: dict[str, Any] = { - "type": "remote", - "url": "", + "type": "local", + "command": [spec.command, *spec.args], "enabled": True, } - if spec.args: - entry["command"] = [spec.command, *spec.args] - else: - entry["command"] = spec.command if spec.env: - entry["env"] = dict(spec.env) + entry["environment"] = dict(spec.env) return entry diff --git a/headroom/providers/opencode/config.py b/headroom/providers/opencode/config.py index b9ca9ee54..3579fabbf 100644 --- a/headroom/providers/opencode/config.py +++ b/headroom/providers/opencode/config.py @@ -12,6 +12,7 @@ import click 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 ---" @@ -98,12 +99,16 @@ def _render_provider_block(port: int) -> str: 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": { - "type": "remote", - "url": f"http://127.0.0.1:{port}/mcp", - "enabled": True, - } + "headroom": mcp_entry, } lines = [ _MCP_MARKER_START, @@ -192,7 +197,7 @@ def inject_opencode_provider_config(port: int) -> None: data = {} # Strip any prior Headroom-managed blocks before re-injecting. - if _PROVIDER_MARKER_START in content: + if _PROVIDER_MARKER_START in content or _MCP_MARKER_START in content: content = strip_opencode_headroom_blocks(content) data = _parse_json_loose(content) @@ -206,16 +211,6 @@ def inject_opencode_provider_config(port: int) -> None: } data = _inject_key_into_json(data, "provider", provider) - # Inject MCP if not already present. - mcp = { - "headroom": { - "type": "remote", - "url": f"http://127.0.0.1:{port}/mcp", - "enabled": True, - } - } - data = _inject_key_into_json(data, "mcp", mcp) - # Write back as formatted JSON (opencode uses standard JSON with comments). output = json.dumps(data, indent=2) + "\n" config_file.write_text(output, encoding="utf-8") diff --git a/headroom/providers/opencode/runtime.py b/headroom/providers/opencode/runtime.py index 75bd0fd87..1b41a9b33 100644 --- a/headroom/providers/opencode/runtime.py +++ b/headroom/providers/opencode/runtime.py @@ -6,6 +6,8 @@ import os from collections.abc import Mapping +from headroom.mcp_registry.install import DEFAULT_PROXY_URL + from .config import HEADROOM_OPENCODE_PLUGIN @@ -37,12 +39,16 @@ def build_opencode_config_content( } } if include_mcp: + proxy_url = f"http://127.0.0.1:{port}" + mcp_entry: dict[str, object] = { + "type": "local", + "command": ["headroom", "mcp", "serve"], + "enabled": True, + } + if proxy_url != DEFAULT_PROXY_URL: + mcp_entry["environment"] = {"HEADROOM_PROXY_URL": proxy_url} config["mcp"] = { - "headroom": { - "type": "remote", - "url": f"http://127.0.0.1:{port}/mcp", - "enabled": True, - } + "headroom": mcp_entry, } if include_plugin: config["plugin"] = [[HEADROOM_OPENCODE_PLUGIN, {"proxyUrl": base_url}]] diff --git a/tests/test_cli/test_install_cli.py b/tests/test_cli/test_install_cli.py index ed1600892..770ec5b6d 100644 --- a/tests/test_cli/test_install_cli.py +++ b/tests/test_cli/test_install_cli.py @@ -135,6 +135,57 @@ def test_install_apply_rejects_provider_scope_targets_without_support() -> None: assert "Provider scope supports only claude, codex, openclaw, and opencode" in result.output +def test_install_apply_accepts_opencode_target(monkeypatch) -> None: + runner = CliRunner() + captured: dict[str, object] = {} + + class Manifest: + profile = "default" + preset = "persistent-service" + runtime_kind = "python" + supervisor_kind = "service" + scope = "provider" + health_url = "http://127.0.0.1:8787/readyz" + targets = ["opencode"] + mutations = [] + artifacts = [] + + manifest = Manifest() + + def fake_build_manifest(**kwargs): + captured.update(kwargs) + return manifest + + monkeypatch.setattr("headroom.cli.install.build_manifest", fake_build_manifest) + monkeypatch.setattr("headroom.cli.install.load_manifest", lambda profile: None) + monkeypatch.setattr("headroom.cli.install.apply_mutations", lambda deployment: []) + monkeypatch.setattr("headroom.cli.install.install_supervisor", lambda deployment: []) + monkeypatch.setattr("headroom.cli.install.save_manifest", lambda deployment: None) + monkeypatch.setattr("headroom.cli.install.start_supervisor", lambda deployment: None) + monkeypatch.setattr("headroom.cli.install.start_detached_agent", lambda profile: None) + monkeypatch.setattr( + "headroom.cli.install.wait_ready", lambda deployment, timeout_seconds=45: True + ) + + result = runner.invoke( + main, + [ + "install", + "apply", + "--scope", + "provider", + "--providers", + "manual", + "--target", + "opencode", + ], + ) + + assert result.exit_code == 0, result.output + assert captured["targets"] == ["opencode"] + assert "Targets: opencode" in result.output + + def test_install_apply_restores_previous_deployment_after_failed_update(monkeypatch) -> None: runner = CliRunner() calls: list[str] = [] diff --git a/tests/test_cli/test_wrap_opencode.py b/tests/test_cli/test_wrap_opencode.py index 693d6cf28..3f44ed3c7 100644 --- a/tests/test_cli/test_wrap_opencode.py +++ b/tests/test_cli/test_wrap_opencode.py @@ -160,6 +160,9 @@ def fake_launch_tool(**kwargs): # noqa: ANN003 env = captured["env"] config = json.loads(env["OPENCODE_CONFIG_CONTENT"]) assert "mcp" not in config + config_file = tmp_path / ".config" / "opencode" / "opencode.json" + persisted_config = json.loads(config_file.read_text()) + assert "headroom" not in persisted_config.get("mcp", {}) def test_wrap_opencode_injects_mcp_by_default( @@ -186,7 +189,12 @@ def fake_launch_tool(**kwargs): # noqa: ANN003 env = captured["env"] config = json.loads(env["OPENCODE_CONFIG_CONTENT"]) assert "mcp" in config - assert config["mcp"]["headroom"]["type"] == "remote" + assert config["mcp"]["headroom"] == { + "type": "local", + "command": ["headroom", "mcp", "serve"], + "enabled": True, + "environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9000"}, + } def test_wrap_opencode_injects_rtk_into_agents_md( diff --git a/tests/test_mcp_registry_opencode.py b/tests/test_mcp_registry_opencode.py index e8a370335..99f4d869d 100644 --- a/tests/test_mcp_registry_opencode.py +++ b/tests/test_mcp_registry_opencode.py @@ -63,8 +63,8 @@ def test_get_server_returns_spec_when_present(tmp_path: Path) -> None: config = { "mcp": { "headroom": { - "type": "remote", - "url": "http://127.0.0.1:8787/mcp", + "type": "local", + "command": ["headroom", "mcp", "serve"], "enabled": True, } } @@ -74,6 +74,8 @@ def test_get_server_returns_spec_when_present(tmp_path: Path) -> None: spec = registrar.get_server("headroom") assert spec is not None assert spec.name == "headroom" + assert spec.command == "headroom" + assert spec.args == ("mcp", "serve") def test_register_server_creates_config_when_missing(tmp_path: Path) -> None: @@ -86,6 +88,12 @@ def test_register_server_creates_config_when_missing(tmp_path: Path) -> None: assert result.status == RegisterStatus.REGISTERED config_path = tmp_path / "opencode.json" assert config_path.exists() + data = json.loads(config_path.read_text()) + assert data["mcp"]["headroom"] == { + "type": "local", + "command": ["headroom", "mcp", "serve"], + "enabled": True, + } def test_register_server_idempotent(tmp_path: Path) -> None: @@ -259,7 +267,7 @@ def test_register_server_with_env_vars(tmp_path: Path) -> None: registrar.register_server(spec) data = json.loads((tmp_path / "opencode.json").read_text()) - assert data["mcp"]["headroom"]["env"] == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"} + assert data["mcp"]["headroom"]["environment"] == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"} def test_register_then_re_register_with_different_env_returns_mismatch(tmp_path: Path) -> None: @@ -306,6 +314,30 @@ def test_entry_to_spec_command_as_string() -> None: assert spec.args == () +def test_entry_to_spec_reads_opencode_environment() -> None: + """_entry_to_spec reads OpenCode's environment map.""" + entry = { + "type": "local", + "command": ["headroom", "mcp", "serve"], + "enabled": True, + "environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}, + } + spec = _entry_to_spec("headroom", entry) + assert spec.env == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"} + + +def test_entry_to_spec_reads_legacy_env() -> None: + """_entry_to_spec keeps compatibility with previously written env maps.""" + entry = { + "type": "local", + "command": ["headroom", "mcp", "serve"], + "enabled": True, + "env": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"}, + } + spec = _entry_to_spec("headroom", entry) + assert spec.env == {"HEADROOM_PROXY_URL": "http://127.0.0.1:9090"} + + def test_entry_to_spec_no_command() -> None: """_entry_to_spec handles an entry without a 'command' key.""" entry: dict[str, Any] = { @@ -329,9 +361,9 @@ def test_spec_to_entry_roundtrip() -> None: env={"KEY": "VAL"}, ) entry = _spec_to_entry(original) - assert entry["type"] == "remote" + assert entry["type"] == "local" assert entry["command"] == ["python", "-m", "server"] - assert entry["env"] == {"KEY": "VAL"} + assert entry["environment"] == {"KEY": "VAL"} restored = _entry_to_spec("test", entry) assert restored.name == original.name diff --git a/tests/test_providers_opencode_config.py b/tests/test_providers_opencode_config.py index 23524e756..439417325 100644 --- a/tests/test_providers_opencode_config.py +++ b/tests/test_providers_opencode_config.py @@ -164,6 +164,7 @@ def test_inject_provider_config_creates_file( assert config_file.exists() config = _parse_json_loose(config_file.read_text()) assert config["provider"]["headroom"]["npm"] == "@ai-sdk/openai-compatible" + assert "mcp" not in config assert "model" not in config # headroom provider is a transparent pass-through @@ -293,10 +294,10 @@ def test_strip_blocks_handles_only_mcp_markers() -> None: # --------------------------------------------------------------------------- -def test_inject_provider_config_merges_with_existing_mcp( +def test_inject_provider_config_preserves_existing_mcp( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - """inject_opencode_provider_config merges headroom MCP with existing MCP servers.""" + """inject_opencode_provider_config preserves MCP without adding headroom.""" _set_test_home(monkeypatch, tmp_path) config_file = tmp_path / ".config" / "opencode" / "opencode.json" config_file.parent.mkdir(parents=True, exist_ok=True) @@ -308,7 +309,7 @@ def test_inject_provider_config_merges_with_existing_mcp( config = json.loads(config_file.read_text()) assert "existing-server" in config["mcp"] - assert "headroom" in config["mcp"] + assert "headroom" not in config["mcp"] def test_inject_provider_config_idempotent_with_complex_config( @@ -335,7 +336,7 @@ def test_inject_provider_config_idempotent_with_complex_config( assert "openai" in config["provider"] assert "headroom" in config["provider"] assert "myserver" in config["mcp"] - assert "headroom" in config["mcp"] + assert "headroom" not in config["mcp"] def test_inject_provider_config_preserves_unrelated_top_level_keys( @@ -412,6 +413,18 @@ def test_build_opencode_config_content_without_mcp() -> None: assert config["plugin"] == [["headroom-opencode", {"proxyUrl": "http://127.0.0.1:8787/v1"}]] +def test_build_opencode_config_content_with_mcp_uses_local_stdio() -> None: + from headroom.providers.opencode.runtime import build_opencode_config_content + + config = build_opencode_config_content(port=9000, include_mcp=True) + assert config["mcp"]["headroom"] == { + "type": "local", + "command": ["headroom", "mcp", "serve"], + "enabled": True, + "environment": {"HEADROOM_PROXY_URL": "http://127.0.0.1:9000"}, + } + + def test_build_launch_env_with_project(monkeypatch: pytest.MonkeyPatch) -> None: from headroom.providers.opencode.runtime import build_launch_env diff --git a/tests/test_providers_opencode_install.py b/tests/test_providers_opencode_install.py index ab8623a92..b8e529da0 100644 --- a/tests/test_providers_opencode_install.py +++ b/tests/test_providers_opencode_install.py @@ -60,6 +60,7 @@ def test_apply_provider_scope_creates_config( config = json.loads(config_file.read_text()) assert config["provider"]["headroom"]["options"]["baseURL"] == "http://127.0.0.1:8787/v1" + assert "mcp" not in config def test_apply_provider_scope_skips_when_scope_is_not_provider(