Skip to content

Commit 7e68fd0

Browse files
author
Walter Gillett
committed
Handle code review feedback: add support for default syntax like Claude Code; compile a regex only once; use re.sub.
1 parent 324c965 commit 7e68fd0

File tree

3 files changed

+157
-15
lines changed

3 files changed

+157
-15
lines changed

docs/mcp/client.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -189,29 +189,34 @@ The configuration file should be a JSON file with an `mcpServers` object contain
189189

190190
### Environment Variables
191191

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:
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:
193195

194196
```json {title="mcp_config_with_env.json"}
195197
{
196198
"mcpServers": {
197199
"python-runner": {
198-
"command": "${PYTHON_CMD}",
200+
"command": "${PYTHON_CMD:-python3}",
199201
"args": ["run", "${MCP_MODULE}", "stdio"],
200202
"env": {
201203
"API_KEY": "${MY_API_KEY}"
202204
}
203205
},
204206
"weather-api": {
205-
"url": "https://${SERVER_HOST}:${SERVER_PORT}/sse"
207+
"url": "https://${SERVER_HOST:-localhost}:${SERVER_PORT:-8080}/sse"
206208
}
207209
}
208210
}
209211
```
210212

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.
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.
212217

213218
!!! 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.
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.
215220

216221
### Usage
217222

pydantic_ai_slim/pydantic_ai/mcp.py

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,13 @@
5353
)
5454
)
5555

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+
5663

5764
class MCPServer(AbstractToolset[Any], ABC):
5865
"""Base class for attaching agents to MCP servers.
@@ -932,7 +939,8 @@ class MCPServerConfig(BaseModel):
932939
def _expand_env_vars(value: Any) -> Any:
933940
"""Recursively expand environment variables in a JSON structure.
934941
935-
Environment variables can be referenced using ${VAR_NAME} syntax.
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.
936944
937945
Args:
938946
value: The value to expand (can be str, dict, list, or other JSON types).
@@ -941,17 +949,27 @@ def _expand_env_vars(value: Any) -> Any:
941949
The value with all environment variables expanded.
942950
943951
Raises:
944-
ValueError: If an environment variable is not defined.
952+
ValueError: If an environment variable is not defined and no default value is provided.
945953
"""
946954
if isinstance(value, str):
947955
# 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:
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
953970
raise ValueError(f'Environment variable ${{{var_name}}} is not defined')
954-
value = value.replace(f'${{{var_name}}}', os.environ[var_name])
971+
972+
value = _ENV_VAR_PATTERN.sub(replace_match, value)
955973

956974
return value
957975
elif isinstance(value, dict):
@@ -965,7 +983,9 @@ def _expand_env_vars(value: Any) -> Any:
965983
def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServerStreamableHTTP | MCPServerSSE]:
966984
"""Load MCP servers from a configuration file.
967985
968-
Environment variables can be referenced in the configuration file using ${VAR_NAME} syntax.
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
969989
970990
Args:
971991
config_path: The path to the configuration file.
@@ -976,7 +996,7 @@ def load_mcp_servers(config_path: str | Path) -> list[MCPServerStdio | MCPServer
976996
Raises:
977997
FileNotFoundError: If the configuration file does not exist.
978998
ValidationError: If the configuration file does not match the schema.
979-
ValueError: If an environment variable referenced in the configuration is not defined.
999+
ValueError: If an environment variable referenced in the configuration is not defined and no default value is provided.
9801000
"""
9811001
config_path = Path(config_path)
9821002

tests/test_mcp.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,21 @@ async def test_aexit_called_more_times_than_aenter():
121121
await server.__aexit__(None, None, None)
122122

123123

124+
async def test_is_running():
125+
"""Test the is_running property."""
126+
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'])
127+
128+
# Server should not be running initially
129+
assert not server.is_running
130+
131+
# Server should be running inside the context manager
132+
async with server:
133+
assert server.is_running
134+
135+
# Server should not be running after exiting the context manager
136+
assert not server.is_running
137+
138+
124139
async def test_stdio_server_with_tool_prefix(run_context: RunContext[int]):
125140
server = MCPServerStdio('python', ['-m', 'tests.mcp_server'], tool_prefix='foo')
126141
async with server:
@@ -1577,6 +1592,108 @@ def test_load_mcp_servers_with_non_string_values(tmp_path: Path, monkeypatch: py
15771592
assert server.command == 'python'
15781593

15791594

1595+
def test_load_mcp_servers_with_default_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1596+
"""Test ${VAR:-default} syntax for environment variable expansion."""
1597+
config = tmp_path / 'mcp.json'
1598+
1599+
# Test with undefined variable using default
1600+
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
1601+
config.write_text('{"mcpServers": {"server": {"command": "${UNDEFINED_VAR:-python3}", "args": []}}}')
1602+
1603+
servers = load_mcp_servers(config)
1604+
assert len(servers) == 1
1605+
server = servers[0]
1606+
assert isinstance(server, MCPServerStdio)
1607+
assert server.command == 'python3'
1608+
1609+
# Test with defined variable (should use actual value, not default)
1610+
monkeypatch.setenv('DEFINED_VAR', 'actual_value')
1611+
config.write_text('{"mcpServers": {"server": {"command": "${DEFINED_VAR:-default_value}", "args": []}}}')
1612+
1613+
servers = load_mcp_servers(config)
1614+
assert len(servers) == 1
1615+
server = servers[0]
1616+
assert isinstance(server, MCPServerStdio)
1617+
assert server.command == 'actual_value'
1618+
1619+
# Test with empty string as default
1620+
monkeypatch.delenv('UNDEFINED_VAR', raising=False)
1621+
config.write_text('{"mcpServers": {"server": {"command": "${UNDEFINED_VAR:-}", "args": []}}}')
1622+
1623+
servers = load_mcp_servers(config)
1624+
assert len(servers) == 1
1625+
server = servers[0]
1626+
assert isinstance(server, MCPServerStdio)
1627+
assert server.command == ''
1628+
1629+
1630+
def test_load_mcp_servers_with_default_values_in_url(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1631+
"""Test ${VAR:-default} syntax in URLs."""
1632+
config = tmp_path / 'mcp.json'
1633+
1634+
# Test with default values in URL
1635+
monkeypatch.delenv('HOST', raising=False)
1636+
monkeypatch.setenv('PROTOCOL', 'https')
1637+
config.write_text('{"mcpServers": {"server": {"url": "${PROTOCOL:-http}://${HOST:-localhost}:${PORT:-8080}/mcp"}}}')
1638+
1639+
servers = load_mcp_servers(config)
1640+
assert len(servers) == 1
1641+
server = servers[0]
1642+
assert isinstance(server, MCPServerStreamableHTTP)
1643+
assert server.url == 'https://localhost:8080/mcp'
1644+
1645+
1646+
def test_load_mcp_servers_with_default_values_in_env_dict(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1647+
"""Test ${VAR:-default} syntax in env dictionary."""
1648+
config = tmp_path / 'mcp.json'
1649+
1650+
monkeypatch.delenv('API_KEY', raising=False)
1651+
monkeypatch.setenv('CUSTOM_VAR', 'custom_value')
1652+
config.write_text(
1653+
'{"mcpServers": {"server": {"command": "python", "args": [], '
1654+
'"env": {"API_KEY": "${API_KEY:-default_key}", "CUSTOM": "${CUSTOM_VAR:-fallback}"}}}}'
1655+
)
1656+
1657+
servers = load_mcp_servers(config)
1658+
assert len(servers) == 1
1659+
server = servers[0]
1660+
assert isinstance(server, MCPServerStdio)
1661+
assert server.env == {'API_KEY': 'default_key', 'CUSTOM': 'custom_value'}
1662+
1663+
1664+
def test_load_mcp_servers_with_complex_default_values(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1665+
"""Test ${VAR:-default} syntax with special characters in default."""
1666+
config = tmp_path / 'mcp.json'
1667+
1668+
monkeypatch.delenv('PATH_VAR', raising=False)
1669+
# Test default with slashes, dots, and dashes
1670+
config.write_text('{"mcpServers": {"server": {"command": "${PATH_VAR:-/usr/local/bin/python-3.10}", "args": []}}}')
1671+
1672+
servers = load_mcp_servers(config)
1673+
assert len(servers) == 1
1674+
server = servers[0]
1675+
assert isinstance(server, MCPServerStdio)
1676+
assert server.command == '/usr/local/bin/python-3.10'
1677+
1678+
1679+
def test_load_mcp_servers_with_mixed_syntax(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
1680+
"""Test mixing ${VAR} and ${VAR:-default} syntax in the same config."""
1681+
config = tmp_path / 'mcp.json'
1682+
1683+
monkeypatch.setenv('REQUIRED_VAR', 'required_value')
1684+
monkeypatch.delenv('OPTIONAL_VAR', raising=False)
1685+
config.write_text(
1686+
'{"mcpServers": {"server": {"command": "${REQUIRED_VAR}", "args": ["${OPTIONAL_VAR:-default_arg}"]}}}'
1687+
)
1688+
1689+
servers = load_mcp_servers(config)
1690+
assert len(servers) == 1
1691+
server = servers[0]
1692+
assert isinstance(server, MCPServerStdio)
1693+
assert server.command == 'required_value'
1694+
assert server.args == ['default_arg']
1695+
1696+
15801697
async def test_server_info(mcp_server: MCPServerStdio) -> None:
15811698
with pytest.raises(
15821699
AttributeError, match='The `MCPServerStdio.server_info` is only instantiated after initialization.'

0 commit comments

Comments
 (0)