Skip to content

feat: support loading MCP server configurations from JSON config files#2108

Draft
Unshure wants to merge 1 commit intomainfrom
agent-tasks/482
Draft

feat: support loading MCP server configurations from JSON config files#2108
Unshure wants to merge 1 commit intomainfrom
agent-tasks/482

Conversation

@Unshure
Copy link
Copy Markdown
Member

@Unshure Unshure commented Apr 10, 2026

Motivation

Users currently must programmatically instantiate each MCPClient with transport callables, which becomes verbose when managing multiple MCP servers. The Claude Desktop / Cursor / VS Code ecosystem has established a convention for declarative mcpServers JSON configuration. This change brings that same pattern to Strands, letting users define multiple MCP servers in a single JSON config file and load them automatically.

Resolves: #482

Public API Changes

New load_mcp_clients_from_config() function for standalone usage:

from strands.tools.mcp import load_mcp_clients_from_config

# From dict
clients = load_mcp_clients_from_config({
    "aws_docs": {
        "command": "uvx",
        "args": ["awslabs.aws-documentation-mcp-server@latest"],
        "prefix": "aws"
    },
    "remote": {
        "transport": "sse",
        "url": "http://localhost:8000/sse",
        "headers": {"Authorization": "Bearer token123"}
    }
})
agent = Agent(tools=list(clients.values()))

# From JSON file (also supports Claude Desktop {"mcpServers": {...}} wrapper format)
clients = load_mcp_clients_from_config("mcp_servers.json")

config_to_agent() now accepts an mcp_servers field:

agent = config_to_agent({
    "model": "us.anthropic.claude-3-5-sonnet-20241022-v2:0",
    "mcp_servers": {
        "echo": {"command": "python", "args": ["echo_server.py"], "prefix": "echo"}
    }
})

Transport type is auto-detected from config fields (command → stdio, url → sse) or specified explicitly via transport. Per-server options include prefix, startup_timeout, tool_filters (with regex pattern support), env, cwd, and headers.

Key implementation decisions:

  • Tool filter strings are always compiled as regex (exact strings like "echo" are valid regex matching themselves), avoiding fragile auto-detection heuristics
  • The mcpServers wrapper key from Claude Desktop/Cursor configs is auto-detected and unwrapped for interoperability
  • Internal helper functions (_create_mcp_client_from_config, _parse_tool_filters) are private; only load_mcp_clients_from_config is the public entry point

Use Cases

  • Multi-server agents: Define all MCP servers in one config file instead of writing boilerplate transport setup code
  • Configuration-driven deployment: Load different server configs per environment without code changes
  • Interoperability: Reuse existing mcpServers JSON config from Claude Desktop or Cursor setups

@codecov
Copy link
Copy Markdown

codecov bot commented Apr 10, 2026

Codecov Report

❌ Patch coverage is 94.69027% with 6 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/strands/experimental/mcp_config.py 95.95% 3 Missing and 1 partial ⚠️
src/strands/experimental/agent_config.py 85.71% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

from mcp.client.stdio import stdio_client
from mcp.client.streamable_http import streamablehttp_client

from .mcp_client import MCPClient, ToolFilters, _ToolMatcher
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Importing private type _ToolMatcher from mcp_client.py.

_ToolMatcher is prefixed with _ indicating it's a private/internal type, but it's now used as a cross-module dependency.

Suggestion: Either promote _ToolMatcher to a public type (remove the underscore and export it) since it's now part of the contract between modules, or re-define the type locally in mcp_config.py.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. I'll redefine the type locally in mcp_config.py to avoid cross-module private dependency.

Returns:
True if the string appears to be a regex pattern, False for plain strings.
"""
return any(c in _REGEX_SPECIAL_CHARS for c in s)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The _is_regex_pattern heuristic is fragile and can produce false positives.

Any string containing characters like ., (, [, $, etc. will be treated as a regex pattern. For example, a tool filter value like "my.tool" or "tools(v2)" would be silently compiled as a regex instead of being treated as a literal match, leading to unexpected behavior.

Suggestion: Consider a more explicit opt-in mechanism. Options include:

  1. A prefix/suffix convention, e.g. "/search_.*/" for regex (similar to JavaScript notation)
  2. Always treat as regex (since exact strings are also valid regex)
  3. A structured format: {"pattern": "search_.*", "type": "regex"} vs {"pattern": "echo", "type": "exact"}

Option 2 (always compile as regex) is the simplest — exact strings like "echo" work fine as regex patterns since they match themselves. This avoids the ambiguity entirely.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, option 2 (always compile as regex) is the simplest and eliminates the ambiguity entirely. Implementing this now.

return result if result else None


def create_mcp_client_from_config(server_name: str, config: dict[str, Any]) -> MCPClient:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: create_mcp_client_from_config and parse_tool_filters are public functions (no underscore prefix) but are not included in __all__ in __init__.py.

Suggestion: Either add them to __all__ for discoverability, or prefix them with _ if they're intended to be internal implementation details. Given that create_mcp_client_from_config could be useful for users who want to create a single client from config, consider making it explicitly public.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll prefix both create_mcp_client_from_config and parse_tool_filters with _ since they're internal implementation details. load_mcp_clients_from_config is the intended public entry point.

raise FileNotFoundError(f"MCP configuration file not found: {file_path}")

with open(config_path) as f:
config_dict: dict[str, Any] = json.load(f)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: PR description mentions Claude Desktop / Cursor interoperability, but the function doesn't support the standard Claude Desktop config wrapper format.

Claude Desktop configs typically nest servers under a "mcpServers" key: {"mcpServers": {"server1": {...}}}. The current implementation requires the unwrapped format (just the server dict). Users copying their claude_desktop_config.json would get unexpected results.

Suggestion: Consider auto-detecting and unwrapping the "mcpServers" wrapper key when it's the only top-level key, or when the config is loaded from a file. This would align with the "Embrace common standards" tenet and the interoperability use case from the PR description.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I'll auto-detect and unwrap the "mcpServers" wrapper key to support copy-pasting from Claude Desktop config files.

agent_kwargs["tools"] = tools_list
elif "mcp_servers" in config_dict:
# Empty dict case - still call to maintain consistent behavior
load_mcp_clients_from_config(config_dict["mcp_servers"])
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The elif branch for empty mcp_servers dict is unnecessary and confusing.

When mcp_servers is {}, calling load_mcp_clients_from_config({}) just returns {} with no side effects. The comment says "still call to maintain consistent behavior" but there's no behavior to maintain — it's a no-op.

Suggestion: Simplify to a single if block:

if config_dict.get("mcp_servers"):
    mcp_clients = load_mcp_clients_from_config(config_dict["mcp_servers"])
    tools_list = agent_kwargs.get("tools", [])
    if not isinstance(tools_list, list):
        tools_list = list(tools_list)
    tools_list.extend(mcp_clients.values())
    agent_kwargs["tools"] = tools_list

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, simplifying to a single if block.


# Extract common MCPClient parameters
prefix = config.get("prefix")
startup_timeout = config.get("startup_timeout", 30)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Missing startup_timeout type annotation and validation.

The startup_timeout value is read from JSON config as config.get("startup_timeout", 30) but there's no validation that it's actually a number. A string value like "30" would silently pass through and potentially cause a type error later in MCPClient.

Suggestion: Add basic type validation for startup_timeout (and optionally prefix) in create_mcp_client_from_config, or add JSON schema validation for the individual server config entries.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding type validation for startup_timeout and invalid regex patterns.

env=config.get("env"),
cwd=config.get("cwd"),
)
transport_callable = lambda: stdio_client(params) # noqa: E731
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The noqa: E731 comments for lambda assignments suggest these should be proper functions.

The linter rule E731 flags lambda assignments for good reason — they're harder to debug (stack traces show <lambda> instead of a descriptive name) and can't have docstrings.

Suggestion: Use nested def statements instead:

if transport == "stdio":
    params = StdioServerParameters(...)
    def transport_callable() -> Any:
        return stdio_client(params)

This eliminates the need for noqa suppressions and produces better stack traces.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replacing lambdas with named def statements for better stack traces.

@github-actions
Copy link
Copy Markdown

Assessment: Request Changes

This PR adds a well-structured feature for loading MCP server configurations from JSON files, addressing a real user need for declarative multi-server setup. The overall architecture is sound — separate config module, integration with existing config_to_agent, standalone load_mcp_clients_from_config — and the test coverage is comprehensive across unit and integration tests.

However, there are a few issues that need to be addressed before merge:

Review Categories
  • Committed Artifacts (Critical): Five log files (final_test.log, focused_test.log, full_test.log, install.log, test_output.log) are committed in the PR and must be removed. Consider adding *.log to .gitignore.
  • API Surface: create_mcp_client_from_config and parse_tool_filters are public-looking functions not exported in __all__; _ToolMatcher is a private type imported cross-module. The public/private boundaries should be clarified.
  • Regex Heuristic: The _is_regex_pattern auto-detection is fragile — strings with ., (, $ etc. are silently treated as regex. Consider always compiling as regex (exact strings are valid regex) or using an explicit opt-in syntax.
  • Interoperability: The PR description claims Claude Desktop/Cursor compatibility, but the function doesn't support the standard {"mcpServers": {...}} wrapper format those tools use.
  • Robustness: No validation for config value types (e.g., non-numeric startup_timeout) and no error handling for invalid regex patterns in tool_filters.
  • Documentation: This PR introduces new public API surface (load_mcp_clients_from_config exported from strands.tools.mcp). A corresponding documentation PR at https://github.com/strands-agents/docs is needed.

The feature design aligns well with the SDK tenets, particularly "Simple at any scale" and "Embrace common standards."

@Unshure
Copy link
Copy Markdown
Member Author

Unshure commented Apr 10, 2026

/strands delete the *.log files

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>

_SCHEMA_PATH = Path(__file__).parent / "mcp_server_config.schema.json"
with open(_SCHEMA_PATH) as _f:
MCP_SERVER_CONFIG_SCHEMA: dict[str, Any] = json.load(_f)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Module-level file I/O at import time is a new pattern in this codebase and introduces side effects.

Both mcp_config.py and agent_config.py now read JSON files at the module level. Since agent_config.py imports from mcp_config.py, importing anything from strands.experimental.agent_config triggers two file reads. The original agent_config.py had the schema inline as a Python dict with no file I/O.

Suggestion: Consider either:

  1. Keep schemas as Python dicts inline (consistent with the original pattern and avoids file I/O at import time)
  2. Lazy-load the schemas on first use (e.g., via functools.lru_cache on a loader function)
  3. If external JSON files are preferred for editor/tooling support, use importlib.resources instead of Path(__file__).parent — this is the recommended approach for reading package data files and works correctly even in zipped distributions.

)


def load_mcp_clients_from_config(config: str | dict[str, Any]) -> dict[str, MCPClient]:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: load_mcp_clients_from_config is not exported from strands.experimental.__init__.py.

The PR description shows the import path as from strands.tools.mcp import load_mcp_clients_from_config, but the function has been moved to strands.experimental.mcp_config. Users currently must use from strands.experimental.mcp_config import load_mcp_clients_from_config, which is an internal module path.

Suggestion: Add load_mcp_clients_from_config to strands.experimental.__init__.py's __all__ and imports. This makes the function discoverable via the established experimental namespace and gives users a stable import path:

from strands.experimental import load_mcp_clients_from_config

"mcp_servers": {
"description": "MCP server configurations. Each key is a server name and the value is a server configuration object with transport-specific settings.",
"type": "object",
"additionalProperties": { "$ref": "mcp_server_config.schema.json" }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The $ref in this JSON schema is not resolved by the JSON schema validator — it's manually replaced in Python code at agent_config.py:31.

This means the JSON file is misleading when read standalone: the $ref looks like a standard JSON Schema reference, but it won't work with any standard JSON Schema tooling. Anyone opening this file in an IDE or schema validator would get a resolution error.

Suggestion: Either:

  1. Inline the MCP server schema directly in this file (removes the misleading $ref)
  2. Add a comment in the JSON file explaining the $ref is resolved programmatically
  3. If keeping separate files, consider using the jsonschema RefResolver to properly resolve $refs

def _parse_tool_filters(config: dict[str, Any] | None) -> ToolFilters | None:
"""Parse a tool filter configuration into a ToolFilters instance.

All filter strings are compiled as regex patterns. Exact-match strings like ``"^echo$"``
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Docstring says "^echo$" but the typical user pattern from config will just be "echo".

Since all strings are compiled as regex and re.match is used (not re.fullmatch), "echo" matches "echo_extra" as well. The docstring mentions "^echo$" as an example of exact-match, but users likely won't think to add anchors from a JSON config file.

Suggestion: Update the docstring to be clearer about this behavior:

All filter strings are compiled as regex patterns and matched using ``re.match``
(prefix match). Use ``"^echo$"`` for exact matching, or ``"echo"`` to match any
tool name starting with "echo".

@github-actions
Copy link
Copy Markdown

Issue: The PR description import path is outdated.

The PR description still shows:

from strands.tools.mcp import load_mcp_clients_from_config

But the function now lives in strands.experimental.mcp_config. Please update the PR description to reflect the actual import path.

@github-actions
Copy link
Copy Markdown

Assessment: Request Changes

Great progress from the previous iteration — all prior feedback was addressed thoughtfully. The mcpServers wrapper support, always-compile regex, JSON schema validation, and private function prefixes are all solid improvements.

A few items remain before this is ready:

Review Categories
  • Module Packaging (Important): The JSON schema files are loaded via Path(__file__).parent at module import time — this is a new pattern in this codebase, introduces file I/O side effects on import, and is less robust than importlib.resources for packaged distributions. Consider keeping schemas as Python dicts (matching the original pattern) or lazy-loading.
  • File Placement (Important): The unit test file is at tests/strands/tools/mcp/test_mcp_config.py but the module is strands.experimental.mcp_config — should mirror src/ structure per AGENTS.md. Additionally, AGENTS.md directory structure needs updating for the three new files.
  • Discoverability (Important): load_mcp_clients_from_config isn't exported from strands.experimental.__init__.py, leaving users with no stable public import path. The PR description also shows an outdated import path (strands.tools.mcp).
  • Documentation: This introduces new public API surface. A documentation PR at https://github.com/strands-agents/docs would help users discover and use this feature.

The overall design is clean and the test coverage is thorough across unit, integration, and schema validation layers.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] Support for multiple MCP servers (and loading from config file)

1 participant