Skip to content

Commit 6fe5146

Browse files
wgillettWalter GillettDouweM
authored
Support mcp.json environment variable expansion in load_mcp_servers() (#3380)
Co-authored-by: Walter Gillett <[email protected]> Co-authored-by: Douwe Maan <[email protected]>
1 parent 829e03b commit 6fe5146

File tree

3 files changed

+300
-1
lines changed

3 files changed

+300
-1
lines changed

docs/mcp/client.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,37 @@ The configuration file should be a JSON file with an `mcpServers` object contain
187187

188188
We made this decision given that the SSE transport is deprecated.
189189

190+
### Environment Variables
191+
192+
The configuration file supports environment variable expansion using the `${VAR}` and `${VAR:-default}` syntax,
193+
[like Claude Code](https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcp-json).
194+
This is useful for keeping sensitive information like API keys or host names out of your configuration files:
195+
196+
```json {title="mcp_config_with_env.json"}
197+
{
198+
"mcpServers": {
199+
"python-runner": {
200+
"command": "${PYTHON_CMD:-python3}",
201+
"args": ["run", "${MCP_MODULE}", "stdio"],
202+
"env": {
203+
"API_KEY": "${MY_API_KEY}"
204+
}
205+
},
206+
"weather-api": {
207+
"url": "https://${SERVER_HOST:-localhost}:${SERVER_PORT:-8080}/sse"
208+
}
209+
}
210+
}
211+
```
212+
213+
When loading this configuration with [`load_mcp_servers()`][pydantic_ai.mcp.load_mcp_servers]:
214+
215+
- `${VAR}` references will be replaced with the corresponding environment variable values.
216+
- `${VAR:-default}` references will use the environment variable value if set, otherwise the default value.
217+
218+
!!! warning
219+
If a referenced environment variable using `${VAR}` syntax is not defined, a `ValueError` will be raised. Use the `${VAR:-default}` syntax to provide a fallback value.
220+
190221
### Usage
191222

192223
```python {title="mcp_config_loader.py" test="skip"}

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import base64
44
import functools
5+
import os
6+
import re
57
import warnings
68
from abc import ABC, abstractmethod
79
from asyncio import Lock
@@ -51,6 +53,13 @@
5153
)
5254
)
5355

56+
# Environment variable expansion pattern
57+
# Supports both ${VAR_NAME} and ${VAR_NAME:-default} syntax
58+
# Group 1: variable name
59+
# Group 2: the ':-' separator (to detect if default syntax is used)
60+
# Group 3: the default value (can be empty)
61+
_ENV_VAR_PATTERN = re.compile(r'\$\{([^}:]+)(:-([^}]*))?\}')
62+
5463

5564
class MCPServer(AbstractToolset[Any], ABC):
5665
"""Base class for attaching agents to MCP servers.
@@ -927,9 +936,57 @@ class MCPServerConfig(BaseModel):
927936
]
928937

929938

939+
def _expand_env_vars(value: Any) -> Any:
940+
"""Recursively expand environment variables in a JSON structure.
941+
942+
Environment variables can be referenced using `${VAR_NAME}` syntax,
943+
or `${VAR_NAME:-default}` syntax to provide a default value if the variable is not set.
944+
945+
Args:
946+
value: The value to expand (can be str, dict, list, or other JSON types).
947+
948+
Returns:
949+
The value with all environment variables expanded.
950+
951+
Raises:
952+
ValueError: If an environment variable is not defined and no default value is provided.
953+
"""
954+
if isinstance(value, str):
955+
# Find all environment variable references in the string
956+
# Supports both ${VAR_NAME} and ${VAR_NAME:-default} syntax
957+
def replace_match(match: re.Match[str]) -> str:
958+
var_name = match.group(1)
959+
has_default = match.group(2) is not None
960+
default_value = match.group(3) if has_default else None
961+
962+
# Check if variable exists in environment
963+
if var_name in os.environ:
964+
return os.environ[var_name]
965+
elif has_default:
966+
# Use default value if the :- syntax was present (even if empty string)
967+
return default_value or ''
968+
else:
969+
# No default value and variable not set - raise error
970+
raise ValueError(f'Environment variable ${{{var_name}}} is not defined')
971+
972+
value = _ENV_VAR_PATTERN.sub(replace_match, value)
973+
974+
return value
975+
elif isinstance(value, dict):
976+
return {k: _expand_env_vars(v) for k, v in value.items()} # type: ignore[misc]
977+
elif isinstance(value, list):
978+
return [_expand_env_vars(item) for item in value] # type: ignore[misc]
979+
else:
980+
return value
981+
982+
930983
def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE]:
931984
"""Load MCP servers from a configuration file.
932985
986+
Environment variables can be referenced in the configuration file using:
987+
- `${VAR_NAME}` syntax - expands to the value of VAR_NAME, raises error if not defined
988+
- `${VAR_NAME:-default}` syntax - expands to VAR_NAME if set, otherwise uses the default value
989+
933990
Args:
934991
config_path: The path to the configuration file.
935992
@@ -939,13 +996,16 @@ def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServer
939996
Raises:
940997
FileNotFoundError: If the configuration file does not exist.
941998
ValidationError: If the configuration file does not match the schema.
999+
ValueError: If an environment variable referenced in the configuration is not defined and no default value is provided.
9421000
"""
9431001
config_path = Path(config_path)
9441002

9451003
if not config_path.exists():
9461004
raise FileNotFoundError(f'Config file {config_path} not found')
9471005

948-
config = MCPServerConfig.model_validate_json(config_path.read_bytes())
1006+
config_data = pydantic_core.from_json(config_path.read_bytes())
1007+
expanded_config_data = _expand_env_vars(config_data)
1008+
config = MCPServerConfig.model_validate(expanded_config_data)
9491009

9501010
servers: list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE] = []
9511011
for name, server in config.mcp_servers.items():

tests/test_mcp.py

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,6 +1539,214 @@ def test_load_mcp_servers(tmp_path: Path):
15391539
load_mcp_servers(tmp_path / 'does_not_exist.json')
15401540

15411541

1542+
def test_load_mcp_servers_with_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1543+
"""Test environment variable expansion in config files."""
1544+
config = tmp_path / 'mcp.json'
1545+
1546+
# Test with environment variables in command
1547+
monkeypatch.setenv('PYTHON_CMD', 'python3')
1548+
monkeypatch.setenv('MCP_MODULE', 'tests.mcp_server')
1549+
config.write_text('{"mcpServers": {"my_server": {"command": "${PYTHON_CMD}", "args": ["-m", "${MCP_MODULE}"]}}}')
1550+
1551+
servers = load_mcp_servers(config)
1552+
1553+
assert len(servers) == 1
1554+
server = servers[0]
1555+
assert isinstance(server, MCPServerStdio)
1556+
assert server.command == 'python3'
1557+
assert server.args == ['-m', 'tests.mcp_server']
1558+
assert server.id == 'my_server'
1559+
assert server.tool_prefix == 'my_server'
1560+
1561+
1562+
def test_load_mcp_servers_env_var_in_env_dict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1563+
"""Test environment variable expansion in env dict."""
1564+
config = tmp_path / 'mcp.json'
1565+
1566+
# Test with environment variables in env dict
1567+
monkeypatch.setenv('API_KEY', 'secret123')
1568+
config.write_text(
1569+
'{"mcpServers": {"my_server": {"command": "python", "args": ["-m", "tests.mcp_server"], '
1570+
'"env": {"API_KEY": "${API_KEY}"}}}}'
1571+
)
1572+
1573+
servers = load_mcp_servers(config)
1574+
1575+
assert len(servers) == 1
1576+
server = servers[0]
1577+
assert isinstance(server, MCPServerStdio)
1578+
assert server.env == {'API_KEY': 'secret123'}
1579+
1580+
1581+
def test_load_mcp_servers_env_var_expansion_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1582+
"""Test environment variable expansion in URL."""
1583+
config = tmp_path / 'mcp.json'
1584+
1585+
# Test with environment variables in URL
1586+
monkeypatch.setenv('SERVER_HOST', 'example.com')
1587+
monkeypatch.setenv('SERVER_PORT', '8080')
1588+
config.write_text('{"mcpServers": {"web_server": {"url": "https://${SERVER_HOST}:${SERVER_PORT}/mcp"}}}')
1589+
1590+
servers = load_mcp_servers(config)
1591+
1592+
assert len(servers) == 1
1593+
server = servers[0]
1594+
assert isinstance(server, MCPServerStreamableHTTP)
1595+
assert server.url == 'https://example.com:8080/mcp'
1596+
1597+
1598+
def test_load_mcp_servers_undefined_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1599+
"""Test that undefined environment variables raise an error."""
1600+
config = tmp_path / 'mcp.json'
1601+
1602+
# Make sure the environment variable is not set
1603+
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
1604+
1605+
config.write_text('{"mcpServers": {"my_server": {"command": "${UNDEFINED_VAR}", "args": []}}}')
1606+
1607+
with pytest.raises(ValueError, match='Environment variable \\$\\{UNDEFINED_VAR\\} is not defined'):
1608+
load_mcp_servers(config)
1609+
1610+
1611+
def test_load_mcp_servers_partial_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1612+
"""Test environment variables in partial strings."""
1613+
config = tmp_path / 'mcp.json'
1614+
1615+
monkeypatch.setenv('HOST', 'example.com')
1616+
monkeypatch.setenv('PATH_SUFFIX', 'mcp')
1617+
config.write_text('{"mcpServers": {"server": {"url": "https://${HOST}/api/${PATH_SUFFIX}"}}}')
1618+
1619+
servers = load_mcp_servers(config)
1620+
1621+
assert len(servers) == 1
1622+
server = servers[0]
1623+
assert isinstance(server, MCPServerStreamableHTTP)
1624+
assert server.url == 'https://example.com/api/mcp'
1625+
1626+
1627+
def test_load_mcp_servers_with_non_string_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1628+
"""Test that non-string primitive values (int, bool, null) in nested structures are passed through unchanged."""
1629+
config = tmp_path / 'mcp.json'
1630+
1631+
# Create a config with environment variables and extra fields containing primitives
1632+
# The extra fields will be ignored during validation but go through _expand_env_vars
1633+
monkeypatch.setenv('PYTHON_CMD', 'python')
1634+
config.write_text(
1635+
'{"mcpServers": {"my_server": {"command": "${PYTHON_CMD}", "args": ["-m", "tests.mcp_server"], '
1636+
'"metadata": {"count": 42, "enabled": true, "value": null}}}}'
1637+
)
1638+
1639+
# This should successfully expand env vars and ignore the metadata field
1640+
servers = load_mcp_servers(config)
1641+
1642+
assert len(servers) == 1
1643+
server = servers[0]
1644+
assert isinstance(server, MCPServerStdio)
1645+
assert server.command == 'python'
1646+
1647+
1648+
def test_load_mcp_servers_with_default_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1649+
"""Test ${VAR:-default} syntax for environment variable expansion."""
1650+
config = tmp_path / 'mcp.json'
1651+
1652+
# Test with undefined variable using default
1653+
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
1654+
config.write_text('{"mcpServers": {"server": {"command": "${UNDEFINED_VAR:-python3}", "args": []}}}')
1655+
1656+
servers = load_mcp_servers(config)
1657+
assert len(servers) == 1
1658+
server = servers[0]
1659+
assert isinstance(server, MCPServerStdio)
1660+
assert server.command == 'python3'
1661+
1662+
# Test with defined variable (should use actual value, not default)
1663+
monkeypatch.setenv('DEFINED_VAR', 'actual_value')
1664+
config.write_text('{"mcpServers": {"server": {"command": "${DEFINED_VAR:-default_value}", "args": []}}}')
1665+
1666+
servers = load_mcp_servers(config)
1667+
assert len(servers) == 1
1668+
server = servers[0]
1669+
assert isinstance(server, MCPServerStdio)
1670+
assert server.command == 'actual_value'
1671+
1672+
# Test with empty string as default
1673+
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
1674+
config.write_text('{"mcpServers": {"server": {"command": "${UNDEFINED_VAR:-}", "args": []}}}')
1675+
1676+
servers = load_mcp_servers(config)
1677+
assert len(servers) == 1
1678+
server = servers[0]
1679+
assert isinstance(server, MCPServerStdio)
1680+
assert server.command == ''
1681+
1682+
1683+
def test_load_mcp_servers_with_default_values_in_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1684+
"""Test ${VAR:-default} syntax in URLs."""
1685+
config = tmp_path / 'mcp.json'
1686+
1687+
# Test with default values in URL
1688+
monkeypatch.delenv('HOST', raising=False)
1689+
monkeypatch.setenv('PROTOCOL', 'https')
1690+
config.write_text('{"mcpServers": {"server": {"url": "${PROTOCOL:-http}://${HOST:-localhost}:${PORT:-8080}/mcp"}}}')
1691+
1692+
servers = load_mcp_servers(config)
1693+
assert len(servers) == 1
1694+
server = servers[0]
1695+
assert isinstance(server, MCPServerStreamableHTTP)
1696+
assert server.url == 'https://localhost:8080/mcp'
1697+
1698+
1699+
def test_load_mcp_servers_with_default_values_in_env_dict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1700+
"""Test ${VAR:-default} syntax in env dictionary."""
1701+
config = tmp_path / 'mcp.json'
1702+
1703+
monkeypatch.delenv('API_KEY', raising=False)
1704+
monkeypatch.setenv('CUSTOM_VAR', 'custom_value')
1705+
config.write_text(
1706+
'{"mcpServers": {"server": {"command": "python", "args": [], '
1707+
'"env": {"API_KEY": "${API_KEY:-default_key}", "CUSTOM": "${CUSTOM_VAR:-fallback}"}}}}'
1708+
)
1709+
1710+
servers = load_mcp_servers(config)
1711+
assert len(servers) == 1
1712+
server = servers[0]
1713+
assert isinstance(server, MCPServerStdio)
1714+
assert server.env == {'API_KEY': 'default_key', 'CUSTOM': 'custom_value'}
1715+
1716+
1717+
def test_load_mcp_servers_with_complex_default_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1718+
"""Test ${VAR:-default} syntax with special characters in default."""
1719+
config = tmp_path / 'mcp.json'
1720+
1721+
monkeypatch.delenv('PATH_VAR', raising=False)
1722+
# Test default with slashes, dots, and dashes
1723+
config.write_text('{"mcpServers": {"server": {"command": "${PATH_VAR:-/usr/local/bin/python-3.10}", "args": []}}}')
1724+
1725+
servers = load_mcp_servers(config)
1726+
assert len(servers) == 1
1727+
server = servers[0]
1728+
assert isinstance(server, MCPServerStdio)
1729+
assert server.command == '/usr/local/bin/python-3.10'
1730+
1731+
1732+
def test_load_mcp_servers_with_mixed_syntax(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1733+
"""Test mixing ${VAR} and ${VAR:-default} syntax in the same config."""
1734+
config = tmp_path / 'mcp.json'
1735+
1736+
monkeypatch.setenv('REQUIRED_VAR', 'required_value')
1737+
monkeypatch.delenv('OPTIONAL_VAR', raising=False)
1738+
config.write_text(
1739+
'{"mcpServers": {"server": {"command": "${REQUIRED_VAR}", "args": ["${OPTIONAL_VAR:-default_arg}"]}}}'
1740+
)
1741+
1742+
servers = load_mcp_servers(config)
1743+
assert len(servers) == 1
1744+
server = servers[0]
1745+
assert isinstance(server, MCPServerStdio)
1746+
assert server.command == 'required_value'
1747+
assert server.args == ['default_arg']
1748+
1749+
15421750
async def test_server_info(mcp_server: MCPServerStdio) -> None:
15431751
with pytest.raises(
15441752
AttributeError, match='The `MCPServerStdio.server_info` is only instantiated after initialization.'

0 commit comments

Comments
 (0)