Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ mcp-servers:
- name: "context-server"
transport: "http"
url: "http://localhost:8000/mcp"
# ${VAR} in a header is stored as-is and expanded by Claude Code at runtime,
# so the token stays in your environment, never in the config file.
header: "Authorization: Bearer ${CONTEXT_SERVER_TOKEN}"

model: "sonnet"
effort-level: "high"
Expand Down
5 changes: 4 additions & 1 deletion docs/environment-configuration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,9 +524,12 @@ mcp-servers:
transport: "http"
url: "http://localhost:3000/api"
header: "Authorization: Bearer ${MY_TOKEN}"
env: "MY_TOKEN"
```

**Environment-variable header values (`${VAR}`):** A `${VAR}` (or `${VAR:-default}`) reference inside `header` is preserved literally in the Claude Code configuration and expanded from the environment by Claude Code **at runtime**, when a session starts -- the secret itself is never written into any configuration file, only the placeholder is stored. The setup script preserves the placeholder verbatim when it registers the server (it does not expand it at install time), so the behavior is identical on every operating system and shell. This is the recommended way to configure an authenticated remote MCP server: each user sets the variable (for example `MY_TOKEN`) as a real environment variable on their own machine, and only this placeholder configuration is shared. Do not add `env: "MY_TOKEN"` for this purpose -- `env` configures variables passed to a server and is unrelated to where the header reads its value.

> **The variable must be set when Claude Code launches.** If a `${VAR}` reference has no value and no default, Claude Code fails to parse the MCP configuration. Ensure the variable is exported before launching Claude Code, or provide a fallback with `${VAR:-default}`.

#### SSE Transport

Uses the same fields as HTTP transport with `transport: "sse"`.
Expand Down
23 changes: 18 additions & 5 deletions scripts/setup_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -8833,9 +8833,15 @@ def configure_mcp_server(
path_preview = explicit_path[:200] + '...' if len(explicit_path) > 200 else explicit_path
debug_log(f'unix_explicit_path: {path_preview}')

env_flags = ' '.join(f'--env "{e}"' for e in env_list) if env_list else ''
# Single-quote --env/--header (via shlex.quote) so a ${VAR} placeholder is
# passed to `claude mcp add` literally and stored verbatim in the config;
# Claude Code expands it from the environment at runtime. Double-quoting
# would let Git Bash expand ${VAR} at setup time, baking the resolved value
# (or an empty string when the variable is unset) into the config instead.
# This matches the Unix branch and escapes any embedded special characters.
env_flags = ' '.join(f'--env {shlex.quote(e)}' for e in env_list) if env_list else ''
env_part = f' {env_flags}' if env_flags else ''
header_part = f' --header "{header}"' if header else ''
header_part = f' --header {shlex.quote(header)}' if header else ''

bash_cmd = (
f'"{env.unix_claude_cmd}" mcp add --scope {scope}{env_part} '
Expand All @@ -8859,8 +8865,11 @@ def configure_mcp_server(
parent_dir = Path(claude_cmd).parent
env_flags = ' '.join(f'--env {shlex.quote(e)}' for e in env_list) if env_list else ''
env_part = f' {env_flags}' if env_flags else ''
# Use double quotes for header to allow ${VAR} expansion in bash
header_part = f' --header "{header}"' if header else ''
# Single-quote the header (via shlex.quote) so a ${VAR} placeholder is
# passed to `claude mcp add` literally and stored verbatim; Claude Code
# expands it at runtime. Double-quoting would let bash expand ${VAR} at
# setup time, baking the resolved value (or an empty string) into the config.
header_part = f' --header {shlex.quote(header)}' if header else ''
bash_cmd = (
f'{shlex.quote(str(claude_cmd))} mcp add --scope {shlex.quote(scope)}{env_part} '
f'--transport {shlex.quote(transport)} {shlex.quote(name)} {shlex.quote(url)}{header_part}'
Expand Down Expand Up @@ -8905,7 +8914,11 @@ def configure_mcp_server(
path_preview = explicit_path[:200] + '...' if len(explicit_path) > 200 else explicit_path
debug_log(f'unix_explicit_path: {path_preview}')

env_flags = ' '.join(f'--env "{e}"' for e in env_list) if env_list else ''
# Single-quote --env (via shlex.quote) so a ${VAR} placeholder in an env
# value is passed literally and expanded by Claude Code at runtime, matching
# the Unix stdio path which passes --env as argv. Double-quoting would let
# Git Bash expand ${VAR} at setup time.
env_flags = ' '.join(f'--env {shlex.quote(e)}' for e in env_list) if env_list else ''
env_part = f' {env_flags}' if env_flags else ''

# Build command string for STDIO
Expand Down
152 changes: 132 additions & 20 deletions tests/e2e/test_mcp_argument_ordering.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,16 @@

from __future__ import annotations

import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import patch

import pytest

from scripts import setup_environment
from scripts.setup_environment import configure_all_mcp_servers

Expand All @@ -39,6 +43,16 @@ def _find_mcp_add_call(call_args_list: list[Any], server_name: str) -> str | Non
return None


# Whether a usable bash is unavailable on this host, evaluated once at collection time.
# The real-shell MCP test needs Git Bash on Windows (run_bash_command) or native bash on
# Unix; skip it cleanly when neither is present rather than failing.
_BASH_UNAVAILABLE: bool = (
setup_environment.find_bash_windows() is None
if sys.platform == 'win32'
else shutil.which('bash') is None
)


class TestMCPArgumentOrdering:
"""E2E tests for CLI argument ordering in MCP server configuration."""

Expand Down Expand Up @@ -303,27 +317,35 @@ def test_combined_scope_dispatches_user_and_profile(
)


class TestMCPHeaderEnvVarExpansion:
"""E2E tests for header environment variable expansion using double quotes."""
class TestMCPHeaderEnvVarPlaceholder:
"""E2E tests: a ${VAR} header placeholder is preserved literally for runtime expansion.

The setup script must NOT expand ${VAR} at install time. It single-quotes the header so
`claude mcp add` stores the literal placeholder; Claude Code expands it from the
environment when a session starts. These tests cover both the generated command string
and -- on the real platform shell -- the value that actually reaches `claude mcp add`.
"""

def test_http_header_with_env_var_uses_double_quotes_unix(
def test_http_header_env_var_placeholder_single_quoted_unix(
self,
e2e_isolated_home: dict[str, Path],
golden_config: dict[str, Any],
) -> None:
"""Verify Unix HTTP transport wraps header in double quotes for ${VAR} expansion.

Single quotes (from shlex.quote) prevent bash variable expansion.
Header values containing ${VAR} patterns must use double quotes so
bash resolves environment variables at runtime.
"""
"""The generated Unix bash command single-quotes a ${VAR} header (no setup-time expansion)."""
mock_bash = MagicMock(
return_value=subprocess.CompletedProcess([], 0, '', ''),
)
mock_run = MagicMock(
return_value=subprocess.CompletedProcess([], 0, '', ''),
)

server = {
'name': 'e2e-envheader-server',
'scope': 'user',
'transport': 'http',
'url': 'https://api.example.com/mcp',
'header': 'Authorization: Bearer ${E2E_YT_TOKEN}',
}

with (
patch('platform.system', return_value='Linux'),
patch.object(
Expand All @@ -335,23 +357,113 @@ def test_http_header_with_env_var_uses_double_quotes_unix(
):
profile_mcp_path = e2e_isolated_home['claude_dir'] / 'mcp.json'
configure_all_mcp_servers(
servers=golden_config.get('mcp-servers', []),
servers=[server],
profile_mcp_config_path=profile_mcp_path,
)

# e2e-http-server has header "X-API-Key: test-key"
bash_cmd = _find_mcp_add_call(mock_bash.call_args_list, 'e2e-http-server')
bash_cmd = _find_mcp_add_call(mock_bash.call_args_list, 'e2e-envheader-server')
assert bash_cmd is not None, (
'No mcp add command found for e2e-http-server in run_bash_command calls'
'No mcp add command found for e2e-envheader-server in run_bash_command calls'
)

# Header must be wrapped in double quotes (not single quotes)
assert '--header "' in bash_cmd, (
f'Header must use double quotes for ${{VAR}} expansion, got: {bash_cmd}'
# The placeholder must be single-quoted (literal), never double-quoted (which would
# let bash expand ${E2E_YT_TOKEN} at setup time instead of preserving it for Claude).
assert "--header 'Authorization: Bearer ${E2E_YT_TOKEN}'" in bash_cmd, (
f'Header must be single-quoted to preserve the placeholder, got: {bash_cmd}'
)
assert '--header "' not in bash_cmd, (
f'Header must NOT be double-quoted (would expand ${{VAR}} at setup time), got: {bash_cmd}'
)
# Verify single quotes are NOT used around the header value
assert "--header '" not in bash_cmd, (
'Header must NOT use single quotes (blocks ${VAR} expansion)'
assert '${E2E_YT_TOKEN}' in bash_cmd, (
'The literal ${VAR} placeholder must be present in the generated command'
)

@pytest.mark.skipif(_BASH_UNAVAILABLE, reason='bash/Git Bash not available on this host')
def test_http_header_env_var_placeholder_survives_real_shell(
self,
e2e_isolated_home: dict[str, Path],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Real cross-OS: the literal ${VAR} placeholder reaches `claude mcp add` through the actual shell.

Drives configure_mcp_server end-to-end through the real run_bash_command on whatever
OS the test runs (native bash on Linux/macOS, Git Bash on Windows). A fake `claude`
records the exact argv it receives. With the auth env var SET to a sentinel at setup
time, the recorded --header must still be the literal ${VAR} placeholder -- proving the
installer shell does not expand it (Claude Code expands it later, at session start).
"""
# A fake `claude` that records the exact argv of every invocation, then succeeds.
local_bin = e2e_isolated_home['local_bin']
fake_claude = local_bin / 'claude'
fake_claude.write_text(
'#!/usr/bin/env bash\n'
'for arg in "$@"; do printf "%s\\n" "$arg" >> "$CLAUDE_E2E_RECORD"; done\n'
'printf "INVOCATION_END\\n" >> "$CLAUDE_E2E_RECORD"\n'
'exit 0\n',
encoding='utf-8',
)
fake_claude.chmod(0o755)

record_path = e2e_isolated_home['home'] / 'mcp_add_record.txt'
# Git Bash needs a POSIX-style path for the redirection target.
if sys.platform == 'win32':
record_env_value = setup_environment.convert_to_unix_path(str(record_path))
else:
record_env_value = str(record_path)
monkeypatch.setenv('CLAUDE_E2E_RECORD', record_env_value)

# Set the auth variable at SETUP time. If the installer wrongly expanded ${VAR}
# (the old double-quote bug), this sentinel would leak into the recorded header.
monkeypatch.setenv('E2E_YT_TOKEN', 'LEAKED_AT_SETUP')

# Point find_command('claude') at the fake recorder, overriding the autouse mock
# (which returns None). Other command lookups pass through.
original_find = setup_environment.find_command

def fake_find(cmd: str) -> str | None:
if cmd == 'claude':
return str(fake_claude)
return original_find(cmd)

monkeypatch.setattr(setup_environment, 'find_command', fake_find)

server = {
'name': 'e2e-envheader-server',
'scope': 'user',
'transport': 'http',
'url': 'https://api.example.com/mcp',
'header': 'Authorization: Bearer ${E2E_YT_TOKEN}',
}

# No platform.system / run_bash_command mocking: exercise the real OS shell.
result = setup_environment.configure_mcp_server(server)
assert result is True, (
'configure_mcp_server should succeed with the fake claude recorder'
)

assert record_path.exists(), (
f'Fake claude recorded nothing at {record_path}; the add command did not run'
)
recorded = record_path.read_text(encoding='utf-8').splitlines()

# `claude mcp add ... --header <value>` -> --header and its value are separate argv
# elements, so the value is the line immediately after the --header line.
header_value = None
for i, line in enumerate(recorded):
if line == '--header' and i + 1 < len(recorded):
header_value = recorded[i + 1]
break

assert header_value is not None, (
f'No --header argument reached claude. Recorded argv: {recorded}'
)
assert header_value == 'Authorization: Bearer ${E2E_YT_TOKEN}', (
'The real shell must pass the literal ${VAR} placeholder to claude, '
f'got: {header_value!r}'
)
assert 'LEAKED_AT_SETUP' not in header_value, (
'The setup-time value of E2E_YT_TOKEN must NOT leak into the stored header '
'(it must be expanded by Claude Code at runtime, not by the installer shell)'
)


Expand Down
91 changes: 66 additions & 25 deletions tests/test_script_size_limits.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
"""Tests enforcing script size limits to prevent E2BIG regressions.
"""Tests guarding against E2BIG ('Argument list too long') regressions in the installer.

The Linux kernel's MAX_ARG_STRLEN limit (131,072 bytes / 128 KiB) causes
'Argument list too long' (E2BIG) errors when scripts are passed as single
execve() arguments. Bootstrap scripts that download-to-file avoid this,
but monitoring ensures the limit is never silently exceeded.
The Linux kernel's MAX_ARG_STRLEN limit (131,072 bytes / 128 KiB) causes E2BIG when a
script is passed as a single execve() argument -- for example when piped via stdin and run
as `python -c "<entire script>"`. The bootstrap scripts avoid this by downloading
install_claude.py to a file and running `uv run <file>`, which is safe at any size.

Two guards live here:
- The real invariant: every Linux/macOS bootstrap script materializes install_claude.py to
a file (curl -o ...) and never pipes it inline into an interpreter. This holds at any
script size, so it is the authoritative protection -- not a byte ceiling.
- A size warning: install_claude.py size is monitored and warns past a comfortable
threshold, since smaller scripts download faster and keep headroom under the kernel limit.

See: https://github.com/astral-sh/uv/issues/11220
"""

import re
import warnings
from pathlib import Path

Expand All @@ -16,33 +24,66 @@
SCRIPTS_DIR = Path(__file__).parent.parent / 'scripts'
INSTALL_CLAUDE = SCRIPTS_DIR / 'install_claude.py'

# Linux kernel MAX_ARG_STRLEN = 131,072 bytes (128 KiB)
# Warn threshold: 100 KiB (provides 28 KiB headroom)
# Error threshold: 120 KiB (provides 8 KiB headroom)
WARN_THRESHOLD_BYTES = 100 * 1024 # 102,400
ERROR_THRESHOLD_BYTES = 120 * 1024 # 122,880
# Linux kernel MAX_ARG_STRLEN = 131,072 bytes (128 KiB). It binds only when a script is
# passed inline as an execve() argument; the download-to-file bootstrap avoids it entirely.
MAX_ARG_STRLEN = 128 * 1024 # 131,072
# Warn threshold: 100 KiB (smaller scripts download faster and keep headroom).
WARN_THRESHOLD_BYTES = 100 * 1024 # 102,400

# Bootstrap shell scripts that fetch and use install_claude.py. On Linux/macOS each must
# download it to a FILE and run that file, never pipe the script content into an interpreter
# (which would make it one execve() argument and risk E2BIG). This invariant holds at any
# script size, so it -- not a byte ceiling -- is the meaningful regression guard.
BOOTSTRAP_SHELL_SCRIPTS = (
SCRIPTS_DIR / 'linux' / 'install-claude-linux.sh',
SCRIPTS_DIR / 'macos' / 'install-claude-macos.sh',
SCRIPTS_DIR / 'linux' / 'setup-environment.sh',
SCRIPTS_DIR / 'macos' / 'setup-environment.sh',
)

class TestScriptSizeLimits:
"""Enforce script size limits to prevent E2BIG regressions on Linux."""

@pytest.mark.xfail(
reason='install_claude.py is currently ~157 KiB, exceeding 120 KiB limit. '
'Bootstrap scripts now use download-to-file, but size should be reduced.',
strict=False,
def _strip_comments(shell_source: str) -> str:
"""Drop full-line shell comments so explanatory text (e.g. a 'python -c' example) is ignored."""
return '\n'.join(
line for line in shell_source.splitlines() if not line.lstrip().startswith('#')
)
def test_install_claude_below_error_threshold(self) -> None:
"""install_claude.py MUST stay below 120 KiB to maintain Linux compatibility."""
size = INSTALL_CLAUDE.stat().st_size
assert size < ERROR_THRESHOLD_BYTES, (
f'install_claude.py is {size:,} bytes ({size / 1024:.1f} KiB), '
f'which exceeds the error threshold of {ERROR_THRESHOLD_BYTES:,} bytes '
f'({ERROR_THRESHOLD_BYTES / 1024:.0f} KiB). '
f'The Linux kernel MAX_ARG_STRLEN limit is {MAX_ARG_STRLEN:,} bytes (128 KiB). '
f'Reduce the script size to prevent E2BIG errors on Linux/WSL.'


class TestInstallClaudeNotPipedInline:
"""The installer must reach the interpreter as a file, never as an inline execve() argument."""

@pytest.mark.parametrize(
'script_path',
BOOTSTRAP_SHELL_SCRIPTS,
ids=lambda p: f'{p.parent.name}/{p.name}',
)
def test_bootstrap_downloads_install_claude_to_file(self, script_path: Path) -> None:
"""Each bootstrap downloads install_claude.py to a file and never pipes it into an interpreter.

Passing the script inline as a single execve() argument is what risks E2BIG on Linux
(MAX_ARG_STRLEN). Downloading to a file and running `uv run <file>` avoids that at any
size, so this is the invariant worth guarding instead of a byte ceiling.
"""
assert script_path.exists(), f'Bootstrap script missing: {script_path}'
code = _strip_comments(script_path.read_text(encoding='utf-8'))

# install_claude.py must be written to a file via `curl ... -o <path>install_claude.py`.
assert re.search(r'-o\s+\S*install_claude\.py', code), (
f'{script_path} must download install_claude.py to a file '
'(curl -o ...install_claude.py) rather than piping it inline, to avoid E2BIG on Linux.'
)

# It must never pipe a fetched script straight into an interpreter (the inline
# anti-pattern that passes the whole script as one execve() argument).
assert not re.search(r'\|\s*(?:uv run|python[0-9.]*)\b', code), (
f'{script_path} must not pipe a downloaded script into uv/python; that risks E2BIG. '
'Download to a file and run `uv run <file>` instead.'
)


class TestScriptSizeLimits:
"""Monitor install_claude.py size (informational; the download-to-file guard is authoritative)."""

def test_install_claude_below_warn_threshold(self) -> None:
"""install_claude.py SHOULD stay below 100 KiB for comfortable headroom."""
size = INSTALL_CLAUDE.stat().st_size
Expand Down
Loading
Loading