Skip to content

Commit dad40f5

Browse files
Unshureclaude
andcommitted
feat(experimental): add MCP server configuration support to agent config
Add declarative MCP server configuration via agent config files and dicts. Supports stdio, SSE, and streamable-HTTP transports with JSON schema validation, tool filters, and auto-detection of transport type. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 50b2c79 commit dad40f5

File tree

7 files changed

+957
-28
lines changed

7 files changed

+957
-28
lines changed

src/strands/experimental/agent_config.py

Lines changed: 20 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -12,40 +12,23 @@
1212
"""
1313

1414
import json
15+
import logging
1516
from pathlib import Path
1617
from typing import Any
1718

1819
import jsonschema
1920
from jsonschema import ValidationError
2021

21-
# JSON Schema for agent configuration
22-
AGENT_CONFIG_SCHEMA = {
23-
"$schema": "http://json-schema.org/draft-07/schema#",
24-
"title": "Agent Configuration",
25-
"description": "Configuration schema for creating agents",
26-
"type": "object",
27-
"properties": {
28-
"name": {"description": "Name of the agent", "type": ["string", "null"], "default": None},
29-
"model": {
30-
"description": "The model ID to use for this agent. If not specified, uses the default model.",
31-
"type": ["string", "null"],
32-
"default": None,
33-
},
34-
"prompt": {
35-
"description": "The system prompt for the agent. Provides high level context to the agent.",
36-
"type": ["string", "null"],
37-
"default": None,
38-
},
39-
"tools": {
40-
"description": "List of tools the agent can use. Can be file paths, "
41-
"Python module names, or @tool annotated functions in files.",
42-
"type": "array",
43-
"items": {"type": "string"},
44-
"default": [],
45-
},
46-
},
47-
"additionalProperties": False,
48-
}
22+
from .mcp_config import MCP_SERVER_CONFIG_SCHEMA, load_mcp_clients_from_config
23+
24+
logger = logging.getLogger(__name__)
25+
26+
_SCHEMA_PATH = Path(__file__).parent / "agent_config.schema.json"
27+
with open(_SCHEMA_PATH) as _f:
28+
AGENT_CONFIG_SCHEMA: dict[str, Any] = json.load(_f)
29+
30+
# Resolve the $ref in mcp_servers.additionalProperties to the actual MCP server schema
31+
AGENT_CONFIG_SCHEMA["properties"]["mcp_servers"]["additionalProperties"] = MCP_SERVER_CONFIG_SCHEMA
4932

5033
# Pre-compile validator for better performance
5134
_VALIDATOR = jsonschema.Draft7Validator(AGENT_CONFIG_SCHEMA)
@@ -129,6 +112,15 @@ def config_to_agent(config: str | dict[str, Any], **kwargs: dict[str, Any]) -> A
129112
if config_key in config_dict and config_dict[config_key] is not None:
130113
agent_kwargs[agent_param] = config_dict[config_key]
131114

115+
# Handle mcp_servers: create MCPClient instances and append to tools
116+
if config_dict.get("mcp_servers"):
117+
mcp_clients = load_mcp_clients_from_config({"mcpServers": config_dict["mcp_servers"]})
118+
tools_list = agent_kwargs.get("tools", [])
119+
if not isinstance(tools_list, list):
120+
tools_list = list(tools_list)
121+
tools_list.extend(mcp_clients.values())
122+
agent_kwargs["tools"] = tools_list
123+
132124
# Override with any additional kwargs provided
133125
agent_kwargs.update(kwargs)
134126

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "Agent Configuration",
4+
"description": "Configuration schema for creating agents.",
5+
"type": "object",
6+
"properties": {
7+
"name": {
8+
"description": "Name of the agent.",
9+
"type": ["string", "null"],
10+
"default": null
11+
},
12+
"model": {
13+
"description": "The model ID to use for this agent. If not specified, uses the default model.",
14+
"type": ["string", "null"],
15+
"default": null
16+
},
17+
"prompt": {
18+
"description": "The system prompt for the agent. Provides high level context to the agent.",
19+
"type": ["string", "null"],
20+
"default": null
21+
},
22+
"tools": {
23+
"description": "List of tools the agent can use. Can be file paths, Python module names, or @tool annotated functions in files.",
24+
"type": "array",
25+
"items": { "type": "string" },
26+
"default": []
27+
},
28+
"mcp_servers": {
29+
"description": "MCP server configurations. Each key is a server name and the value is a server configuration object with transport-specific settings.",
30+
"type": "object",
31+
"additionalProperties": { "$ref": "mcp_server_config.schema.json" }
32+
}
33+
},
34+
"additionalProperties": false
35+
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
"""MCP server configuration parsing and MCPClient factory.
2+
3+
This module handles parsing MCP server configurations from dictionaries or JSON files
4+
and creating MCPClient instances with the appropriate transport callables.
5+
6+
Supported transport types:
7+
- stdio: Local subprocess via stdin/stdout (auto-detected when 'command' is present)
8+
- sse: Server-Sent Events over HTTP (auto-detected when 'url' is present without explicit transport)
9+
- streamable-http: Streamable HTTP transport
10+
"""
11+
12+
import json
13+
import logging
14+
import re
15+
from pathlib import Path
16+
from typing import Any
17+
18+
import jsonschema
19+
from jsonschema import ValidationError
20+
from mcp import StdioServerParameters
21+
from mcp.client.sse import sse_client
22+
from mcp.client.stdio import stdio_client
23+
from mcp.client.streamable_http import streamable_http_client
24+
25+
from ..tools.mcp.mcp_client import MCPClient, ToolFilters
26+
27+
logger = logging.getLogger(__name__)
28+
29+
_SCHEMA_PATH = Path(__file__).parent / "mcp_server_config.schema.json"
30+
with open(_SCHEMA_PATH) as _f:
31+
MCP_SERVER_CONFIG_SCHEMA: dict[str, Any] = json.load(_f)
32+
33+
_SERVER_VALIDATOR = jsonschema.Draft7Validator(MCP_SERVER_CONFIG_SCHEMA)
34+
35+
36+
def _parse_tool_filters(config: dict[str, Any] | None) -> ToolFilters | None:
37+
"""Parse a tool filter configuration into a ToolFilters instance.
38+
39+
All filter strings are compiled as regex patterns. Exact-match strings like ``"^echo$"``
40+
work correctly as regex since they match themselves.
41+
42+
Args:
43+
config: Tool filter configuration dict with 'allowed' and/or 'rejected' lists,
44+
or None.
45+
46+
Returns:
47+
A ToolFilters instance, or None if config is None or empty.
48+
49+
Raises:
50+
ValueError: If a filter string is not a valid regex pattern.
51+
"""
52+
if not config:
53+
return None
54+
55+
result: ToolFilters = {}
56+
57+
if "allowed" in config:
58+
allowed: list[re.Pattern[str]] = []
59+
for pattern_str in config["allowed"]:
60+
try:
61+
allowed.append(re.compile(pattern_str))
62+
except re.error as e:
63+
raise ValueError(f"invalid regex pattern in tool_filters.allowed: '{pattern_str}': {e}") from e
64+
result["allowed"] = allowed
65+
66+
if "rejected" in config:
67+
rejected: list[re.Pattern[str]] = []
68+
for pattern_str in config["rejected"]:
69+
try:
70+
rejected.append(re.compile(pattern_str))
71+
except re.error as e:
72+
raise ValueError(f"invalid regex pattern in tool_filters.rejected: '{pattern_str}': {e}") from e
73+
result["rejected"] = rejected
74+
75+
return result if result else None
76+
77+
78+
def _create_mcp_client_from_config(server_name: str, config: dict[str, Any]) -> MCPClient:
79+
"""Create an MCPClient instance from a server configuration dictionary.
80+
81+
Transport type is auto-detected based on the presence of 'command' (stdio) or 'url' (sse),
82+
unless explicitly specified via the 'transport' field.
83+
84+
Args:
85+
server_name: Name of the server (used in error messages).
86+
config: Server configuration dictionary.
87+
88+
Returns:
89+
A configured MCPClient instance.
90+
91+
Raises:
92+
ValueError: If the configuration is invalid or missing required fields.
93+
"""
94+
# Validate against schema
95+
try:
96+
_SERVER_VALIDATOR.validate(config)
97+
except ValidationError as e:
98+
error_path = " -> ".join(str(p) for p in e.absolute_path) if e.absolute_path else "root"
99+
raise ValueError(f"server '{server_name}' configuration validation error at {error_path}: {e.message}") from e
100+
101+
# Determine transport type
102+
transport = config.get("transport")
103+
command = config.get("command")
104+
url = config.get("url")
105+
106+
if transport is None:
107+
if command:
108+
transport = "stdio"
109+
elif url:
110+
transport = "sse"
111+
else:
112+
raise ValueError(
113+
f"server '{server_name}' must specify either 'command' (for stdio) or 'url' (for sse/http)"
114+
)
115+
116+
# Extract common MCPClient parameters
117+
prefix = config.get("prefix")
118+
startup_timeout = config.get("startup_timeout", 30)
119+
tool_filters = _parse_tool_filters(config.get("tool_filters"))
120+
121+
# Build transport callable based on type
122+
if transport == "stdio":
123+
124+
def _stdio_transport() -> Any:
125+
params = StdioServerParameters(
126+
command=config["command"],
127+
args=config.get("args", []),
128+
env=config.get("env"),
129+
cwd=config.get("cwd"),
130+
)
131+
return stdio_client(params)
132+
133+
transport_callable = _stdio_transport
134+
elif transport == "sse":
135+
if not url:
136+
raise ValueError(f"server '{server_name}': 'url' is required for sse transport")
137+
headers = config.get("headers")
138+
139+
def _sse_transport() -> Any:
140+
return sse_client(url=url, headers=headers)
141+
142+
transport_callable = _sse_transport
143+
elif transport == "streamable-http":
144+
if not url:
145+
raise ValueError(f"server '{server_name}': 'url' is required for streamable-http transport")
146+
headers = config.get("headers")
147+
148+
def _streamable_http_transport() -> Any:
149+
return streamable_http_client(url=url, headers=headers)
150+
151+
transport_callable = _streamable_http_transport
152+
else:
153+
raise ValueError(f"server '{server_name}': unsupported transport type '{transport}'")
154+
155+
logger.debug(
156+
"server_name=<%s>, transport=<%s> | creating MCP client from config",
157+
server_name,
158+
transport,
159+
)
160+
161+
return MCPClient(
162+
transport_callable,
163+
startup_timeout=startup_timeout,
164+
tool_filters=tool_filters,
165+
prefix=prefix,
166+
)
167+
168+
169+
def load_mcp_clients_from_config(config: str | dict[str, Any]) -> dict[str, MCPClient]:
170+
"""Load MCP client instances from a configuration file or dictionary.
171+
172+
Expects the standard ``mcpServers`` wrapper format used by Claude Desktop, VS Code, etc::
173+
174+
{
175+
"mcpServers": {
176+
"server_name": { "command": "...", ... }
177+
}
178+
}
179+
180+
Args:
181+
config: Either a file path (with optional file:// prefix) to a JSON config file,
182+
or a dictionary with a ``mcpServers`` key mapping server names to configs.
183+
184+
Returns:
185+
A dictionary mapping server names to MCPClient instances.
186+
187+
Raises:
188+
FileNotFoundError: If the config file does not exist.
189+
json.JSONDecodeError: If the config file contains invalid JSON.
190+
ValueError: If the config format is invalid or a server config is invalid.
191+
"""
192+
if isinstance(config, str):
193+
file_path = config
194+
if file_path.startswith("file://"):
195+
file_path = file_path[7:]
196+
197+
config_path = Path(file_path)
198+
if not config_path.exists():
199+
raise FileNotFoundError(f"MCP configuration file not found: {file_path}")
200+
201+
with open(config_path) as f:
202+
config_dict: dict[str, Any] = json.load(f)
203+
elif isinstance(config, dict):
204+
config_dict = config
205+
else:
206+
raise ValueError("Config must be a file path string or dictionary")
207+
208+
if "mcpServers" not in config_dict or not isinstance(config_dict["mcpServers"], dict):
209+
raise ValueError("Config must contain an 'mcpServers' key with a dictionary of server configurations")
210+
211+
servers = config_dict["mcpServers"]
212+
clients: dict[str, MCPClient] = {}
213+
for server_name, server_config in servers.items():
214+
clients[server_name] = _create_mcp_client_from_config(server_name, server_config)
215+
216+
logger.debug("loaded_servers=<%d> | MCP clients created from config", len(clients))
217+
218+
return clients
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"title": "MCP Server Configuration",
4+
"description": "Configuration for a single MCP server.",
5+
"type": "object",
6+
"properties": {
7+
"transport": {
8+
"description": "Transport type. Auto-detected from 'command' (stdio) or 'url' (sse) if omitted.",
9+
"type": "string",
10+
"enum": ["stdio", "sse", "streamable-http"]
11+
},
12+
"command": {
13+
"description": "Command to run for stdio transport.",
14+
"type": "string"
15+
},
16+
"args": {
17+
"description": "Arguments for the stdio command.",
18+
"type": "array",
19+
"items": { "type": "string" },
20+
"default": []
21+
},
22+
"env": {
23+
"description": "Environment variables for the stdio command.",
24+
"type": "object",
25+
"additionalProperties": { "type": "string" }
26+
},
27+
"cwd": {
28+
"description": "Working directory for the stdio command.",
29+
"type": "string"
30+
},
31+
"url": {
32+
"description": "URL for sse or streamable-http transport.",
33+
"type": "string"
34+
},
35+
"headers": {
36+
"description": "HTTP headers for sse or streamable-http transport.",
37+
"type": "object",
38+
"additionalProperties": { "type": "string" }
39+
},
40+
"prefix": {
41+
"description": "Prefix to apply to tool names from this server.",
42+
"type": "string"
43+
},
44+
"startup_timeout": {
45+
"description": "Timeout in seconds for server initialization. Defaults to 30.",
46+
"type": "integer",
47+
"default": 30
48+
},
49+
"tool_filters": {
50+
"description": "Filters for controlling which tools are loaded.",
51+
"type": "object",
52+
"properties": {
53+
"allowed": {
54+
"description": "List of regex patterns for tools to include.",
55+
"type": "array",
56+
"items": { "type": "string" }
57+
},
58+
"rejected": {
59+
"description": "List of regex patterns for tools to exclude.",
60+
"type": "array",
61+
"items": { "type": "string" }
62+
}
63+
},
64+
"additionalProperties": false
65+
}
66+
},
67+
"additionalProperties": false
68+
}

0 commit comments

Comments
 (0)