Skip to content

Commit eb654d5

Browse files
feat(mcp/py): load MCP servers from JSON (strands-agents#3053)
1 parent 49f3a21 commit eb654d5

3 files changed

Lines changed: 484 additions & 3 deletions

File tree

strands-py/src/strands/tools/mcp/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
"""
88

99
from .mcp_agent_tool import MCPAgentTool
10-
from .mcp_client import MCPClient, ToolFilters
10+
from .mcp_client import MCPClient, MCPServerConfig, ToolFilters
1111
from .mcp_tasks import TasksConfig
1212
from .mcp_types import MCPTransport
1313

14-
__all__ = ["MCPAgentTool", "MCPClient", "MCPTransport", "TasksConfig", "ToolFilters"]
14+
__all__ = ["MCPAgentTool", "MCPClient", "MCPServerConfig", "MCPTransport", "TasksConfig", "ToolFilters"]

strands-py/src/strands/tools/mcp/mcp_client.py

Lines changed: 216 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,25 @@
1212
import contextvars
1313
import json
1414
import logging
15+
import os
16+
import re
1517
import sys
1618
import threading
1719
import uuid
1820
from asyncio import AbstractEventLoop
1921
from collections.abc import Callable, Coroutine, Sequence
2022
from concurrent import futures
2123
from datetime import timedelta
24+
from pathlib import Path
2225
from re import Pattern
2326
from types import TracebackType
2427
from typing import Any, TypeVar, cast
2528

2629
import anyio
27-
from mcp import ClientSession, ListToolsResult
30+
from mcp import ClientSession, ListToolsResult, StdioServerParameters, stdio_client
2831
from mcp.client.session import ElicitationFnT
32+
from mcp.client.sse import sse_client
33+
from mcp.client.streamable_http import streamablehttp_client
2934
from mcp.shared.exceptions import McpError
3035
from mcp.shared.session import ProgressFnT
3136
from mcp.types import (
@@ -79,6 +84,27 @@ class ToolFilters(TypedDict, total=False):
7984
rejected: list[_ToolMatcher]
8085

8186

87+
class MCPServerConfig(TypedDict, total=False):
88+
"""Schema for a single MCP server entry in a load_servers config.
89+
90+
Provide either 'command' (stdio) or 'url' (streamable-http/sse), not both. When 'transport' is
91+
omitted it is auto-detected from the fields present. String values support '${VAR}' /
92+
'${env:VAR}' interpolation, and '~' in 'command' and 'cwd' is expanded to the home directory.
93+
"""
94+
95+
command: str
96+
args: list[str]
97+
env: dict[str, str]
98+
cwd: str
99+
url: str
100+
headers: dict[str, str]
101+
transport: str
102+
disabled: bool
103+
prefix: str
104+
tool_filters: ToolFilters
105+
startup_timeout: int
106+
107+
82108
MIME_TO_FORMAT: dict[str, ImageFormat] = {
83109
"image/jpeg": "jpeg",
84110
"image/jpg": "jpeg",
@@ -114,6 +140,53 @@ class MCPClient(ToolProvider):
114140
from MCP tools, it will be returned as the last item in the content array of the ToolResult.
115141
"""
116142

143+
@classmethod
144+
def load_servers(cls, config: "str | dict[str, Any]") -> "list[MCPClient]":
145+
"""Create MCPClient instances from an ``mcpServers`` JSON config (file path or mapping).
146+
147+
Returns one client per enabled server. Accepts either a flat mapping of server name to
148+
config, or that mapping nested under an ``mcpServers`` key. Servers marked
149+
``"disabled": true`` are skipped.
150+
151+
Transport is auto-detected from the fields present: ``command`` selects stdio and ``url``
152+
selects streamable-http. Set ``transport`` explicitly (``"stdio"``, ``"sse"``, or
153+
``"streamable-http"``) to override. String values support ``${VAR}`` / ``${env:VAR}``
154+
interpolation against the process environment, and ``~`` in ``command`` and ``cwd`` is
155+
expanded to the user's home directory.
156+
157+
Args:
158+
config: A file path (with optional ``file://`` prefix) to a JSON config, or a
159+
dictionary mapping server names to configs (optionally under an ``mcpServers`` key).
160+
161+
Returns:
162+
One MCPClient per enabled server, ready to pass to ``Agent(tools=...)``.
163+
164+
Raises:
165+
FileNotFoundError: If the config file does not exist.
166+
json.JSONDecodeError: If the config file contains invalid JSON.
167+
ValueError: If the config shape is invalid, a server entry is not a mapping, a server
168+
config is invalid, or a referenced environment variable is not set.
169+
"""
170+
servers = _load_servers_mapping(config)
171+
172+
clients: list[MCPClient] = []
173+
for name, server in servers.items():
174+
# A non-dict entry is a malformed-config error and always raises, unlike per-server failures.
175+
if not isinstance(server, dict):
176+
raise ValueError(f"server '{name}' configuration must be a dictionary, got {type(server).__name__}")
177+
unknown_keys = sorted(server.keys() - MCPServerConfig.__annotations__.keys())
178+
if unknown_keys:
179+
logger.warning(
180+
"server_name=<%s>, unknown_keys=<%s> | ignoring unrecognized MCP config keys", name, unknown_keys
181+
)
182+
if server.get("disabled", False):
183+
logger.debug("server_name=<%s> | skipping disabled MCP server", name)
184+
continue
185+
clients.append(_build_client_from_config(name, server))
186+
187+
logger.debug("loaded_servers=<%d> | created MCP clients from config", len(clients))
188+
return clients
189+
117190
def __init__(
118191
self,
119192
transport_callable: Callable[[], MCPTransport],
@@ -1259,3 +1332,145 @@ async def _poll_until_terminal() -> GetTaskResult | None:
12591332
final_status.status,
12601333
)
12611334
return self._create_task_error_result(f"Unexpected task status: {final_status.status}")
1335+
1336+
1337+
# Matches ${VAR} and ${env:VAR} where VAR is a valid environment variable identifier.
1338+
_ENV_VAR_PATTERN = re.compile(r"\$\{(?:env:)?([A-Za-z_][A-Za-z0-9_]*)\}")
1339+
1340+
1341+
def _load_servers_mapping(config: str | dict[str, Any]) -> dict[str, Any]:
1342+
"""Resolve the load_servers input into a mapping of server name to server config.
1343+
1344+
Reads and parses the file when config is a path, then unwraps the optional ``mcpServers`` key
1345+
so both wrapped and flat shapes are accepted.
1346+
"""
1347+
if isinstance(config, str):
1348+
file_path = config[len("file://") :] if config.startswith("file://") else config
1349+
path = Path(file_path).expanduser()
1350+
if not path.exists():
1351+
raise FileNotFoundError(f"MCP configuration file not found: {file_path}")
1352+
config_dict = json.loads(path.read_text())
1353+
elif isinstance(config, dict):
1354+
config_dict = config
1355+
else:
1356+
raise ValueError("config must be a file path string or a dictionary")
1357+
1358+
servers = config_dict.get("mcpServers", config_dict) if isinstance(config_dict, dict) else None
1359+
if not isinstance(servers, dict):
1360+
raise ValueError(
1361+
'MCP config must be a JSON object mapping server names to configs, e.g. {"my-server": {"command": "node"}}'
1362+
)
1363+
return servers
1364+
1365+
1366+
def _build_client_from_config(name: str, server: dict[str, Any]) -> MCPClient:
1367+
"""Build a single MCPClient from one server entry.
1368+
1369+
Interpolates ``${VAR}`` references first so secrets can live in the environment, then detects
1370+
the transport and constructs the matching transport callable.
1371+
"""
1372+
server = cast(dict[str, Any], _interpolate_env_vars(server))
1373+
1374+
if server.get("command") and server.get("url") and not server.get("transport"):
1375+
raise ValueError(f"server '{name}' has both 'command' and 'url' — set 'transport' explicitly or remove one")
1376+
1377+
transport = server.get("transport")
1378+
if transport is None:
1379+
transport = "stdio" if server.get("command") else "streamable-http" if server.get("url") else None
1380+
if transport is None:
1381+
raise ValueError(f"server '{name}' must include either 'command' (stdio) or 'url' (http)")
1382+
1383+
transport_callable = _config_transport_callable(name, transport, server)
1384+
1385+
logger.debug("server_name=<%s>, transport=<%s> | creating MCP client from config", name, transport)
1386+
return MCPClient(
1387+
transport_callable,
1388+
startup_timeout=server.get("startup_timeout", 30),
1389+
tool_filters=_parse_config_tool_filters(name, server.get("tool_filters")),
1390+
prefix=server.get("prefix"),
1391+
)
1392+
1393+
1394+
def _config_transport_callable(name: str, transport: str, server: dict[str, Any]) -> Callable[[], MCPTransport]:
1395+
"""Return a zero-arg callable that opens the transport for the given server entry."""
1396+
match transport:
1397+
case "stdio":
1398+
command = server.get("command")
1399+
if not command:
1400+
raise ValueError(f"server '{name}': stdio transport requires 'command'")
1401+
params = StdioServerParameters(
1402+
command=os.path.expanduser(command),
1403+
args=server.get("args", []),
1404+
env=server.get("env"),
1405+
cwd=os.path.expanduser(server["cwd"]) if server.get("cwd") else None,
1406+
)
1407+
return lambda: stdio_client(params)
1408+
1409+
case "streamable-http":
1410+
url = server.get("url")
1411+
if not url:
1412+
raise ValueError(f"server '{name}': streamable-http transport requires 'url'")
1413+
headers = server.get("headers")
1414+
return lambda: streamablehttp_client(url=cast(str, url), headers=headers)
1415+
1416+
case "sse":
1417+
url = server.get("url")
1418+
if not url:
1419+
raise ValueError(f"server '{name}': sse transport requires 'url'")
1420+
headers = server.get("headers")
1421+
return lambda: sse_client(url=cast(str, url), headers=headers)
1422+
1423+
case _:
1424+
raise ValueError(f"server '{name}': unsupported transport type '{transport}'")
1425+
1426+
1427+
def _parse_config_tool_filters(name: str, config: dict[str, Any] | None) -> ToolFilters | None:
1428+
"""Compile a tool-filter config into a ToolFilters mapping of regex patterns.
1429+
1430+
Patterns match the server-side (unprefixed) tool name; any ``prefix`` is applied afterwards.
1431+
"""
1432+
if not config:
1433+
return None
1434+
1435+
result: ToolFilters = {}
1436+
if config.get("allowed") is not None:
1437+
result["allowed"] = _compile_filter_patterns(name, "allowed", config["allowed"])
1438+
if config.get("rejected") is not None:
1439+
result["rejected"] = _compile_filter_patterns(name, "rejected", config["rejected"])
1440+
1441+
return result or None
1442+
1443+
1444+
def _compile_filter_patterns(name: str, key: str, patterns: list[str]) -> list[_ToolMatcher]:
1445+
"""Compile a list of regex strings into patterns, raising a clear error on an invalid one."""
1446+
compiled: list[_ToolMatcher] = []
1447+
for pattern in patterns:
1448+
try:
1449+
compiled.append(re.compile(pattern))
1450+
except re.error as e:
1451+
raise ValueError(f"server '{name}': invalid regex in tool_filters.{key}: '{pattern}': {e}") from e
1452+
return compiled
1453+
1454+
1455+
def _interpolate_env_vars(value: Any) -> Any:
1456+
"""Recursively replace ``${VAR}`` / ``${env:VAR}`` references in strings with env values.
1457+
1458+
Strings nested inside lists and dicts are interpolated; other types pass through unchanged.
1459+
1460+
Raises:
1461+
ValueError: If a referenced environment variable is not set.
1462+
"""
1463+
if isinstance(value, str):
1464+
1465+
def _replace(match: re.Match[str]) -> str:
1466+
var = match.group(1)
1467+
if var not in os.environ:
1468+
raise ValueError(f"environment variable '{var}' is not set")
1469+
return os.environ[var]
1470+
1471+
return _ENV_VAR_PATTERN.sub(_replace, value)
1472+
if isinstance(value, list):
1473+
return [_interpolate_env_vars(item) for item in value]
1474+
if isinstance(value, dict):
1475+
return {key: _interpolate_env_vars(item) for key, item in value.items()}
1476+
return value

0 commit comments

Comments
 (0)