Skip to content
26 changes: 26 additions & 0 deletions docs/mcp/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ The configuration file should be a JSON file with an `mcpServers` object contain

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

### Environment Variables

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:

```json {title="mcp_config_with_env.json"}
{
"mcpServers": {
"python-runner": {
"command": "${PYTHON_CMD}",
"args": ["run", "${MCP_MODULE}", "stdio"],
"env": {
"API_KEY": "${MY_API_KEY}"
}
},
"weather-api": {
"url": "https://${SERVER_HOST}:${SERVER_PORT}/sse"
}
}
}
```

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.

!!! warning
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.

### Usage

```python {title="mcp_config_loader.py" test="skip"}
Expand Down
42 changes: 41 additions & 1 deletion pydantic_ai_slim/pydantic_ai/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

import base64
import functools
import os
import re
import warnings
from abc import ABC, abstractmethod
from asyncio import Lock
Expand Down Expand Up @@ -927,9 +929,44 @@ class MCPServerConfig(BaseModel):
]


def _expand_env_vars(value: Any) -> Any:
"""Recursively expand environment variables in a JSON structure.

Environment variables can be referenced using ${VAR_NAME} syntax.

Args:
value: The value to expand (can be str, dict, list, or other JSON types).

Returns:
The value with all environment variables expanded.

Raises:
ValueError: If an environment variable is not defined.
"""
if isinstance(value, str):
# Find all environment variable references in the string
env_var_pattern = re.compile(r'\$\{([^}]+)\}')
matches = env_var_pattern.findall(value)

for var_name in matches:
if var_name not in os.environ:
raise ValueError(f'Environment variable ${{{var_name}}} is not defined')
value = value.replace(f'${{{var_name}}}', os.environ[var_name])

return value
elif isinstance(value, dict):
return {k: _expand_env_vars(v) for k, v in value.items()} # type: ignore[misc]
elif isinstance(value, list):
return [_expand_env_vars(item) for item in value] # type: ignore[misc]
else:
return value


def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE]:
"""Load MCP servers from a configuration file.

Environment variables can be referenced in the configuration file using ${VAR_NAME} syntax.

Args:
config_path: The path to the configuration file.

Expand All @@ -939,13 +976,16 @@ def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServer
Raises:
FileNotFoundError: If the configuration file does not exist.
ValidationError: If the configuration file does not match the schema.
ValueError: If an environment variable referenced in the configuration is not defined.
"""
config_path = Path(config_path)

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

config = MCPServerConfig.model_validate_json(config_path.read_bytes())
config_data = pydantic_core.from_json(config_path.read_bytes())
expanded_config_data = _expand_env_vars(config_data)
config = MCPServerConfig.model_validate(expanded_config_data)

servers: list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE] = []
for name, server in config.mcp_servers.items():
Expand Down
96 changes: 96 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -1481,6 +1481,102 @@ def test_load_mcp_servers(tmp_path: Path):
load_mcp_servers(tmp_path / 'does_not_exist.json')


def test_load_mcp_servers_with_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test environment variable expansion in config files."""
config = tmp_path / 'mcp.json'

# Test with environment variables in command
monkeypatch.setenv('PYTHON_CMD', 'python3')
monkeypatch.setenv('MCP_MODULE', 'tests.mcp_server')
config.write_text('{"mcpServers": {"my_server": {"command": "${PYTHON_CMD}", "args": ["-m", "${MCP_MODULE}"]}}}')

servers = load_mcp_servers(config)

assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStdio)
assert server.command == 'python3'
assert server.args == ['-m', 'tests.mcp_server']
assert server.id == 'my_server'
assert server.tool_prefix == 'my_server'

# Test with environment variables in URL
monkeypatch.setenv('SERVER_HOST', 'example.com')
monkeypatch.setenv('SERVER_PORT', '8080')
config.write_text('{"mcpServers": {"web_server": {"url": "https://${SERVER_HOST}:${SERVER_PORT}/mcp"}}}')

servers = load_mcp_servers(config)

assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStreamableHTTP)
assert server.url == 'https://example.com:8080/mcp'

# Test with environment variables in env dict
monkeypatch.setenv('API_KEY', 'secret123')
config.write_text(
'{"mcpServers": {"my_server": {"command": "python", "args": ["-m", "tests.mcp_server"], '
'"env": {"API_KEY": "${API_KEY}"}}}}'
)

servers = load_mcp_servers(config)

assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStdio)
assert server.env == {'API_KEY': 'secret123'}


def test_load_mcp_servers_undefined_env_var(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test that undefined environment variables raise an error."""
config = tmp_path / 'mcp.json'

# Make sure the environment variable is not set
monkeypatch.delenv('UNDEFINED_VAR', raising=False)

config.write_text('{"mcpServers": {"my_server": {"command": "${UNDEFINED_VAR}", "args": []}}}')

with pytest.raises(ValueError, match='Environment variable \\$\\{UNDEFINED_VAR\\} is not defined'):
load_mcp_servers(config)


def test_load_mcp_servers_partial_env_vars(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test environment variables in partial strings."""
config = tmp_path / 'mcp.json'

monkeypatch.setenv('HOST', 'example.com')
monkeypatch.setenv('PATH_SUFFIX', 'mcp')
config.write_text('{"mcpServers": {"server": {"url": "https://${HOST}/api/${PATH_SUFFIX}"}}}')

servers = load_mcp_servers(config)

assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStreamableHTTP)
assert server.url == 'https://example.com/api/mcp'


def test_load_mcp_servers_with_non_string_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test that non-string primitive values (int, bool, null) in nested structures are passed through unchanged."""
config = tmp_path / 'mcp.json'

# Create a config with environment variables and extra fields containing primitives
# The extra fields will be ignored during validation but go through _expand_env_vars
monkeypatch.setenv('PYTHON_CMD', 'python')
config.write_text(
'{"mcpServers": {"my_server": {"command": "${PYTHON_CMD}", "args": ["-m", "tests.mcp_server"], '
'"metadata": {"count": 42, "enabled": true, "value": null}}}}'
)

# This should successfully expand env vars and ignore the metadata field
servers = load_mcp_servers(config)

assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStdio)
assert server.command == 'python'


async def test_server_info(mcp_server: MCPServerStdio) -> None:
with pytest.raises(
AttributeError, match='The `MCPServerStdio.server_info` is only instantiated after initialization.'
Expand Down
Loading