This commit is contained in:
X-iZhang
2026-02-07 01:53:46 +00:00
parent 05b098ca63
commit 7b15b3b8a0
8 changed files with 414 additions and 173 deletions
+30 -16
View File
@@ -22,7 +22,7 @@ from deepagents.backends import FilesystemBackend, CompositeBackend
from .backends import CustomSandboxBackend, MergedReadOnlyBackend
from .config import get_effective_config, apply_config_to_env
from .llm import get_chat_model
from .mcp_client import load_mcp_tools
from .mcp import load_mcp_tools
from .middleware import create_skills_middleware, create_memory_middleware
from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt
from .utils import load_subagents
@@ -118,26 +118,40 @@ tool_registry = {
"view_image": view_image,
}
# MCP config path (stable across reloads)
MCP_CONFIG = Path(__file__).parent / "mcp.yaml"
# Base tools that every agent variant gets (before MCP)
BASE_TOOLS = [think_tool, skill_manager, view_image]
def _build_base_kwargs(base_backend, base_middleware):
"""Build agent kwargs *without* MCP (fast, no subprocess spawning)."""
subs = load_subagents(
SUBAGENTS_CONFIG,
tool_registry=tool_registry,
prompt_refs=prompt_refs,
)
return dict(
name="EvoScientist",
model=chat_model,
tools=list(BASE_TOOLS),
backend=base_backend,
subagents=subs,
middleware=base_middleware,
system_prompt=SYSTEM_PROMPT,
)
def load_mcp_and_build_kwargs(base_backend, base_middleware):
"""(Re-)load MCP tools and build agent kwargs.
Called once at import time for the default agent, and again on every
``create_cli_agent()`` call so that ``/new`` picks up MCP config changes.
Called on every ``create_cli_agent()`` call so that ``/new`` picks up
MCP config changes. Falls back to base kwargs if no MCP configured.
"""
# Fresh tool registry each time — start from base tools
mcp_by_agent = load_mcp_tools()
if not mcp_by_agent:
return _build_base_kwargs(base_backend, base_middleware)
# Fresh tool registry — start from base tools + MCP tools
registry = dict(tool_registry)
# Load current MCP config
mcp_by_agent = load_mcp_tools(MCP_CONFIG)
# Register all MCP tools so subagent.yaml can reference them
for tools in mcp_by_agent.values():
for t in tools:
registry[t.name] = t
@@ -175,10 +189,10 @@ base_middleware = [
create_skills_middleware(SKILLS_DIR, user_skills_dir=USER_SKILLS_DIR),
]
# Shared kwargs for agent creation (snapshot at import time)
_AGENT_KWARGS = load_mcp_and_build_kwargs(backend, base_middleware)
# Default agent (no checkpointer) — used by langgraph dev / LangSmith / notebooks
# Default agent (no checkpointer) — used by langgraph dev / LangSmith / notebooks.
# Built WITHOUT MCP at import time to avoid spawning subprocesses on every import.
# MCP tools are loaded on-demand in create_cli_agent().
_AGENT_KWARGS = _build_base_kwargs(backend, base_middleware)
EvoScientist_agent = create_deep_agent(**_AGENT_KWARGS).with_config({"recursion_limit": 500})
+74 -9
View File
@@ -387,11 +387,9 @@ def _cmd_channel(args: str, agent: Any, thread_id: str) -> None:
def _mcp_list_servers() -> None:
"""Print a table of configured MCP servers."""
from .mcp_client import load_mcp_config
from pathlib import Path
from .mcp import load_mcp_config
config_path = Path(__file__).parent / "mcp.yaml"
config = load_mcp_config(config_path)
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
@@ -423,7 +421,7 @@ def _mcp_list_servers() -> None:
def _cmd_mcp_add(args_str: str) -> None:
"""Handle ``/mcp add ...``."""
import shlex
from .mcp_client import add_mcp_server, parse_mcp_add_args
from .mcp import add_mcp_server, parse_mcp_add_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp add <name> <transport> <command-or-url> [args...]")
@@ -457,7 +455,7 @@ def _cmd_mcp_add(args_str: str) -> None:
def _cmd_mcp_edit(args_str: str) -> None:
"""Handle ``/mcp edit <name> --field value ...``."""
import shlex
from .mcp_client import edit_mcp_server, parse_mcp_edit_args
from .mcp import edit_mcp_server, parse_mcp_edit_args
if not args_str.strip():
console.print("[bold]Usage:[/bold] /mcp edit <name> --<field> <value> ...")
@@ -490,7 +488,7 @@ def _cmd_mcp_edit(args_str: str) -> None:
def _cmd_mcp_remove(name: str) -> None:
"""Handle ``/mcp remove <name>``."""
from .mcp_client import remove_mcp_server
from .mcp import remove_mcp_server
if not name.strip():
console.print("[red]Usage:[/red] /mcp remove <name>")
@@ -1152,7 +1150,7 @@ def mcp_add(
evosci mcp add my-api http http://localhost:8080/mcp -H "Authorization:Bearer tok"
evosci mcp add my-sse sse http://localhost:9090/sse -e research-agent
"""
from .mcp_client import add_mcp_server
from .mcp import add_mcp_server
kwargs: dict = {
"name": name,
@@ -1194,12 +1192,79 @@ def mcp_add(
raise typer.Exit(1)
@mcp_app.command("edit")
def mcp_edit(
name: str = typer.Argument(..., help="Server name to edit"),
transport: Optional[str] = typer.Option(None, "--transport", help="New transport type"),
command: Optional[str] = typer.Option(None, "--command", help="New command (stdio)"),
url: Optional[str] = typer.Option(None, "--url", help="New URL (http/sse/websocket)"),
tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist ('none' to clear)"),
expose_to: Optional[str] = typer.Option(None, "--expose-to", "-e", help="Comma-separated target agents ('none' to clear)"),
header: Optional[list[str]] = typer.Option(None, "--header", "-H", help="HTTP header as Key:Value (repeatable)"),
env: Optional[list[str]] = typer.Option(None, "--env", help="Env var as KEY=VALUE for stdio (repeatable)"),
):
"""Edit an existing MCP server in user config.
\b
Examples:
evosci mcp edit filesystem --expose-to main,code-agent
evosci mcp edit filesystem -t read_file,write_file
evosci mcp edit my-api --url http://new-host:9090/mcp
evosci mcp edit my-api --tools none
"""
from .mcp import edit_mcp_server
fields: dict = {}
if transport is not None:
fields["transport"] = transport
if command is not None:
fields["command"] = command
if url is not None:
fields["url"] = url
if tools is not None:
fields["tools"] = None if tools == "none" else [t.strip() for t in tools.split(",") if t.strip()]
if expose_to is not None:
fields["expose_to"] = None if expose_to == "none" else [a.strip() for a in expose_to.split(",") if a.strip()]
if header:
hdr_dict = {}
for h in header:
if ":" in h:
k, v = h.split(":", 1)
hdr_dict[k.strip()] = v.strip()
if hdr_dict:
fields["headers"] = hdr_dict
if env:
env_dict = {}
for e in env:
if "=" in e:
k, v = e.split("=", 1)
env_dict[k.strip()] = v.strip()
if env_dict:
fields["env"] = env_dict
if not fields:
console.print("[red]No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc.[/red]")
raise typer.Exit(1)
try:
edit_mcp_server(name, **fields)
console.print(f"[green]Updated MCP server:[/green] [cyan]{name}[/cyan]")
for k, v in fields.items():
console.print(f" [dim]{k}:[/dim] {v}")
except KeyError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(1)
except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]")
raise typer.Exit(1)
@mcp_app.command("remove")
def mcp_remove(
name: str = typer.Argument(..., help="Server name to remove"),
):
"""Remove an MCP server from user config."""
from .mcp_client import remove_mcp_server
from .mcp import remove_mcp_server
if remove_mcp_server(name):
console.print(f"[green]Removed MCP server:[/green] [cyan]{name}[/cyan]")
-26
View File
@@ -1,26 +0,0 @@
# MCP Server Configuration
# ========================
# Configure MCP servers to expose external tools to EvoScientist agents.
#
# Each key is a server name. Supported transports: stdio, http, sse, websocket.
#
# User-level config: ~/.config/evoscientist/mcp.yaml (merged on top of this file)
#
# Examples:
#
# brave-search:
# transport: http
# url: "http://localhost:8080/mcp"
# headers:
# Authorization: "Bearer ${BRAVE_API_KEY}"
# expose_to: [research-agent]
#
# local-sse-server:
# transport: sse
# url: "http://localhost:9090/sse"
# expose_to: [main]
#
# ws-server:
# transport: websocket
# url: "ws://localhost:8765"
# expose_to: [main]
+98
View File
@@ -0,0 +1,98 @@
# MCP (Model Context Protocol) Integration
Optional feature for connecting external tools to EvoScientist agents via [MCP](https://modelcontextprotocol.io/).
## Install
```bash
pip install "evoscientist[mcp]"
```
## Quick Start
Edit `~/.config/evoscientist/mcp.yaml`:
```yaml
# Local process (stdio) — most common
my-server:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
env:
GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_TOKEN}"
# Remote server (http/sse/websocket)
my-api:
transport: http
url: "http://localhost:8080/mcp"
headers:
Authorization: "Bearer ${API_KEY}"
```
Then restart the agent (`/new` in interactive mode).
## Config Fields
| Field | Required | Description |
|-------|----------|-------------|
| `transport` | Yes | `stdio`, `http`, `sse`, `websocket` |
| `command` | stdio only | Command to run (e.g. `npx`) |
| `args` | stdio only | Arguments list (varies per MCP package) |
| `env` | No | Environment variables for subprocess |
| `url` | http/sse/ws | Server URL |
| `headers` | No | HTTP headers (e.g. auth tokens) |
| `tools` | No | Tool allowlist (omit = all tools) |
| `expose_to` | No | Target agents (default: `["main"]`) |
## Tool Routing
Use `expose_to` to send tools to specific sub-agents:
```yaml
postgres:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-postgres", "postgresql://localhost/mydb"]
expose_to: [data-analysis-agent]
github:
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
expose_to: [main, research-agent]
```
Available agents: `main`, `planner-agent`, `research-agent`, `code-agent`, `debug-agent`, `data-analysis-agent`, `writing-agent`.
## CLI Commands
```
/mcp List configured servers
/mcp add <name> <transport> <command-or-url> [args...]
/mcp edit <name> --field value
/mcp remove <name>
```
Or from the terminal: `EvoSci mcp list`, `EvoSci mcp add ...`, etc.
## Environment Variables
Use `${VAR}` in YAML values to reference environment variables:
```yaml
headers:
Authorization: "Bearer ${MY_API_KEY}"
```
Missing variables are replaced with empty string and logged as a warning.
## How It Works
1. On agent startup (`/new` or `create_cli_agent()`), reads `~/.config/evoscientist/mcp.yaml`
2. Connects to each server via the configured transport
3. Retrieves available tools from each server
4. Filters tools by `tools` allowlist (if set)
5. Routes tools to target agents by `expose_to`
6. Tools are injected into the agent's tool list automatically
MCP servers that fail to connect are skipped with a warning — they don't block startup.
+29
View File
@@ -0,0 +1,29 @@
"""MCP (Model Context Protocol) integration — optional external tool support.
Install with: pip install "evoscientist[mcp]"
See mcp/README.md for usage details.
"""
from .client import (
load_mcp_config,
load_mcp_tools,
aload_mcp_tools,
add_mcp_server,
edit_mcp_server,
remove_mcp_server,
parse_mcp_add_args,
parse_mcp_edit_args,
VALID_TRANSPORTS,
)
__all__ = [
"load_mcp_config",
"load_mcp_tools",
"aload_mcp_tools",
"add_mcp_server",
"edit_mcp_server",
"remove_mcp_server",
"parse_mcp_add_args",
"parse_mcp_edit_args",
"VALID_TRANSPORTS",
]
@@ -15,11 +15,12 @@ from typing import Any
import yaml
logger = logging.getLogger(__name__)
# User-level config path
USER_CONFIG_DIR = Path.home() / ".config" / "evoscientist"
USER_MCP_CONFIG = USER_CONFIG_DIR / "mcp.yaml"
# =============================================================================
# Constants
# =============================================================================
# Regex for ${VAR} env var interpolation
ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
@@ -27,12 +28,34 @@ ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}")
# Supported transport protocols
VALID_TRANSPORTS = {"stdio", "http", "streamable_http", "sse", "websocket"}
# URL-based transports (share the same connection shape)
_URL_TRANSPORTS = {"http", "streamable_http", "sse", "websocket"}
def _get_mcp_config_dir() -> Path:
"""Get the MCP configuration directory, respecting XDG_CONFIG_HOME."""
xdg_config = os.environ.get("XDG_CONFIG_HOME")
if xdg_config:
return Path(xdg_config) / "evoscientist"
return Path.home() / ".config" / "evoscientist"
# User-level config path
USER_CONFIG_DIR = _get_mcp_config_dir()
USER_MCP_CONFIG = USER_CONFIG_DIR / "mcp.yaml"
# =============================================================================
# Environment variable interpolation
# =============================================================================
def _interpolate_env(value: str) -> str:
"""Replace ``${VAR}`` patterns in *value* with environment variable values.
"""Replace ``${VAR}`` patterns with environment variable values.
Missing variables are replaced with an empty string and a warning is logged.
"""
def _replace(match: re.Match) -> str:
var = match.group(1)
val = os.environ.get(var)
@@ -55,6 +78,11 @@ def _interpolate_value(value: Any) -> Any:
return value
# =============================================================================
# User config persistence
# =============================================================================
def _load_user_config() -> dict[str, Any]:
"""Load the user-level MCP config, returning an empty dict if absent."""
if USER_MCP_CONFIG.is_file():
@@ -69,7 +97,14 @@ def _load_user_config() -> dict[str, Any]:
def _save_user_config(config: dict[str, Any]) -> None:
"""Write *config* to the user-level MCP config file."""
USER_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
USER_MCP_CONFIG.write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
USER_MCP_CONFIG.write_text(
yaml.dump(config, default_flow_style=False, sort_keys=False)
)
# =============================================================================
# CRUD operations
# =============================================================================
def add_mcp_server(
@@ -154,7 +189,7 @@ def edit_mcp_server(name: str, **fields: Any) -> dict[str, Any]:
)
if transport == "stdio" and not entry.get("command"):
raise ValueError("stdio transport requires a command")
if transport in ("http", "streamable_http", "sse", "websocket") and not entry.get("url"):
if transport in _URL_TRANSPORTS and not entry.get("url"):
raise ValueError(f"{transport} transport requires a url")
user_cfg[name] = entry
@@ -175,6 +210,11 @@ def remove_mcp_server(name: str) -> bool:
return True
# =============================================================================
# CLI argument parsing
# =============================================================================
def parse_mcp_add_args(tokens: list[str]) -> dict:
"""Parse CLI tokens for ``/mcp add`` into kwargs for :func:`add_mcp_server`.
@@ -231,14 +271,18 @@ def parse_mcp_add_args(tokens: list[str]) -> dict:
if transport == "stdio":
if not positional:
raise ValueError("stdio transport requires a command after the transport name")
raise ValueError(
"stdio transport requires a command after the transport name"
)
kwargs["command"] = positional[0]
kwargs["args"] = positional[1:]
if env:
kwargs["env"] = env
else:
if not positional:
raise ValueError(f"{transport} transport requires a url after the transport name")
raise ValueError(
f"{transport} transport requires a url after the transport name"
)
kwargs["url"] = positional[0]
if headers:
kwargs["headers"] = headers
@@ -290,11 +334,19 @@ def parse_mcp_edit_args(tokens: list[str]) -> tuple[str, dict]:
i += 2
elif tok == "--tools" and i + 1 < len(tokens):
val = tokens[i + 1]
fields["tools"] = None if val == "none" else [t.strip() for t in val.split(",") if t.strip()]
fields["tools"] = (
None
if val == "none"
else [t.strip() for t in val.split(",") if t.strip()]
)
i += 2
elif tok == "--expose-to" and i + 1 < len(tokens):
val = tokens[i + 1]
fields["expose_to"] = None if val == "none" else [a.strip() for a in val.split(",") if a.strip()]
fields["expose_to"] = (
None
if val == "none"
else [a.strip() for a in val.split(",") if a.strip()]
)
i += 2
elif tok == "--header" and i + 1 < len(tokens):
kv = tokens[i + 1]
@@ -317,53 +369,42 @@ def parse_mcp_edit_args(tokens: list[str]) -> tuple[str, dict]:
fields["env"] = env
if not fields:
raise ValueError("No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc.")
raise ValueError(
"No fields to edit. Use --transport, --command, --url, --tools, --expose-to, etc."
)
return name, fields
def load_mcp_config(config_path: str | Path | None = None) -> dict[str, Any]:
"""Load and merge MCP configuration.
# =============================================================================
# Config loading & merging
# =============================================================================
Merges package-level config (shipped with EvoScientist) with user-level
config at ``~/.config/evoscientist/mcp.yaml``. User config wins on
conflict.
def load_mcp_config() -> dict[str, Any]:
"""Load MCP configuration from user config.
Reads ``~/.config/evoscientist/mcp.yaml`` and interpolates ``${VAR}``
environment variable references.
Returns an empty dict if no servers are configured (MCP is optional).
"""
merged: dict[str, Any] = {}
if not USER_MCP_CONFIG.is_file():
return {}
# 1. Package-level config
if config_path:
pkg_path = Path(config_path)
if pkg_path.is_file():
try:
data = yaml.safe_load(pkg_path.read_text()) or {}
if isinstance(data, dict):
merged.update(data)
except Exception as exc:
logger.warning("Failed to load MCP config %s: %s", pkg_path, exc)
try:
data = yaml.safe_load(USER_MCP_CONFIG.read_text()) or {}
if not isinstance(data, dict):
return {}
except Exception as exc:
logger.warning("Failed to load MCP config %s: %s", USER_MCP_CONFIG, exc)
return {}
# 2. User-level config (overrides package-level)
if USER_MCP_CONFIG.is_file():
try:
data = yaml.safe_load(USER_MCP_CONFIG.read_text()) or {}
if isinstance(data, dict):
merged.update(data)
except Exception as exc:
logger.warning("Failed to load user MCP config %s: %s", USER_MCP_CONFIG, exc)
# Interpolate env vars across all values
merged = _interpolate_value(merged)
return merged
return _interpolate_value(data)
def _build_connections(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Convert our YAML config to ``MultiServerMCPClient`` connections format.
Each server entry maps to one connection dict with the fields that
``MultiServerMCPClient`` expects for the given transport.
"""Convert YAML config to ``MultiServerMCPClient`` connections format.
Unknown transports are skipped with a warning.
"""
@@ -382,7 +423,7 @@ def _build_connections(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
conn["env"] = server["env"]
connections[name] = conn
elif transport in ("http", "streamable_http"):
elif transport in _URL_TRANSPORTS:
conn = {
"transport": transport,
"url": server.get("url", ""),
@@ -391,28 +432,19 @@ def _build_connections(config: dict[str, Any]) -> dict[str, dict[str, Any]]:
conn["headers"] = server["headers"]
connections[name] = conn
elif transport == "sse":
conn = {
"transport": "sse",
"url": server.get("url", ""),
}
if "headers" in server:
conn["headers"] = server["headers"]
connections[name] = conn
elif transport == "websocket":
conn = {
"transport": "websocket",
"url": server.get("url", ""),
}
connections[name] = conn
else:
logger.warning("MCP server %r: unknown transport %r, skipping", name, transport)
logger.warning(
"MCP server %r: unknown transport %r, skipping", name, transport
)
return connections
# =============================================================================
# Tool loading, filtering & routing
# =============================================================================
def _filter_tools(tools: list, allowed_names: list[str] | None) -> list:
"""Filter tools by allowlist.
@@ -462,8 +494,17 @@ async def _load_tools(config: dict[str, Any]) -> dict[str, list]:
"""Connect to MCP servers and retrieve tools.
Returns a dict of server name -> list of LangChain tools.
Raises:
ImportError: if ``langchain-mcp-adapters`` is not installed.
"""
from langchain_mcp_adapters.client import MultiServerMCPClient
try:
from langchain_mcp_adapters.client import MultiServerMCPClient
except ImportError:
raise ImportError(
"MCP servers are configured but langchain-mcp-adapters is not installed.\n"
"Install with: pip install 'evoscientist[mcp]'"
)
connections = _build_connections(config)
if not connections:
@@ -476,9 +517,7 @@ async def _load_tools(config: dict[str, Any]) -> dict[str, list]:
try:
tools = await client.get_tools(server_name=server_name)
server_tools[server_name] = tools
logger.info(
"MCP server %r: loaded %d tool(s)", server_name, len(tools)
)
logger.info("MCP server %r: loaded %d tool(s)", server_name, len(tools))
except Exception as exc:
logger.warning("MCP server %r: failed to load tools: %s", server_name, exc)
server_tools[server_name] = []
@@ -486,11 +525,27 @@ async def _load_tools(config: dict[str, Any]) -> dict[str, list]:
return server_tools
def load_mcp_tools(config_path: str | Path | None = None) -> dict[str, list]:
async def aload_mcp_tools() -> dict[str, list]:
"""Async version of :func:`load_mcp_tools`.
Prefer this when already inside an async context (e.g. Jupyter, async CLI).
"""
config = load_mcp_config()
if not config:
return {}
try:
server_tools = await _load_tools(config)
except Exception as exc:
logger.warning("MCP tool loading failed: %s", exc)
return {}
return _route_tools(config, server_tools)
def load_mcp_tools() -> dict[str, list]:
"""Load MCP tools and return them grouped by target agent.
This is the main entry point. It:
1. Loads and merges YAML configs (package + user level)
This is the main synchronous entry point. It:
1. Loads user config from ``~/.config/evoscientist/mcp.yaml``
2. Connects to each configured MCP server
3. Filters tools per server allowlist
4. Routes tools to target agents
@@ -500,22 +555,22 @@ def load_mcp_tools(config_path: str | Path | None = None) -> dict[str, list]:
Key ``"main"`` = main agent. Other keys = subagent names.
Returns empty dict if no MCP servers are configured.
"""
config = load_mcp_config(config_path)
config = load_mcp_config()
if not config:
return {}
# Run async loader — use nest_asyncio for Jupyter compatibility
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = None
if loop and loop.is_running():
# Inside an already-running event loop (e.g. Jupyter)
import nest_asyncio
nest_asyncio.apply()
try:
if loop and loop.is_running():
# Inside an already-running event loop (e.g. Jupyter) —
# nest_asyncio patches the loop so asyncio.run() works.
import nest_asyncio
nest_asyncio.apply()
server_tools = asyncio.run(_load_tools(config))
except Exception as exc:
logger.warning("MCP tool loading failed: %s", exc)
+4 -2
View File
@@ -33,10 +33,12 @@ dependencies = [
"httpx>=0.27",
"markdownify>=0.14",
"nest-asyncio>=1.6",
"langchain-mcp-adapters>=0.1",
]
[project.optional-dependencies]
mcp = [
"langchain-mcp-adapters>=0.1",
]
dev = [
"pytest>=8.0",
"pytest-cov>=5.0",
@@ -63,7 +65,7 @@ build-backend = "setuptools.build_meta"
include = ["EvoScientist*"]
[tool.setuptools.package-data]
EvoScientist = ["subagent.yaml", "mcp.yaml", "skills/**/*"]
EvoScientist = ["subagent.yaml", "skills/**/*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+48 -44
View File
@@ -1,4 +1,4 @@
"""Tests for EvoScientist.mcp_client module."""
"""Tests for EvoScientist.mcp module."""
import textwrap
from types import SimpleNamespace
@@ -6,7 +6,7 @@ from types import SimpleNamespace
import pytest
import yaml
from EvoScientist.mcp_client import (
from EvoScientist.mcp.client import (
_interpolate_env,
_filter_tools,
_route_tools,
@@ -48,60 +48,53 @@ class TestInterpolateEnv:
@pytest.fixture()
def _no_user_mcp(monkeypatch, tmp_path):
"""Isolate load_mcp_config tests from the real user config."""
monkeypatch.setattr(
"EvoScientist.mcp_client.USER_MCP_CONFIG",
tmp_path / "no_user_mcp.yaml",
)
def mcp_config_file(monkeypatch, tmp_path):
"""Point USER_MCP_CONFIG to a temp file for isolated testing."""
cfg = tmp_path / "mcp.yaml"
monkeypatch.setattr("EvoScientist.mcp.client.USER_MCP_CONFIG", cfg)
return cfg
class TestLoadMcpConfig:
def test_missing_file_returns_empty(self, tmp_path, _no_user_mcp):
result = load_mcp_config(tmp_path / "nonexistent.yaml")
assert result == {}
def test_missing_file_returns_empty(self, mcp_config_file):
# File doesn't exist yet
assert load_mcp_config() == {}
def test_valid_file_parses(self, tmp_path, _no_user_mcp):
cfg = tmp_path / "mcp.yaml"
cfg.write_text(textwrap.dedent("""\
def test_valid_file_parses(self, mcp_config_file):
mcp_config_file.write_text(
textwrap.dedent("""\
my-server:
transport: stdio
command: echo
args: ["hello"]
"""))
result = load_mcp_config(cfg)
""")
)
result = load_mcp_config()
assert "my-server" in result
assert result["my-server"]["transport"] == "stdio"
def test_empty_file_returns_empty(self, tmp_path, _no_user_mcp):
cfg = tmp_path / "mcp.yaml"
cfg.write_text("")
result = load_mcp_config(cfg)
assert result == {}
def test_empty_file_returns_empty(self, mcp_config_file):
mcp_config_file.write_text("")
assert load_mcp_config() == {}
def test_comments_only_returns_empty(self, tmp_path, _no_user_mcp):
cfg = tmp_path / "mcp.yaml"
cfg.write_text("# just a comment\n# another comment\n")
result = load_mcp_config(cfg)
assert result == {}
def test_comments_only_returns_empty(self, mcp_config_file):
mcp_config_file.write_text("# just a comment\n# another comment\n")
assert load_mcp_config() == {}
def test_env_var_interpolation(self, tmp_path, _no_user_mcp, monkeypatch):
def test_env_var_interpolation(self, mcp_config_file, monkeypatch):
monkeypatch.setenv("TEST_TOKEN", "tok_abc")
cfg = tmp_path / "mcp.yaml"
cfg.write_text(textwrap.dedent("""\
mcp_config_file.write_text(
textwrap.dedent("""\
my-server:
transport: http
url: "http://localhost:8080/mcp"
headers:
Authorization: "Bearer ${TEST_TOKEN}"
"""))
result = load_mcp_config(cfg)
""")
)
result = load_mcp_config()
assert result["my-server"]["headers"]["Authorization"] == "Bearer tok_abc"
def test_none_config_path_returns_empty(self, _no_user_mcp):
result = load_mcp_config(None)
assert result == {}
# ---- _build_connections ----
@@ -286,8 +279,8 @@ def user_mcp_dir(tmp_path, monkeypatch):
cfg_dir = tmp_path / "config"
cfg_dir.mkdir()
cfg_file = cfg_dir / "mcp.yaml"
monkeypatch.setattr("EvoScientist.mcp_client.USER_CONFIG_DIR", cfg_dir)
monkeypatch.setattr("EvoScientist.mcp_client.USER_MCP_CONFIG", cfg_file)
monkeypatch.setattr("EvoScientist.mcp.client.USER_CONFIG_DIR", cfg_dir)
monkeypatch.setattr("EvoScientist.mcp.client.USER_MCP_CONFIG", cfg_file)
return cfg_file
@@ -305,7 +298,8 @@ class TestAddMcpServer:
def test_add_http_server(self, user_mcp_dir):
entry = add_mcp_server(
"api", "http",
"api",
"http",
url="http://localhost:8080/mcp",
headers={"Authorization": "Bearer tok"},
)
@@ -322,8 +316,10 @@ class TestAddMcpServer:
def test_add_with_tools_and_expose_to(self, user_mcp_dir):
entry = add_mcp_server(
"fs", "stdio",
command="npx", args=[],
"fs",
"stdio",
command="npx",
args=[],
tools=["read_file"],
expose_to=["main", "code-agent"],
)
@@ -400,11 +396,15 @@ class TestParseMcpAddArgs:
assert r["tools"] == ["a", "b"]
def test_expose_to_flag(self):
r = parse_mcp_add_args(["srv", "http", "http://x", "--expose-to", "main,code-agent"])
r = parse_mcp_add_args(
["srv", "http", "http://x", "--expose-to", "main,code-agent"]
)
assert r["expose_to"] == ["main", "code-agent"]
def test_header_flag(self):
r = parse_mcp_add_args(["srv", "http", "http://x", "--header", "Authorization:Bearer tok"])
r = parse_mcp_add_args(
["srv", "http", "http://x", "--header", "Authorization:Bearer tok"]
)
assert r["headers"] == {"Authorization": "Bearer tok"}
def test_env_flag(self):
@@ -466,8 +466,12 @@ class TestEditMcpServer:
def test_edit_preserves_unrelated_fields(self, user_mcp_dir):
add_mcp_server(
"fs", "stdio", command="npx", args=["-y", "srv"],
tools=["a"], expose_to=["main"],
"fs",
"stdio",
command="npx",
args=["-y", "srv"],
tools=["a"],
expose_to=["main"],
)
entry = edit_mcp_server("fs", expose_to=["code-agent"])
assert entry["tools"] == ["a"]