From a2412fbde3816d3991e391aa5509e70aa25ce3bc Mon Sep 17 00:00:00 2001 From: dijdzv Date: Fri, 12 Jun 2026 20:30:57 +0000 Subject: [PATCH] Add Codex env HTTP header mapping for MCP Codex supports remote MCP headers whose values are read from environment variables at runtime, but APM only emitted static HTTP headers. This forced secret-bearing headers to be resolved or stored during install. Add an env_headers MCP manifest field and map it to Codex env_http_headers while preserving existing static header behavior. Cover self-defined remotes, overlay merging, Codex formatting for list and dict shapes, and the end-to-end Codex config write path. --- .../docs/consumer/install-mcp-servers.md | 11 ++ .../author-primitives/mcp-as-primitive.md | 11 ++ .../content/docs/reference/manifest-schema.md | 8 ++ src/apm_cli/adapters/client/codex.py | 18 +++ src/apm_cli/integration/mcp_integrator.py | 32 ++++++ src/apm_cli/models/dependency/mcp.py | 26 +++++ .../test_mcp_targets_gating_e2e.py | 40 +++++++ tests/unit/test_codex_adapter_phase3.py | 86 ++++++++++++++ tests/unit/test_mcp_overlays.py | 108 ++++++++++++++++++ 9 files changed, 340 insertions(+) diff --git a/docs/src/content/docs/consumer/install-mcp-servers.md b/docs/src/content/docs/consumer/install-mcp-servers.md index 8c7966e59..c29b51bb9 100644 --- a/docs/src/content/docs/consumer/install-mcp-servers.md +++ b/docs/src/content/docs/consumer/install-mcp-servers.md @@ -43,6 +43,14 @@ dependencies: url: https://mcp.linear.app/sse headers: Authorization: "Bearer ${LINEAR_TOKEN}" + + # 4. Self-defined remote with native env-header binding + - name: internal-docs + registry: false + transport: streamable-http + url: https://mcp.example.com/mcp + env_headers: + X-Api-Key: INTERNAL_DOCS_MCP_KEY ``` The full grammar (overlays, `${input:...}` variables, `tools:` @@ -149,6 +157,9 @@ MCP defines two transport families. APM exposes both: remote endpoint. Requires `url:` (http or https only -- websockets and `file://` are rejected). Use `--header KEY=VALUE` (repeatable) for HTTP headers such as `Authorization`. + In `apm.yml`, use `env_headers:` when the target runtime should read + a header value from an environment variable at server-start instead + of storing or resolving the value during install. `--transport` is inferred when omitted: a `--url` implies a remote transport, a post-`--` command implies `stdio`. The mutually-exclusive diff --git a/docs/src/content/docs/producer/author-primitives/mcp-as-primitive.md b/docs/src/content/docs/producer/author-primitives/mcp-as-primitive.md index 383211ff3..58e95b277 100644 --- a/docs/src/content/docs/producer/author-primitives/mcp-as-primitive.md +++ b/docs/src/content/docs/producer/author-primitives/mcp-as-primitive.md @@ -50,6 +50,7 @@ either a bare string or a mapping. Fields, from | `url` | self-defined `http`/`sse`/`streamable-http` | `http://` or `https://` only. | | `env` | optional, stdio | Map of env vars passed to the child process. | | `headers` | optional, remote | Map of HTTP headers. CR/LF rejected. | +| `env_headers` | optional, remote | Map of HTTP header names to environment variable names for targets with native env-header support, such as Codex `env_http_headers`. | | `tools` | optional | Allowlist of tool names. Default `["*"]`. | | `version` | optional | Pin a registry server version. | | `registry` | optional | `false` = self-defined; URL = custom registry. | @@ -77,6 +78,8 @@ dependencies: url: https://mcp.linear.app/sse headers: Authorization: "Bearer ${LINEAR_TOKEN}" + env_headers: + X-Api-Key: LINEAR_API_KEY ``` ## What the consumer sees on install @@ -104,6 +107,14 @@ shipped. Do not embed tokens. Two patterns work: headers: Authorization: "Bearer ${LINEAR_TOKEN}" +# Native env-header binding -- targets that support it read the variable at runtime +- name: my-remote + registry: false + transport: streamable-http + url: https://mcp.example.com/mcp + env_headers: + Authorization: MY_REMOTE_MCP_TOKEN + # Stdio env -- value passed verbatim; use ${VAR} for indirection - name: my-internal registry: false diff --git a/docs/src/content/docs/reference/manifest-schema.md b/docs/src/content/docs/reference/manifest-schema.md index e8c382b11..4c963620b 100644 --- a/docs/src/content/docs/reference/manifest-schema.md +++ b/docs/src/content/docs/reference/manifest-schema.md @@ -498,6 +498,7 @@ A plain registry reference: `io.github.github/github-mcp-server`. | `registry` | `bool` or `string` | OPTIONAL | Default: `true` (public registry) | `false` = self-defined (private) server. String = custom registry URL. | | `package` | `enum` | OPTIONAL | `npm`, `pypi`, `oci` | Package manager type hint. | | `headers` | `map` | OPTIONAL | | Custom HTTP headers for remote endpoints. Same variable syntax as `env`. | +| `env_headers` | `map` | OPTIONAL | | Remote HTTP header names mapped to environment variable names. Targets with native support emit runtime env-header bindings instead of resolving or storing the value. | | `tools` | `list` | OPTIONAL | Default: `["*"]` | Restrict which tools are exposed. | | `url` | `string` | Conditional | | Endpoint URL. REQUIRED when `registry: false` and `transport` is `http`, `sse`, or `streamable-http`. | | `command` | `string` | Conditional | Single binary path; no embedded whitespace unless `args` is also present | Binary path. REQUIRED when `registry: false` and `transport` is `stdio`. | @@ -551,6 +552,11 @@ Values in `headers` and `env` may contain three placeholder syntaxes. APM resolv - **Recommended:** Use `${VAR}` or `${env:VAR}` in all new manifests - they work on every target that supports remote MCP servers. `` is legacy; in VS Code it would silently render as literal text in the generated config. - **Registry-backed servers** - APM auto-generates input prompts from registry metadata only for required variables. Optional variables do not generate prompts or runtime config entries when no value is available. If a user has already edited an optional value in runtime config, reinstall preserves that value rather than overwriting it. - **Self-defined servers** - APM detects `${input:...}` patterns in `apm.yml` and generates matching input definitions automatically. +- **Remote env headers** - `env_headers` is for targets with native HTTP + header environment-variable bindings. Values are environment variable names, + not header values. For Codex, `env_headers: { Authorization: MCP_TOKEN }` + becomes `env_http_headers = { Authorization = "MCP_TOKEN" }` in + `config.toml`. GitHub Actions templates (`${{ ... }}`) are intentionally left untouched. @@ -565,6 +571,8 @@ dependencies: Authorization: "Bearer ${MY_SECRET_TOKEN}" # bare env-var X-Tenant: "${env:TENANT_ID}" # env-prefixed X-Project: "${input:my-server-project}" # VS Code input prompt + env_headers: + X-Api-Key: MCP_API_KEY # Codex env_http_headers ``` --- diff --git a/src/apm_cli/adapters/client/codex.py b/src/apm_cli/adapters/client/codex.py index 28ae8798c..155f0690f 100644 --- a/src/apm_cli/adapters/client/codex.py +++ b/src/apm_cli/adapters/client/codex.py @@ -273,6 +273,24 @@ def _process_stdio_arg(arg): if http_headers: remote_config["http_headers"] = http_headers self._warn_input_variables(http_headers, server_name, "Codex CLI") + env_http_headers: dict[str, str] = {} + raw_env_headers = remote.get("env_headers", []) + if raw_env_headers is None: + raw_env_headers = [] + if isinstance(raw_env_headers, dict): + for h_name, env_name in raw_env_headers.items(): + if h_name and env_name: + env_http_headers[str(h_name)] = str(env_name) + else: + for header in raw_env_headers: + if not isinstance(header, dict): + continue + h_name = header.get("name", "") + env_name = header.get("env", "") + if h_name and env_name: + env_http_headers[str(h_name)] = str(env_name) + if env_http_headers: + remote_config["env_http_headers"] = env_http_headers return remote_config if not packages: diff --git a/src/apm_cli/integration/mcp_integrator.py b/src/apm_cli/integration/mcp_integrator.py index 8987a7d95..ac83fdada 100644 --- a/src/apm_cli/integration/mcp_integrator.py +++ b/src/apm_cli/integration/mcp_integrator.py @@ -315,6 +315,8 @@ def _build_self_defined_info(dep) -> dict: } if dep.headers: remote["headers"] = [{"name": k, "value": v} for k, v in dep.headers.items()] + if dep.env_headers: + remote["env_headers"] = [{"name": k, "env": v} for k, v in dep.env_headers.items()] info["remotes"] = [remote] else: # Build as a stdio package @@ -391,6 +393,36 @@ def _apply_overlay(server_info_cache: dict, dep) -> None: elif isinstance(existing_headers, builtins.dict): existing_headers.update(dep.headers) + # Env headers overlay: merge header -> environment-variable bindings. + if dep.env_headers and "remotes" in info: + for remote in info["remotes"]: + existing_env_headers = remote.get("env_headers", []) + if existing_env_headers is None: + existing_env_headers = [] + if isinstance(existing_env_headers, builtins.list): + merged_env_headers = {} + for header in existing_env_headers: + if not isinstance(header, builtins.dict): + continue + h_name = header.get("name", "") + env_name = header.get("env", "") + if h_name and env_name: + merged_env_headers[str(h_name)] = str(env_name) + for k, v in dep.env_headers.items(): + merged_env_headers[str(k)] = str(v) + remote["env_headers"] = [ + {"name": k, "env": v} for k, v in merged_env_headers.items() + ] + elif isinstance(existing_env_headers, builtins.dict): + merged_env_headers = { + str(k): str(v) for k, v in existing_env_headers.items() if k and v + } + for k, v in dep.env_headers.items(): + merged_env_headers[str(k)] = str(v) + remote["env_headers"] = [ + {"name": k, "env": v} for k, v in merged_env_headers.items() + ] + # Args overlay: merge into package runtime arguments if dep.args and "packages" in info: for pkg in info["packages"]: diff --git a/src/apm_cli/models/dependency/mcp.py b/src/apm_cli/models/dependency/mcp.py index 2c08b56b0..429b9f2f2 100644 --- a/src/apm_cli/models/dependency/mcp.py +++ b/src/apm_cli/models/dependency/mcp.py @@ -20,6 +20,7 @@ "registry", "package", "headers", + "env_headers", "tools", "url", "command", @@ -27,6 +28,7 @@ ) _NAME_REGEX = re.compile(r"^[a-zA-Z0-9@_][a-zA-Z0-9._@/:=-]{0,127}$") +_ENV_VAR_NAME_REGEX = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _ALLOWED_URL_SCHEMES = frozenset({"http", "https"}) @@ -50,6 +52,7 @@ class MCPDependency: registry: Any | None = None # None=default, False=self-defined, str=custom registry URL package: str | None = None # "npm" | "pypi" | "oci" — select package type headers: dict[str, str] | None = None # Custom HTTP headers for remote endpoints + env_headers: dict[str, str] | None = None # Header name -> environment variable name tools: list[str] | None = None # Restrict exposed tools (default is ["*"]) url: str | None = None # Required for self-defined http/sse transports command: str | None = None # Required for self-defined stdio transports @@ -91,6 +94,7 @@ def from_dict(cls, d: dict) -> "MCPDependency": registry=d.get("registry"), package=d.get("package"), headers=d.get("headers"), + env_headers=d.get("env_headers"), tools=d.get("tools"), url=d.get("url"), command=d.get("command"), @@ -124,6 +128,7 @@ def to_dict(self) -> dict: "registry", "package", "headers", + "env_headers", "tools", "url", "command", @@ -152,6 +157,9 @@ def __repr__(self) -> str: if self.headers: safe_headers = {k: "***" for k in self.headers} parts.append(f"headers={safe_headers}") + if self.env_headers: + safe_env_headers = {k: str(v) for k, v in self.env_headers.items()} + parts.append(f"env_headers={safe_env_headers!r}") if self.args is not None: parts.append("args=...") if self.tools: @@ -217,6 +225,24 @@ def validate(self, strict: bool = True) -> None: f"Invalid header '{k_str}={v_str}': control characters " f"(CR/LF) not allowed in keys or values" ) + if self.env_headers: + for k, v in self.env_headers.items(): + k_str = str(k) if k is not None else "" + v_str = str(v) if v is not None else "" + if not k_str.strip(): + raise ValueError( + f"Invalid env_header '{k_str}={v_str}': header name cannot be empty" + ) + if "\r" in k_str or "\n" in k_str or "\r" in v_str or "\n" in v_str: + raise ValueError( + f"Invalid env_header '{k_str}={v_str}': control characters " + f"(CR/LF) not allowed in keys or values" + ) + if not _ENV_VAR_NAME_REGEX.match(v_str): + raise ValueError( + f"Invalid env_header '{k_str}={v_str}': value must be an " + f"environment variable name like 'MCP_AUTH_TOKEN'" + ) if self.command is not None: if not isinstance(self.command, str): raise ValueError( diff --git a/tests/integration/test_mcp_targets_gating_e2e.py b/tests/integration/test_mcp_targets_gating_e2e.py index 7c01a7193..fdec37bd7 100644 --- a/tests/integration/test_mcp_targets_gating_e2e.py +++ b/tests/integration/test_mcp_targets_gating_e2e.py @@ -25,6 +25,7 @@ from pathlib import Path import pytest +import toml from apm_cli.integration.mcp_integrator import MCPIntegrator from apm_cli.models.dependency.mcp import MCPDependency @@ -45,6 +46,20 @@ def _make_stdio_dep(name: str = "test-srv") -> MCPDependency: ) +def _make_streamable_http_dep(name: str = "test-remote") -> MCPDependency: + """Build a self-defined remote MCP dependency that needs no network.""" + return MCPDependency.from_dict( + { + "name": name, + "registry": False, + "transport": "streamable-http", + "url": "https://example.com/mcp", + "headers": {"X-Static": "literal"}, + "env_headers": {"Authorization": "MCP_AUTH_TOKEN"}, + } + ) + + def _seed_signal(project: Path, target: str) -> None: """Seed an on-disk signal that ``detect_signals`` recognizes.""" if target == "copilot": @@ -207,3 +222,28 @@ def test_greenfield_no_targets_no_signals_no_flag_writes_nothing( "not receive a silent copilot-vscode MCP write -- the " "pre-#1336 fallback is gone." ) + + def test_codex_env_headers_write_env_http_headers(self, tmp_path): + """Codex should receive runtime env-header bindings, not baked secrets. + + This covers the full install boundary: + ``MCPDependency.env_headers`` -> ``MCPIntegrator`` remote info -> + ``CodexClientAdapter`` -> ``.codex/config.toml``. + """ + project = tmp_path / "proj-codex-env-headers" + project.mkdir() + _seed_signal(project, "codex") + + MCPIntegrator.install( + [_make_streamable_http_dep("e2e-codex-env-headers")], + project_root=project, + apm_config={"targets": ["codex"]}, + ) + + config_path = _codex_mcp_path(project) + assert config_path.exists(), "Codex MCP config must be written for the codex target" + config = toml.loads(config_path.read_text(encoding="utf-8")) + server = config["mcp_servers"]["e2e-codex-env-headers"] + assert server["url"] == "https://example.com/mcp" + assert server["http_headers"] == {"X-Static": "literal"} + assert server["env_http_headers"] == {"Authorization": "MCP_AUTH_TOKEN"} diff --git a/tests/unit/test_codex_adapter_phase3.py b/tests/unit/test_codex_adapter_phase3.py index 8c851cb9b..5a5d34f64 100644 --- a/tests/unit/test_codex_adapter_phase3.py +++ b/tests/unit/test_codex_adapter_phase3.py @@ -344,6 +344,92 @@ def test_raises_when_no_packages(self, tmp_path: Path) -> None: with pytest.raises(ValueError, match="no package information"): adapter._format_server_config(server_info) + def test_remote_env_headers_map_to_codex_env_http_headers(self, tmp_path: Path) -> None: + adapter = _make_adapter(project_root=tmp_path) + server_info = { + "name": "remote-env", + "id": "uuid-remote", + "packages": [], + "remotes": [ + { + "url": "https://example.com/mcp", + "transport_type": "http", + "headers": [{"name": "X-Static", "value": "literal"}], + "env_headers": [ + {"name": "Authorization", "env": "MCP_AUTH_TOKEN"}, + {"name": "X-Api-Key", "env": "MCP_API_KEY"}, + ], + } + ], + } + cfg = adapter._format_server_config(server_info) + assert cfg["url"] == "https://example.com/mcp" + assert cfg["http_headers"] == {"X-Static": "literal"} + assert cfg["env_http_headers"] == { + "Authorization": "MCP_AUTH_TOKEN", + "X-Api-Key": "MCP_API_KEY", + } + + def test_remote_env_headers_accept_dict_shape(self, tmp_path: Path) -> None: + adapter = _make_adapter(project_root=tmp_path) + server_info = { + "name": "remote-env-dict", + "id": "uuid-remote-dict", + "packages": [], + "remotes": [ + { + "url": "https://example.com/mcp", + "transport_type": "http", + "env_headers": { + "Authorization": "MCP_AUTH_TOKEN", + "X-Api-Key": "MCP_API_KEY", + }, + } + ], + } + cfg = adapter._format_server_config(server_info) + assert cfg["env_http_headers"] == { + "Authorization": "MCP_AUTH_TOKEN", + "X-Api-Key": "MCP_API_KEY", + } + + def test_remote_env_headers_none_is_ignored(self, tmp_path: Path) -> None: + adapter = _make_adapter(project_root=tmp_path) + server_info = { + "name": "remote-env-none", + "id": "uuid-remote-none", + "packages": [], + "remotes": [ + { + "url": "https://example.com/mcp", + "transport_type": "http", + "env_headers": None, + } + ], + } + cfg = adapter._format_server_config(server_info) + assert "env_http_headers" not in cfg + + def test_remote_env_headers_list_shape_normalizes_entries(self, tmp_path: Path) -> None: + adapter = _make_adapter(project_root=tmp_path) + server_info = { + "name": "remote-env-list-normalized", + "id": "uuid-remote-list-normalized", + "packages": [], + "remotes": [ + { + "url": "https://example.com/mcp", + "transport_type": "http", + "env_headers": [ + {"name": 123, "env": "MCP_NUMERIC_HEADER"}, + "not-a-header-entry", + ], + } + ], + } + cfg = adapter._format_server_config(server_info) + assert cfg["env_http_headers"] == {"123": "MCP_NUMERIC_HEADER"} + def test_npm_package_basic(self, tmp_path: Path) -> None: adapter = _make_adapter(project_root=tmp_path) pkg = { diff --git a/tests/unit/test_mcp_overlays.py b/tests/unit/test_mcp_overlays.py index 7ffa654e1..db9834e5c 100644 --- a/tests/unit/test_mcp_overlays.py +++ b/tests/unit/test_mcp_overlays.py @@ -44,6 +44,7 @@ def test_from_dict_full_overlay(self): "version": "1.2.3", "package": "npm", "headers": {"X-Auth": "token"}, + "env_headers": {"X-Env-Auth": "AUTH_TOKEN"}, "tools": ["read", "write"], } ) @@ -54,6 +55,7 @@ def test_from_dict_full_overlay(self): assert dep.version == "1.2.3" assert dep.package == "npm" assert dep.headers == {"X-Auth": "token"} + assert dep.env_headers == {"X-Env-Auth": "AUTH_TOKEN"} assert dep.tools == ["read", "write"] def test_from_dict_self_defined_http(self): @@ -350,6 +352,7 @@ def test_to_dict_roundtrip(self): version="2.0.0", package="npm", headers={"X-H": "v"}, + env_headers={"X-Env-H": "ENV_H"}, tools=["tool1"], url="http://example.com", command="cmd", @@ -362,6 +365,7 @@ def test_to_dict_roundtrip(self): assert d["version"] == "2.0.0" assert d["package"] == "npm" assert d["headers"] == {"X-H": "v"} + assert d["env_headers"] == {"X-Env-H": "ENV_H"} assert d["tools"] == ["tool1"] assert d["url"] == "http://example.com" assert d["command"] == "cmd" @@ -514,6 +518,38 @@ def test_headers_crlf_rejected(self, key, val): with pytest.raises(ValueError, match="control characters"): dep.validate(strict=False) + # -- Env header validation --------------------------------------------- + + def test_env_headers_normal_pass(self): + dep = MCPDependency(name="srv", env_headers={"X-Api-Key": "MCP_API_KEY"}) + dep.validate(strict=False) + + @pytest.mark.parametrize( + "key,val", + [ + ("X-Bad\rKey", "TOKEN"), + ("X-Bad\nKey", "TOKEN"), + ("X-OK", "TOKEN\rBAD"), + ("X-OK", "TOKEN\nBAD"), + ], + ) + def test_env_headers_crlf_rejected(self, key, val): + dep = MCPDependency(name="srv", env_headers={key: val}) + with pytest.raises(ValueError, match="control characters"): + dep.validate(strict=False) + + @pytest.mark.parametrize("header_name", ["", " "]) + def test_env_headers_header_name_rejected(self, header_name): + dep = MCPDependency(name="srv", env_headers={header_name: "MCP_API_KEY"}) + with pytest.raises(ValueError, match=f"Invalid env_header '{header_name}=MCP_API_KEY'"): + dep.validate(strict=False) + + @pytest.mark.parametrize("env_name", ["", "1TOKEN", "TOKEN-NAME", "TOKEN.NAME", "${TOKEN}"]) + def test_env_headers_env_name_rejected(self, env_name): + dep = MCPDependency(name="srv", env_headers={"X-Api-Key": env_name}) + with pytest.raises(ValueError, match="environment variable name"): + dep.validate(strict=False) + # -- Command path-traversal check --------------------------------------- @pytest.mark.parametrize( @@ -626,6 +662,19 @@ def test_http_with_headers(self): assert len(headers) == 1 assert headers[0] == {"name": "Authorization", "value": "Bearer token"} + def test_http_with_env_headers(self): + dep = MCPDependency( + name="env-hdr-srv", + registry=False, + transport="http", + url="http://example.com", + env_headers={"Authorization": "MCP_AUTH_TOKEN"}, + ) + result = MCPIntegrator._build_self_defined_info(dep) + env_headers = result["remotes"][0]["env_headers"] + assert len(env_headers) == 1 + assert env_headers[0] == {"name": "Authorization", "env": "MCP_AUTH_TOKEN"} + def test_stdio_with_env(self): dep = MCPDependency( name="env-srv", @@ -729,6 +778,65 @@ def test_headers_merged_into_remotes(self): assert len(headers) == 1 assert headers[0] == {"name": "X-Custom", "value": "val"} + def test_env_headers_merged_into_remotes(self): + cache = { + "srv": { + "remotes": [{"url": "http://x", "env_headers": []}], + } + } + dep = MCPDependency(name="srv", env_headers={"X-Token": "TOKEN_ENV"}) + MCPIntegrator._apply_overlay(cache, dep) + env_headers = cache["srv"]["remotes"][0]["env_headers"] + assert len(env_headers) == 1 + assert env_headers[0] == {"name": "X-Token", "env": "TOKEN_ENV"} + + def test_env_headers_overlay_treats_none_as_empty(self): + cache = { + "srv": { + "remotes": [{"url": "http://x", "env_headers": None}], + } + } + dep = MCPDependency(name="srv", env_headers={"X-Token": "TOKEN_ENV"}) + MCPIntegrator._apply_overlay(cache, dep) + env_headers = cache["srv"]["remotes"][0]["env_headers"] + assert env_headers == [{"name": "X-Token", "env": "TOKEN_ENV"}] + + def test_env_headers_overlay_replaces_existing_list_entry(self): + cache = { + "srv": { + "remotes": [ + { + "url": "http://x", + "env_headers": [{"name": "X-Token", "env": "OLD_TOKEN"}], + } + ], + } + } + dep = MCPDependency(name="srv", env_headers={"X-Token": "NEW_TOKEN"}) + MCPIntegrator._apply_overlay(cache, dep) + MCPIntegrator._apply_overlay(cache, dep) + env_headers = cache["srv"]["remotes"][0]["env_headers"] + assert env_headers == [{"name": "X-Token", "env": "NEW_TOKEN"}] + + def test_env_headers_overlay_normalizes_existing_dict_shape(self): + cache = { + "srv": { + "remotes": [ + { + "url": "http://x", + "env_headers": {"X-Existing": "OLD_ENV"}, + } + ], + } + } + dep = MCPDependency(name="srv", env_headers={"X-Token": "TOKEN_ENV"}) + MCPIntegrator._apply_overlay(cache, dep) + env_headers = cache["srv"]["remotes"][0]["env_headers"] + assert env_headers == [ + {"name": "X-Existing", "env": "OLD_ENV"}, + {"name": "X-Token", "env": "TOKEN_ENV"}, + ] + def test_tools_embedded(self): cache = {"srv": {"packages": [{"registry_name": "npm"}]}} dep = MCPDependency(name="srv", tools=["repos"])