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
31 changes: 31 additions & 0 deletions docs/mcp/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,37 @@ 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}` and `${VAR:-default}` syntax,
[like Claude Code](https://code.claude.com/docs/en/mcp#environment-variable-expansion-in-mcp-json).
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:-python3}",
"args": ["run", "${MCP_MODULE}", "stdio"],
"env": {
"API_KEY": "${MY_API_KEY}"
}
},
"weather-api": {
"url": "https://${SERVER_HOST:-localhost}:${SERVER_PORT:-8080}/sse"
}
}
}
```

When loading this configuration with [`load_mcp_servers()`][pydantic_ai.mcp.load_mcp_servers]:

- `${VAR}` references will be replaced with the corresponding environment variable values.
- `${VAR:-default}` references will use the environment variable value if set, otherwise the default value.

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

### Usage

```python {title="mcp_config_loader.py" test="skip"}
Expand Down
62 changes: 61 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 @@ -51,6 +53,13 @@
)
)

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


class MCPServer(AbstractToolset[Any], ABC):
"""Base class for attaching agents to MCP servers.
Expand Down Expand Up @@ -927,9 +936,57 @@ 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,
or ${VAR_NAME:-default} syntax to provide a default value if the variable is not set.

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 and no default value is provided.
"""
if isinstance(value, str):
# Find all environment variable references in the string
# Supports both ${VAR_NAME} and ${VAR_NAME:-default} syntax
def replace_match(match: re.Match[str]) -> str:
var_name = match.group(1)
has_default = match.group(2) is not None
default_value = match.group(3) if has_default else None

# Check if variable exists in environment
if var_name in os.environ:
return os.environ[var_name]
elif has_default:
# Use default value if the :- syntax was present (even if empty string)
return default_value or ''
else:
# No default value and variable not set - raise error
raise ValueError(f'Environment variable ${{{var_name}}} is not defined')

value = _ENV_VAR_PATTERN.sub(replace_match, value)

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 - expands to the value of VAR_NAME, raises error if not defined
- `${VAR_NAME:-default}` syntax - expands to VAR_NAME if set, otherwise uses the default value

Args:
config_path: The path to the configuration file.

Expand All @@ -939,13 +996,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 and no default value is provided.
"""
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
213 changes: 213 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,21 @@ async def test_aexit_called_more_times_than_aenter():
await server.__aexit__(None, None, None)


async def test_is_running():
"""Test the is_running property."""
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])

# Server should not be running initially
assert not server.is_running

# Server should be running inside the context manager
async with server:
assert server.is_running

# Server should not be running after exiting the context manager
assert not server.is_running


async def test_stdio_server_with_tool_prefix(run_context: RunContext[int]):
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], tool_prefix='foo')
async with server:
Expand Down Expand Up @@ -1481,6 +1496,204 @@ 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'


def test_load_mcp_servers_with_default_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test ${VAR:-default} syntax for environment variable expansion."""
config = tmp_path / 'mcp.json'

# Test with undefined variable using default
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
config.write_text('{"mcpServers": {"server": {"command": "${UNDEFINED_VAR:-python3}", "args": []}}}')

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

# Test with defined variable (should use actual value, not default)
monkeypatch.setenv('DEFINED_VAR', 'actual_value')
config.write_text('{"mcpServers": {"server": {"command": "${DEFINED_VAR:-default_value}", "args": []}}}')

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

# Test with empty string as default
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
config.write_text('{"mcpServers": {"server": {"command": "${UNDEFINED_VAR:-}", "args": []}}}')

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


def test_load_mcp_servers_with_default_values_in_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test ${VAR:-default} syntax in URLs."""
config = tmp_path / 'mcp.json'

# Test with default values in URL
monkeypatch.delenv('HOST', raising=False)
monkeypatch.setenv('PROTOCOL', 'https')
config.write_text('{"mcpServers": {"server": {"url": "${PROTOCOL:-http}://${HOST:-localhost}:${PORT:-8080}/mcp"}}}')

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


def test_load_mcp_servers_with_default_values_in_env_dict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test ${VAR:-default} syntax in env dictionary."""
config = tmp_path / 'mcp.json'

monkeypatch.delenv('API_KEY', raising=False)
monkeypatch.setenv('CUSTOM_VAR', 'custom_value')
config.write_text(
'{"mcpServers": {"server": {"command": "python", "args": [], '
'"env": {"API_KEY": "${API_KEY:-default_key}", "CUSTOM": "${CUSTOM_VAR:-fallback}"}}}}'
)

servers = load_mcp_servers(config)
assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStdio)
assert server.env == {'API_KEY': 'default_key', 'CUSTOM': 'custom_value'}


def test_load_mcp_servers_with_complex_default_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test ${VAR:-default} syntax with special characters in default."""
config = tmp_path / 'mcp.json'

monkeypatch.delenv('PATH_VAR', raising=False)
# Test default with slashes, dots, and dashes
config.write_text('{"mcpServers": {"server": {"command": "${PATH_VAR:-/usr/local/bin/python-3.10}", "args": []}}}')

servers = load_mcp_servers(config)
assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStdio)
assert server.command == '/usr/local/bin/python-3.10'


def test_load_mcp_servers_with_mixed_syntax(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Test mixing ${VAR} and ${VAR:-default} syntax in the same config."""
config = tmp_path / 'mcp.json'

monkeypatch.setenv('REQUIRED_VAR', 'required_value')
monkeypatch.delenv('OPTIONAL_VAR', raising=False)
config.write_text(
'{"mcpServers": {"server": {"command": "${REQUIRED_VAR}", "args": ["${OPTIONAL_VAR:-default_arg}"]}}}'
)

servers = load_mcp_servers(config)
assert len(servers) == 1
server = servers[0]
assert isinstance(server, MCPServerStdio)
assert server.command == 'required_value'
assert server.args == ['default_arg']


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