mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
fix(opencode): write local MCP config (#1381)
## Description Fixes the OpenCode config corruption reported in #1380 for wrap, MCP registration, and provider-scope install paths. OpenCode MCP entries are local stdio servers, not remote HTTP endpoints. This changes Headroom's OpenCode MCP serialization to write `type: "local"` with `command: ["headroom", "mcp", "serve"]`, uses OpenCode's `environment` field for MCP env vars, and still reads the older `env` key for compatibility. This also stops provider-only OpenCode config injection from creating a fake `http://127.0.0.1:<port>/mcp` entry, so `headroom wrap opencode --no-mcp` no longer leaves `mcp.headroom` behind. Finally, the install CLI/docs now accept and document `--target opencode` with provider scope. This does not change the broader `headroom mcp status/uninstall` behavior from #1380; that looks like a separate follow-up. ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) - [ ] New feature (non-breaking change that adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to change) - [x] Documentation update - [ ] Performance improvement - [ ] Code refactoring (no functional changes) ## Changes Made - Write OpenCode MCP entries as local stdio config instead of remote `/mcp` config. - Use `environment` for OpenCode MCP env vars while continuing to read legacy `env` entries. - Stop OpenCode provider injection/persistent provider install from adding MCP config. - Keep `--no-mcp` from writing `mcp.headroom` while preserving other MCP entries such as Serena. - Allow `headroom install apply --target opencode` at the CLI layer. - Update OpenCode docs and changelog. ## Testing - [x] Focused unit tests pass - [x] Linting passes (`ruff check .`) - [x] Formatting passes (`ruff format --check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for the fixed behavior - [x] Manual testing performed ### Test Output ```text $ pytest tests/test_mcp_registry_opencode.py tests/test_cli/test_wrap_opencode.py tests/test_providers_opencode_config.py tests/test_providers_opencode_install.py tests/test_cli/test_install_cli.py tests/test_install/test_providers.py Pytest: 164 passed $ uvx ruff check . All checks passed! $ uvx ruff format --check . 986 files already formatted $ uvx mypy --config-file pyproject.toml headroom Success: no issues found in 398 source files ``` ## Real Behavior Proof - Environment: macOS local worktree at `/Users/vinaygupta/Desktop/git/headroom-fix-opencode-mcp-config`; branch `fix-opencode-mcp-config`; commit `aea96208`. - Exact command / steps: ran the focused OpenCode/installer regression suite plus Ruff lint/format checks and mypy commands shown above. - Observed result: the focused tests pass and cover OpenCode MCP serialization as `type: "local"`, `command: ["headroom", "mcp", "serve"]`, `environment` env vars, `--no-mcp` not writing `mcp.headroom`, provider-scope install not adding MCP config, and `install apply --target opencode` being accepted. - Not tested: full `pytest` locally, because collection requires the native `headroom._core` extension in this worktree. Attempting the project runner hit a local native build failure first: `esaxx-rs` failed compiling `src/esaxx.cpp` with `fatal error: 'cstdint' file not found`. The broader generic `headroom mcp status/uninstall` behavior from #1380 is intentionally left for a follow-up. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review Scope note: generic `mcp status/uninstall` support from #1380 is intentionally left as a separate follow-up PR.
This commit is contained in:
parent
b09f027062
commit
6c83790680
11 changed files with 156 additions and 56 deletions
|
|
@ -39,6 +39,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
* **proxy:** add `--protect-tool-results` / `HEADROOM_PROTECT_TOOL_RESULTS` to prevent lossy compression of exact-output tool results (e.g. `Bash cat`/`grep` results) — closes [#1307](https://github.com/headroomlabs-ai/headroom/issues/1307).
|
||||
* **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.
|
||||
|
|
|
|||
|
|
@ -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:<port>/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:<port>/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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from typing import Any
|
|||
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")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import json
|
|||
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}]]
|
||||
|
|
|
|||
|
|
@ -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] = []
|
||||
|
|
|
|||
|
|
@ -160,6 +160,9 @@ def test_wrap_opencode_no_mcp_skips_mcp_injection(
|
|||
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 test_wrap_opencode_injects_mcp_by_default(
|
|||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue