Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/src/content/docs/consumer/install-mcp-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:`
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions docs/src/content/docs/reference/manifest-schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>` | OPTIONAL | `npm`, `pypi`, `oci` | Package manager type hint. |
| `headers` | `map<string, string>` | OPTIONAL | | Custom HTTP headers for remote endpoints. Same variable syntax as `env`. |
| `env_headers` | `map<string, string>` | 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<string>` | 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`. |
Expand Down Expand Up @@ -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. `<VAR>` 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.

Expand All @@ -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
```

---
Expand Down
18 changes: 18 additions & 0 deletions src/apm_cli/adapters/client/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Comment thread
dijdzv marked this conversation as resolved.
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
Comment thread
dijdzv marked this conversation as resolved.
Comment thread
dijdzv marked this conversation as resolved.
return remote_config

if not packages:
Expand Down
32 changes: 32 additions & 0 deletions src/apm_cli/integration/mcp_integrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Comment thread
dijdzv marked this conversation as resolved.
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()
]

Comment thread
dijdzv marked this conversation as resolved.
# Args overlay: merge into package runtime arguments
if dep.args and "packages" in info:
for pkg in info["packages"]:
Expand Down
26 changes: 26 additions & 0 deletions src/apm_cli/models/dependency/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@
"registry",
"package",
"headers",
"env_headers",
"tools",
"url",
"command",
}
)

_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"})


Expand All @@ -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
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -124,6 +128,7 @@ def to_dict(self) -> dict:
"registry",
"package",
"headers",
"env_headers",
"tools",
"url",
"command",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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'"
)
Comment thread
dijdzv marked this conversation as resolved.
if self.command is not None:
if not isinstance(self.command, str):
raise ValueError(
Expand Down
40 changes: 40 additions & 0 deletions tests/integration/test_mcp_targets_gating_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
from pathlib import Path

import pytest
import toml
Comment thread
dijdzv marked this conversation as resolved.

from apm_cli.integration.mcp_integrator import MCPIntegrator
from apm_cli.models.dependency.mcp import MCPDependency
Expand All @@ -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":
Expand Down Expand Up @@ -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"]},
)
Comment thread
dijdzv marked this conversation as resolved.

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"}
86 changes: 86 additions & 0 deletions tests/unit/test_codex_adapter_phase3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
Loading
Loading