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"])