From 7b15b3b8a0bd94e62ce356900ee4582f203453d2 Mon Sep 17 00:00:00 2001 From: X-iZhang Date: Sat, 7 Feb 2026 01:53:46 +0000 Subject: [PATCH] update --- EvoScientist/EvoScientist.py | 46 ++-- EvoScientist/cli.py | 83 ++++++- EvoScientist/mcp.yaml | 26 --- EvoScientist/mcp/README.md | 98 +++++++++ EvoScientist/mcp/__init__.py | 29 +++ EvoScientist/{mcp_client.py => mcp/client.py} | 207 +++++++++++------- pyproject.toml | 6 +- tests/test_mcp_client.py | 92 ++++---- 8 files changed, 414 insertions(+), 173 deletions(-) delete mode 100644 EvoScientist/mcp.yaml create mode 100644 EvoScientist/mcp/README.md create mode 100644 EvoScientist/mcp/__init__.py rename EvoScientist/{mcp_client.py => mcp/client.py} (73%) diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 2b27b46..b4d4b76 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -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}) diff --git a/EvoScientist/cli.py b/EvoScientist/cli.py index e6b3e4c..8e96bf1 100644 --- a/EvoScientist/cli.py +++ b/EvoScientist/cli.py @@ -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 [args...]") @@ -457,7 +455,7 @@ def _cmd_mcp_add(args_str: str) -> None: def _cmd_mcp_edit(args_str: str) -> None: """Handle ``/mcp edit --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 -- ...") @@ -490,7 +488,7 @@ def _cmd_mcp_edit(args_str: str) -> None: def _cmd_mcp_remove(name: str) -> None: """Handle ``/mcp remove ``.""" - from .mcp_client import remove_mcp_server + from .mcp import remove_mcp_server if not name.strip(): console.print("[red]Usage:[/red] /mcp remove ") @@ -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]") diff --git a/EvoScientist/mcp.yaml b/EvoScientist/mcp.yaml deleted file mode 100644 index 47301e1..0000000 --- a/EvoScientist/mcp.yaml +++ /dev/null @@ -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] diff --git a/EvoScientist/mcp/README.md b/EvoScientist/mcp/README.md new file mode 100644 index 0000000..c5ef185 --- /dev/null +++ b/EvoScientist/mcp/README.md @@ -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 [args...] +/mcp edit --field value +/mcp remove +``` + +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. diff --git a/EvoScientist/mcp/__init__.py b/EvoScientist/mcp/__init__.py new file mode 100644 index 0000000..506058f --- /dev/null +++ b/EvoScientist/mcp/__init__.py @@ -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", +] diff --git a/EvoScientist/mcp_client.py b/EvoScientist/mcp/client.py similarity index 73% rename from EvoScientist/mcp_client.py rename to EvoScientist/mcp/client.py index ce2d63c..80f9aaa 100644 --- a/EvoScientist/mcp_client.py +++ b/EvoScientist/mcp/client.py @@ -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) diff --git a/pyproject.toml b/pyproject.toml index 8f47799..c192d00 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py index 78a5845..efaada5 100644 --- a/tests/test_mcp_client.py +++ b/tests/test_mcp_client.py @@ -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"]