diff --git a/tests/hermes_cli/test_mcp_catalog_env_boundary.py b/tests/hermes_cli/test_mcp_catalog_env_boundary.py index 63c19bc6fe..bd269b682d 100644 --- a/tests/hermes_cli/test_mcp_catalog_env_boundary.py +++ b/tests/hermes_cli/test_mcp_catalog_env_boundary.py @@ -147,12 +147,6 @@ def test_catalog_accepts_declared_credential( from tools.connectors.mcp import _CatalogBackend probes: list[str] = [] - installs: list[str] = [] - monkeypatch.setattr( - mcp_catalog, - "install_entry", - lambda entry, enable=True, preloaded_env=None: installs.append(entry.name), - ) def probe(name, cfg, **_kwargs): # The credential is in scope for the probe, and nothing is saved before it answers. @@ -326,3 +320,34 @@ def test_preexisting_copilot_controls_remain_usable( assert _resolve_command() == "/opt/operator/copilot" assert _resolve_args() == ["--acp", "--stdio", "--operator-mode"] + + +def test_connection_card_install_keeps_env_file_secrets_only( + client: TestClient, + catalog_env: Path, + monkeypatch: pytest.MonkeyPatch, +): + """The connector-card backend (Desktop/TUI/CLI setup card) makes the same secrets-only split + as the terminal install: a declared non-secret lands in the server block, never in .env.""" + import hermes_cli.mcp_config as mcp_config + from tools.connectors.mcp import _CatalogBackend + + catalog_root = Path(os.environ["HERMES_OPTIONAL_MCPS"]) + manifest_path = catalog_root / "demo" / "manifest.yaml" + manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) + manifest["transport"]["env"] = {"DEMO_BASE_URL": "${DEMO_BASE_URL}"} + manifest["auth"]["env"] = [ + {"name": "DEMO_API_KEY", "prompt": "Demo API key", "secret": True}, + {"name": "DEMO_BASE_URL", "prompt": "Demo base URL", "secret": False}, + ] + manifest_path.write_text(yaml.safe_dump(manifest), encoding="utf-8") + monkeypatch.setattr(mcp_config, "_probe_single_server", lambda name, cfg, **_k: [("demo_tool", "")]) + + _CatalogBackend().install( + "demo", {"DEMO_API_KEY": "valid-demo-value", "DEMO_BASE_URL": "https://demo.example.test"} + ) + + env_text = (catalog_env / ".env").read_text(encoding="utf-8") + assert "DEMO_API_KEY=valid-demo-value" in env_text + assert "DEMO_BASE_URL" not in env_text and "https://demo.example.test" not in env_text + assert mcp_config._get_mcp_servers()["demo"]["env"]["DEMO_BASE_URL"] == "https://demo.example.test" diff --git a/tools/connectors/mcp.py b/tools/connectors/mcp.py index d91a7b753b..9276310dc6 100644 --- a/tools/connectors/mcp.py +++ b/tools/connectors/mcp.py @@ -136,12 +136,18 @@ class _CatalogBackend: after the server answered. A failure writes nothing, so a failed reinstall keeps the previous configuration.""" from agent.secret_scope import current_secret_scope, reset_secret_scope, set_secret_scope - from hermes_cli.mcp_catalog import card_install_config + from hermes_cli.mcp_catalog import _inline_non_secret_value, card_install_config from hermes_cli.mcp_config import _probe_single_server, _save_mcp_server entry = _catalog_entry(name) _check_declared(name, entry, env) cfg = card_install_config(entry) + # `.env` is secrets-only: non-secret values (hostnames, client ids, workspace names) are + # inlined into the server block, the same split `install_entry` makes for the terminal path. + secret_names = {spec.name for spec in (entry.auth.env or []) if spec.secret} + for key, value in env.items(): + if key not in secret_names and value: + cfg = _inline_non_secret_value(cfg, key, value) token = set_secret_scope({**dict(current_secret_scope() or {}), **env}) try: tools = [str(tool[0]) for tool in (_probe_single_server(name, cfg) or [])] @@ -149,7 +155,7 @@ class _CatalogBackend: reset_secret_scope(token) if not _save_mcp_server(name, cfg): raise RuntimeError(f"'{name}' was rejected: suspicious command/args configuration") - _save_env(env) + _save_env({k: v for k, v in env.items() if k in secret_names}) return tools def enable(self, name: str) -> None: