Skip to content

Commit d96f0f0

Browse files
author
Walter Gillett
committed
Make it possible to use environment variables in mcp.json
1 parent faca9c4 commit d96f0f0

File tree

3 files changed

+163
-1
lines changed

3 files changed

+163
-1
lines changed

docs/mcp/client.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,32 @@ 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_NAME}` syntax. This is useful for keeping sensitive information like API keys or host names out of your configuration files:
193+
194+
```json {title="mcp_config_with_env.json"}
195+
{
196+
"mcpServers": {
197+
"python-runner": {
198+
"command": "${PYTHON_CMD}",
199+
"args": ["run", "${MCP_MODULE}", "stdio"],
200+
"env": {
201+
"API_KEY": "${MY_API_KEY}"
202+
}
203+
},
204+
"weather-api": {
205+
"url": "https://${SERVER_HOST}:${SERVER_PORT}/sse"
206+
}
207+
}
208+
}
209+
```
210+
211+
When loading this configuration with [`load_mcp_servers()`][pydantic_ai.mcp.load_mcp_servers], the `${VAR_NAME}` references will be replaced with the corresponding environment variable values.
212+
213+
!!! warning
214+
If a referenced environment variable is not defined, a `ValueError` will be raised. Make sure all required environment variables are set before loading the configuration.
215+
190216
### Usage
191217

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

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 41 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
@@ -927,9 +929,44 @@ class MCPServerConfig(BaseModel):
927929
]
928930

929931

932+
def _expand_env_vars(value: Any) -> Any:
933+
"""Recursively expand environment variables in a JSON structure.
934+
935+
Environment variables can be referenced using ${VAR_NAME} syntax.
936+
937+
Args:
938+
value: The value to expand (can be str, dict, list, or other JSON types).
939+
940+
Returns:
941+
The value with all environment variables expanded.
942+
943+
Raises:
944+
ValueError: If an environment variable is not defined.
945+
"""
946+
if isinstance(value, str):
947+
# Find all environment variable references in the string
948+
env_var_pattern = re.compile(r'\$\{([^}]+)\}')
949+
matches = env_var_pattern.findall(value)
950+
951+
for var_name in matches:
952+
if var_name not in os.environ:
953+
raise ValueError(f'Environment variable ${{{var_name}}} is not defined')
954+
value = value.replace(f'${{{var_name}}}', os.environ[var_name])
955+
956+
return value
957+
elif isinstance(value, dict):
958+
return {k: _expand_env_vars(v) for k, v in value.items()} # type: ignore[misc]
959+
elif isinstance(value, list):
960+
return [_expand_env_vars(item) for item in value] # type: ignore[misc]
961+
else:
962+
return value
963+
964+
930965
def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE]:
931966
"""Load MCP servers from a configuration file.
932967
968+
Environment variables can be referenced in the configuration file using ${VAR_NAME} syntax.
969+
933970
Args:
934971
config_path: The path to the configuration file.
935972
@@ -939,13 +976,16 @@ def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServer
939976
Raises:
940977
FileNotFoundError: If the configuration file does not exist.
941978
ValidationError: If the configuration file does not match the schema.
979+
ValueError: If an environment variable referenced in the configuration is not defined.
942980
"""
943981
config_path = Path(config_path)
944982

945983
if not config_path.exists():
946984
raise FileNotFoundError(f'Config file {config_path} not found')
947985

948-
config = MCPServerConfig.model_validate_json(config_path.read_bytes())
986+
config_data = pydantic_core.from_json(config_path.read_bytes())
987+
expanded_config_data = _expand_env_vars(config_data)
988+
config = MCPServerConfig.model_validate(expanded_config_data)
949989

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

tests/test_mcp.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1481,6 +1481,102 @@ def test_load_mcp_servers(tmp_path: Path):
14811481
load_mcp_servers(tmp_path / 'does_not_exist.json')
14821482

14831483

1484+
def test_load_mcp_servers_with_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1485+
"""Test environment variable expansion in config files."""
1486+
config = tmp_path / 'mcp.json'
1487+
1488+
# Test with environment variables in command
1489+
monkeypatch.setenv('PYTHON_CMD', 'python3')
1490+
monkeypatch.setenv('MCP_MODULE', 'tests.mcp_server')
1491+
config.write_text('{"mcpServers": {"my_server": {"command": "${PYTHON_CMD}", "args": ["-m", "${MCP_MODULE}"]}}}')
1492+
1493+
servers = load_mcp_servers(config)
1494+
1495+
assert len(servers) == 1
1496+
server = servers[0]
1497+
assert isinstance(server, MCPServerStdio)
1498+
assert server.command == 'python3'
1499+
assert server.args == ['-m', 'tests.mcp_server']
1500+
assert server.id == 'my_server'
1501+
assert server.tool_prefix == 'my_server'
1502+
1503+
# Test with environment variables in URL
1504+
monkeypatch.setenv('SERVER_HOST', 'example.com')
1505+
monkeypatch.setenv('SERVER_PORT', '8080')
1506+
config.write_text('{"mcpServers": {"web_server": {"url": "https://${SERVER_HOST}:${SERVER_PORT}/mcp"}}}')
1507+
1508+
servers = load_mcp_servers(config)
1509+
1510+
assert len(servers) == 1
1511+
server = servers[0]
1512+
assert isinstance(server, MCPServerStreamableHTTP)
1513+
assert server.url == 'https://example.com:8080/mcp'
1514+
1515+
# Test with environment variables in env dict
1516+
monkeypatch.setenv('API_KEY', 'secret123')
1517+
config.write_text(
1518+
'{"mcpServers": {"my_server": {"command": "python", "args": ["-m", "tests.mcp_server"], '
1519+
'"env": {"API_KEY": "${API_KEY}"}}}}'
1520+
)
1521+
1522+
servers = load_mcp_servers(config)
1523+
1524+
assert len(servers) == 1
1525+
server = servers[0]
1526+
assert isinstance(server, MCPServerStdio)
1527+
assert server.env == {'API_KEY': 'secret123'}
1528+
1529+
1530+
def test_load_mcp_servers_undefined_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1531+
"""Test that undefined environment variables raise an error."""
1532+
config = tmp_path / 'mcp.json'
1533+
1534+
# Make sure the environment variable is not set
1535+
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
1536+
1537+
config.write_text('{"mcpServers": {"my_server": {"command": "${UNDEFINED_VAR}", "args": []}}}')
1538+
1539+
with pytest.raises(ValueError, match='Environment variable \\$\\{UNDEFINED_VAR\\} is not defined'):
1540+
load_mcp_servers(config)
1541+
1542+
1543+
def test_load_mcp_servers_partial_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1544+
"""Test environment variables in partial strings."""
1545+
config = tmp_path / 'mcp.json'
1546+
1547+
monkeypatch.setenv('HOST', 'example.com')
1548+
monkeypatch.setenv('PATH_SUFFIX', 'mcp')
1549+
config.write_text('{"mcpServers": {"server": {"url": "https://${HOST}/api/${PATH_SUFFIX}"}}}')
1550+
1551+
servers = load_mcp_servers(config)
1552+
1553+
assert len(servers) == 1
1554+
server = servers[0]
1555+
assert isinstance(server, MCPServerStreamableHTTP)
1556+
assert server.url == 'https://example.com/api/mcp'
1557+
1558+
1559+
def test_load_mcp_servers_with_non_string_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1560+
"""Test that non-string primitive values (int, bool, null) in nested structures are passed through unchanged."""
1561+
config = tmp_path / 'mcp.json'
1562+
1563+
# Create a config with environment variables and extra fields containing primitives
1564+
# The extra fields will be ignored during validation but go through _expand_env_vars
1565+
monkeypatch.setenv('PYTHON_CMD', 'python')
1566+
config.write_text(
1567+
'{"mcpServers": {"my_server": {"command": "${PYTHON_CMD}", "args": ["-m", "tests.mcp_server"], '
1568+
'"metadata": {"count": 42, "enabled": true, "value": null}}}}'
1569+
)
1570+
1571+
# This should successfully expand env vars and ignore the metadata field
1572+
servers = load_mcp_servers(config)
1573+
1574+
assert len(servers) == 1
1575+
server = servers[0]
1576+
assert isinstance(server, MCPServerStdio)
1577+
assert server.command == 'python'
1578+
1579+
14841580
async def test_server_info(mcp_server: MCPServerStdio) -> None:
14851581
with pytest.raises(
14861582
AttributeError, match='The `MCPServerStdio.server_info` is only instantiated after initialization.'

0 commit comments

Comments
 (0)