diff --git a/EvoScientist/EvoScientist.py b/EvoScientist/EvoScientist.py index 3479641..2b27b46 100644 --- a/EvoScientist/EvoScientist.py +++ b/EvoScientist/EvoScientist.py @@ -22,6 +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 .middleware import create_skills_middleware, create_memory_middleware from .prompts import RESEARCHER_INSTRUCTIONS, get_system_prompt from .utils import load_subagents @@ -117,29 +118,65 @@ 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 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. + """ + # Fresh tool registry each time — start from base 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 + + mcp_main = mcp_by_agent.pop("main", []) + + subs = load_subagents( + SUBAGENTS_CONFIG, + tool_registry=registry, + prompt_refs=prompt_refs, + ) + + # Inject MCP tools into subagents by name + for sa in subs: + if sa_tools := mcp_by_agent.get(sa["name"], []): + sa.setdefault("tools", []).extend(sa_tools) + + return dict( + name="EvoScientist", + model=chat_model, + tools=BASE_TOOLS + mcp_main, + backend=base_backend, + subagents=subs, + middleware=base_middleware, + system_prompt=SYSTEM_PROMPT, + ) + + prompt_refs = { "RESEARCHER_INSTRUCTIONS": RESEARCHER_INSTRUCTIONS.format(date=current_date), } -subagents = load_subagents( - SUBAGENTS_CONFIG, - tool_registry=tool_registry, - prompt_refs=prompt_refs, -) +base_middleware = [ + create_memory_middleware(MEMORY_DIR, extraction_model=chat_model), + create_skills_middleware(SKILLS_DIR, user_skills_dir=USER_SKILLS_DIR), +] -# Shared kwargs for agent creation -_AGENT_KWARGS = dict( - name="EvoScientist", - model=chat_model, - tools=[think_tool, skill_manager, view_image], - backend=backend, - subagents=subagents, - middleware=[ - create_memory_middleware(MEMORY_DIR, extraction_model=chat_model), - create_skills_middleware(SKILLS_DIR, user_skills_dir=USER_SKILLS_DIR), - ], - system_prompt=SYSTEM_PROMPT, -) +# 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 EvoScientist_agent = create_deep_agent(**_AGENT_KWARGS).with_config({"recursion_limit": 500}) @@ -178,17 +215,16 @@ def create_cli_agent(workspace_dir: str | None = None): "/memory/": mem_backend, }, ) - mw = [ - create_memory_middleware(MEMORY_DIR, extraction_model=chat_model), - create_skills_middleware(SKILLS_DIR, user_skills_dir=USER_SKILLS_DIR), - ] - kwargs = dict( - _AGENT_KWARGS, - backend=be, - middleware=mw, - ) else: - kwargs = dict(_AGENT_KWARGS) + be = backend + + mw = [ + create_memory_middleware(MEMORY_DIR, extraction_model=chat_model), + create_skills_middleware(SKILLS_DIR, user_skills_dir=USER_SKILLS_DIR), + ] + + # Re-load MCP tools from current config (picks up /mcp add changes) + kwargs = load_mcp_and_build_kwargs(be, mw) return create_deep_agent( **kwargs, diff --git a/EvoScientist/cli.py b/EvoScientist/cli.py index 26dbb5a..25507d8 100644 --- a/EvoScientist/cli.py +++ b/EvoScientist/cli.py @@ -235,6 +235,8 @@ def print_banner( info.append("/install-skill", style="bold") info.append(", ", style="dim") info.append("/uninstall-skill", style="bold") + info.append(", ", style="dim") + info.append("/mcp", style="bold") console.print(info) console.print() @@ -400,6 +402,148 @@ def _cmd_channel(args: str, agent: Any, thread_id: str) -> None: console.print("[dim]Use[/dim] /channel stop [dim]to disconnect[/dim]\n") +def _mcp_list_servers() -> None: + """Print a table of configured MCP servers.""" + from .mcp_client import load_mcp_config + from pathlib import Path + + config_path = Path(__file__).parent / "mcp.yaml" + config = load_mcp_config(config_path) + + if not config: + console.print("[dim]No MCP servers configured.[/dim]") + console.print("[dim]Add one with:[/dim] /mcp add [args...]") + console.print() + return + + table = Table(title="MCP Servers", show_header=True) + table.add_column("Server", style="cyan") + table.add_column("Transport", style="green") + table.add_column("Tools", style="yellow") + table.add_column("Expose To", style="magenta") + + for name, server in config.items(): + transport = server.get("transport", "?") + tools = server.get("tools") + tools_str = ", ".join(tools) if tools else "(all)" + expose_to = server.get("expose_to", ["main"]) + if isinstance(expose_to, str): + expose_to = [expose_to] + expose_str = ", ".join(expose_to) + table.add_row(name, transport, tools_str, expose_str) + + console.print(table) + console.print("\n[dim]User config:[/dim] [cyan]~/.config/evoscientist/mcp.yaml[/cyan]") + console.print() + + +def _cmd_mcp_add(args_str: str) -> None: + """Handle ``/mcp add ...``.""" + import shlex + from .mcp_client import add_mcp_server, parse_mcp_add_args + + if not args_str.strip(): + console.print("[bold]Usage:[/bold] /mcp add [args...]") + console.print() + console.print("[dim]Transports:[/dim] stdio, http, sse, websocket") + console.print() + console.print("[bold]Examples:[/bold]") + console.print(" /mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /tmp") + console.print(" /mcp add my-api http http://localhost:8080/mcp --header Authorization:Bearer\\ tok") + console.print(" /mcp add my-api sse http://localhost:9090/sse --expose-to research-agent") + console.print() + console.print("[dim]Options:[/dim]") + console.print(" --tools t1,t2 Tool allowlist") + console.print(" --expose-to a1,a2 Target agents (default: main)") + console.print(" --header Key:Value HTTP header (repeatable)") + console.print(" --env KEY=VALUE Env var for stdio (repeatable)") + console.print() + return + + try: + tokens = shlex.split(args_str) + kwargs = parse_mcp_add_args(tokens) + entry = add_mcp_server(**kwargs) + console.print(f"[green]Added MCP server:[/green] [cyan]{kwargs['name']}[/cyan] ({entry['transport']})") + console.print("[dim]Reload the agent with /new to activate.[/dim]") + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + console.print() + + +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 + + if not args_str.strip(): + console.print("[bold]Usage:[/bold] /mcp edit -- ...") + console.print() + console.print("[dim]Fields:[/dim] --transport, --command, --url, --args, --tools, --expose-to, --header, --env") + console.print("[dim]Use[/dim] --tools none [dim]or[/dim] --expose-to none [dim]to clear a field.[/dim]") + console.print() + console.print("[bold]Examples:[/bold]") + console.print(" /mcp edit filesystem --expose-to main,code-agent") + console.print(" /mcp edit filesystem --tools read_file,write_file") + console.print(" /mcp edit my-api --url http://new-host:8080/mcp") + console.print(" /mcp edit my-api --tools none") + console.print() + return + + try: + tokens = shlex.split(args_str) + name, fields = parse_mcp_edit_args(tokens) + 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}") + console.print("[dim]Reload the agent with /new to apply.[/dim]") + except KeyError as exc: + console.print(f"[red]{exc}[/red]") + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + console.print() + + +def _cmd_mcp_remove(name: str) -> None: + """Handle ``/mcp remove ``.""" + from .mcp_client import remove_mcp_server + + if not name.strip(): + console.print("[red]Usage:[/red] /mcp remove ") + console.print() + return + + if remove_mcp_server(name.strip()): + console.print(f"[green]Removed MCP server:[/green] [cyan]{name.strip()}[/cyan]") + console.print("[dim]Reload the agent with /new to apply.[/dim]") + else: + console.print(f"[red]Server not found:[/red] {name.strip()}") + console.print() + + +def _cmd_mcp(args: str) -> None: + """Dispatch ``/mcp`` subcommands.""" + args = args.strip() + + if not args or args == "list": + _mcp_list_servers() + elif args.startswith("add"): + _cmd_mcp_add(args[3:].strip()) + elif args.startswith("edit"): + _cmd_mcp_edit(args[4:].strip()) + elif args.startswith("remove"): + _cmd_mcp_remove(args[6:].strip()) + else: + console.print("[bold]MCP commands:[/bold]") + console.print(" /mcp List configured servers") + console.print(" /mcp list List configured servers") + console.print(" /mcp add ... Add a server") + console.print(" /mcp edit ... Edit an existing server") + console.print(" /mcp remove ... Remove a server") + console.print() + + def _cmd_channel_stop() -> None: """Stop background iMessage channel.""" if not _ChannelState.is_running(): @@ -698,6 +842,10 @@ def cmd_interactive( _cmd_uninstall_skill(name) continue + if user_input.lower().startswith("/mcp"): + _cmd_mcp(user_input[4:]) + continue + if user_input.lower().startswith("/channel"): args = user_input[len("/channel"):].strip() if args.lower() == "stop": @@ -810,6 +958,10 @@ app = typer.Typer(no_args_is_help=False, add_completion=False) config_app = typer.Typer(help="Configuration management commands", invoke_without_command=True) app.add_typer(config_app, name="config") +# MCP subcommand group +mcp_app = typer.Typer(help="MCP server management commands", invoke_without_command=True) +app.add_typer(mcp_app, name="mcp") + # ============================================================================= # Onboard command @@ -939,6 +1091,98 @@ def config_path(): console.print(f"{path} ({status})") +# ============================================================================= +# MCP commands +# ============================================================================= + +@mcp_app.callback(invoke_without_command=True) +def mcp_callback(ctx: typer.Context): + """MCP server management commands.""" + if ctx.invoked_subcommand is None: + mcp_list() + + +@mcp_app.command("list") +def mcp_list(): + """List configured MCP servers.""" + _mcp_list_servers() + + +@mcp_app.command("add") +def mcp_add( + name: str = typer.Argument(..., help="Server name"), + transport: str = typer.Argument(..., help="Transport: stdio, http, sse, websocket"), + target: str = typer.Argument(..., help="Command (stdio) or URL (http/sse/websocket)"), + args: Optional[list[str]] = typer.Argument(None, help="Extra args for stdio command"), + tools: Optional[str] = typer.Option(None, "--tools", "-t", help="Comma-separated tool allowlist"), + expose_to: Optional[str] = typer.Option(None, "--expose-to", "-e", help="Comma-separated target agents"), + 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)"), +): + """Add an MCP server to user config. + + \b + Examples: + evosci mcp add filesystem stdio npx -- -y @modelcontextprotocol/server-filesystem /tmp + 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 + + kwargs: dict = { + "name": name, + "transport": transport, + } + + if transport == "stdio": + kwargs["command"] = target + kwargs["args"] = list(args) if args else [] + 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: + kwargs["env"] = env_dict + else: + kwargs["url"] = target + 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: + kwargs["headers"] = hdr_dict + + if tools: + kwargs["tools"] = [t.strip() for t in tools.split(",") if t.strip()] + if expose_to: + kwargs["expose_to"] = [a.strip() for a in expose_to.split(",") if a.strip()] + + try: + entry = add_mcp_server(**kwargs) + console.print(f"[green]Added MCP server:[/green] [cyan]{name}[/cyan] ({entry['transport']})") + 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 + + if remove_mcp_server(name): + console.print(f"[green]Removed MCP server:[/green] [cyan]{name}[/cyan]") + else: + console.print(f"[red]Server not found:[/red] {name}") + raise typer.Exit(1) + + # ============================================================================= # Main callback (default behavior) # ============================================================================= diff --git a/EvoScientist/mcp.yaml b/EvoScientist/mcp.yaml new file mode 100644 index 0000000..47301e1 --- /dev/null +++ b/EvoScientist/mcp.yaml @@ -0,0 +1,26 @@ +# 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_client.py b/EvoScientist/mcp_client.py new file mode 100644 index 0000000..ce2d63c --- /dev/null +++ b/EvoScientist/mcp_client.py @@ -0,0 +1,524 @@ +"""MCP (Model Context Protocol) client integration. + +Loads MCP server configurations from YAML, connects via langchain-mcp-adapters, +and routes the resulting LangChain tools to the appropriate agents. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import re +from pathlib import Path +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" + +# Regex for ${VAR} env var interpolation +ENV_VAR_RE = re.compile(r"\$\{([^}]+)\}") + +# Supported transport protocols +VALID_TRANSPORTS = {"stdio", "http", "streamable_http", "sse", "websocket"} + + +def _interpolate_env(value: str) -> str: + """Replace ``${VAR}`` patterns in *value* 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) + if val is None: + logger.warning("MCP config: env var $%s is not set", var) + return "" + return val + + return ENV_VAR_RE.sub(_replace, value) + + +def _interpolate_value(value: Any) -> Any: + """Recursively interpolate env vars in strings, dicts, and lists.""" + if isinstance(value, str): + return _interpolate_env(value) + if isinstance(value, dict): + return {k: _interpolate_value(v) for k, v in value.items()} + if isinstance(value, list): + return [_interpolate_value(v) for v in value] + return value + + +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(): + try: + data = yaml.safe_load(USER_MCP_CONFIG.read_text()) or {} + return data if isinstance(data, dict) else {} + except Exception: + return {} + return {} + + +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)) + + +def add_mcp_server( + name: str, + transport: str, + *, + command: str | None = None, + args: list[str] | None = None, + url: str | None = None, + headers: dict[str, str] | None = None, + env: dict[str, str] | None = None, + tools: list[str] | None = None, + expose_to: list[str] | None = None, +) -> dict[str, Any]: + """Add or replace an MCP server in the user config. + + Returns the server entry that was written. + """ + if transport not in VALID_TRANSPORTS: + raise ValueError( + f"Unknown transport {transport!r}. " + f"Must be one of: {', '.join(sorted(VALID_TRANSPORTS))}" + ) + + entry: dict[str, Any] = {"transport": transport} + + if transport == "stdio": + if not command: + raise ValueError("stdio transport requires a command") + entry["command"] = command + entry["args"] = args or [] + if env: + entry["env"] = env + else: + if not url: + raise ValueError(f"{transport} transport requires a url") + entry["url"] = url + if headers: + entry["headers"] = headers + + if tools: + entry["tools"] = tools + if expose_to: + entry["expose_to"] = expose_to + + user_cfg = _load_user_config() + user_cfg[name] = entry + _save_user_config(user_cfg) + return entry + + +def edit_mcp_server(name: str, **fields: Any) -> dict[str, Any]: + """Update fields on an existing MCP server entry. + + Only the provided *fields* are changed; everything else is preserved. + Passing ``None`` for a field removes it. + + Returns the updated entry. + + Raises: + KeyError: if *name* doesn't exist in the user config. + ValueError: on invalid transport or missing required fields. + """ + user_cfg = _load_user_config() + if name not in user_cfg: + raise KeyError(f"MCP server {name!r} not found in user config") + + entry = user_cfg[name] + + for key, value in fields.items(): + if value is None: + entry.pop(key, None) + else: + entry[key] = value + + # Re-validate after edits + transport = entry.get("transport", "") + if transport and transport not in VALID_TRANSPORTS: + raise ValueError( + f"Unknown transport {transport!r}. " + f"Must be one of: {', '.join(sorted(VALID_TRANSPORTS))}" + ) + 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"): + raise ValueError(f"{transport} transport requires a url") + + user_cfg[name] = entry + _save_user_config(user_cfg) + return entry + + +def remove_mcp_server(name: str) -> bool: + """Remove an MCP server from the user config. + + Returns True if removed, False if it didn't exist. + """ + user_cfg = _load_user_config() + if name not in user_cfg: + return False + del user_cfg[name] + _save_user_config(user_cfg) + return True + + +def parse_mcp_add_args(tokens: list[str]) -> dict: + """Parse CLI tokens for ``/mcp add`` into kwargs for :func:`add_mcp_server`. + + Syntax:: + + [extra-args...] + [--tools t1,t2] [--expose-to a1,a2] [--header Key:Value]... + [--env KEY=VALUE]... + + For stdio: positional args after transport are command + args. + For http/sse/websocket: first positional arg after transport is url. + """ + if len(tokens) < 3: + raise ValueError( + "Usage: [args...]\n" + " Options: --tools t1,t2 --expose-to agent1,agent2 --header Key:Value --env KEY=VALUE" + ) + + name = tokens[0] + transport = tokens[1] + + positional: list[str] = [] + tools: list[str] | None = None + expose_to: list[str] | None = None + headers: dict[str, str] = {} + env: dict[str, str] = {} + + i = 2 + while i < len(tokens): + tok = tokens[i] + if tok == "--tools" and i + 1 < len(tokens): + tools = [t.strip() for t in tokens[i + 1].split(",") if t.strip()] + i += 2 + elif tok == "--expose-to" and i + 1 < len(tokens): + expose_to = [a.strip() for a in tokens[i + 1].split(",") if a.strip()] + i += 2 + elif tok == "--header" and i + 1 < len(tokens): + kv = tokens[i + 1] + if ":" in kv: + k, v = kv.split(":", 1) + headers[k.strip()] = v.strip() + i += 2 + elif tok == "--env" and i + 1 < len(tokens): + kv = tokens[i + 1] + if "=" in kv: + k, v = kv.split("=", 1) + env[k.strip()] = v.strip() + i += 2 + else: + positional.append(tok) + i += 1 + + kwargs: dict = {"name": name, "transport": transport} + + if transport == "stdio": + if not positional: + 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") + kwargs["url"] = positional[0] + if headers: + kwargs["headers"] = headers + + if tools: + kwargs["tools"] = tools + if expose_to: + kwargs["expose_to"] = expose_to + + return kwargs + + +def parse_mcp_edit_args(tokens: list[str]) -> tuple[str, dict]: + """Parse CLI tokens for ``/mcp edit`` into (name, fields). + + Syntax:: + + [--transport T] [--command C] [--url U] + [--tools t1,t2] [--tools none] [--expose-to a1,a2] + [--header Key:Value]... [--env KEY=VALUE]... + + ``--tools none`` and ``--expose-to none`` clear those fields. + """ + if not tokens: + raise ValueError( + "Usage: [--transport T] [--command C] [--url U] " + "[--tools t1,t2] [--expose-to a1,a2] [--header K:V] [--env K=V]" + ) + + name = tokens[0] + fields: dict[str, Any] = {} + headers: dict[str, str] = {} + env: dict[str, str] = {} + + i = 1 + while i < len(tokens): + tok = tokens[i] + if tok == "--transport" and i + 1 < len(tokens): + fields["transport"] = tokens[i + 1] + i += 2 + elif tok == "--command" and i + 1 < len(tokens): + fields["command"] = tokens[i + 1] + i += 2 + elif tok == "--url" and i + 1 < len(tokens): + fields["url"] = tokens[i + 1] + i += 2 + elif tok == "--args" and i + 1 < len(tokens): + fields["args"] = tokens[i + 1].split(",") + 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()] + 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()] + i += 2 + elif tok == "--header" and i + 1 < len(tokens): + kv = tokens[i + 1] + if ":" in kv: + k, v = kv.split(":", 1) + headers[k.strip()] = v.strip() + i += 2 + elif tok == "--env" and i + 1 < len(tokens): + kv = tokens[i + 1] + if "=" in kv: + k, v = kv.split("=", 1) + env[k.strip()] = v.strip() + i += 2 + else: + i += 1 + + if headers: + fields["headers"] = headers + if env: + fields["env"] = env + + if not fields: + 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. + + Merges package-level config (shipped with EvoScientist) with user-level + config at ``~/.config/evoscientist/mcp.yaml``. User config wins on + conflict. + + Returns an empty dict if no servers are configured (MCP is optional). + """ + merged: dict[str, Any] = {} + + # 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) + + # 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 + + +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. + + Unknown transports are skipped with a warning. + """ + connections: dict[str, dict[str, Any]] = {} + + for name, server in config.items(): + transport = server.get("transport", "") + + if transport == "stdio": + conn: dict[str, Any] = { + "transport": "stdio", + "command": server.get("command", ""), + "args": server.get("args", []), + } + if "env" in server: + conn["env"] = server["env"] + connections[name] = conn + + elif transport in ("http", "streamable_http"): + conn = { + "transport": transport, + "url": server.get("url", ""), + } + if "headers" in server: + 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) + + return connections + + +def _filter_tools(tools: list, allowed_names: list[str] | None) -> list: + """Filter tools by allowlist. + + If *allowed_names* is ``None``, all tools pass through. + """ + if allowed_names is None: + return tools + allowed_set = set(allowed_names) + return [t for t in tools if t.name in allowed_set] + + +def _route_tools( + config: dict[str, Any], + server_tools: dict[str, list], +) -> dict[str, list]: + """Group filtered tools by target agent. + + Args: + config: Full MCP config dict (server name -> server settings). + server_tools: server name -> list of LangChain tools from that server. + + Returns: + Dict mapping agent name -> list of tools. Key ``"main"`` targets the + main EvoScientist agent; other keys match subagent names. + """ + by_agent: dict[str, list] = {} + + for server_name, tools in server_tools.items(): + server_cfg = config.get(server_name, {}) + + # Apply tool name filter + allowed = server_cfg.get("tools") # None means all + filtered = _filter_tools(tools, allowed) + + # Determine target agents + expose_to = server_cfg.get("expose_to", ["main"]) + if isinstance(expose_to, str): + expose_to = [expose_to] + + for agent_name in expose_to: + by_agent.setdefault(agent_name, []).extend(filtered) + + return by_agent + + +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. + """ + from langchain_mcp_adapters.client import MultiServerMCPClient + + connections = _build_connections(config) + if not connections: + return {} + + server_tools: dict[str, list] = {} + client = MultiServerMCPClient(connections) # type: ignore[invalid-argument-type] + + for server_name in connections: + 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) + ) + except Exception as exc: + logger.warning("MCP server %r: failed to load tools: %s", server_name, exc) + server_tools[server_name] = [] + + return server_tools + + +def load_mcp_tools(config_path: str | Path | None = None) -> 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) + 2. Connects to each configured MCP server + 3. Filters tools per server allowlist + 4. Routes tools to target agents + + Returns: + Dict mapping agent name -> list of LangChain ``BaseTool`` objects. + Key ``"main"`` = main agent. Other keys = subagent names. + Returns empty dict if no MCP servers are configured. + """ + config = load_mcp_config(config_path) + 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: + server_tools = asyncio.run(_load_tools(config)) + except Exception as exc: + logger.warning("MCP tool loading failed: %s", exc) + return {} + + return _route_tools(config, server_tools) diff --git a/README.md b/README.md index 6cb9c1d..565ac82 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ - [CLI Inference](#cli-inference) - [Script Inference](#script-inference) - [Web Interface](#web-interface) +- [🔌 MCP Tools](#-mcp-tools) - [📊 Evaluation](#-evaluation) - [📝 Citation](#-citation) - [📚 Acknowledgments](#-acknowledgments) @@ -201,6 +202,7 @@ EvoSci config path # Show config file path | `/skills` | List installed user skills | | `/install-skill ` | Install a skill from local path or GitHub | | `/uninstall-skill ` | Uninstall a user-installed skill | +| `/mcp` | List configured MCP servers and tool routing | **Skill Installation Examples:** @@ -305,7 +307,118 @@ for state in EvoScientist_agent.stream( > TODO -## 📊 Evaluation +## 🔌 MCP Tools + +EvoScientist supports [MCP](https://modelcontextprotocol.io/) servers, allowing you to extend agents with external tools (databases, APIs, etc.). + +### Adding Servers + +The quickest way to add an MCP server is from the CLI: + +```Shell +# stdio transport (local process) +EvoSci mcp add filesystem stdio npx -- -y @modelcontextprotocol/server-filesystem /tmp + +# http transport +EvoSci mcp add brave-search http http://localhost:8080/mcp -H "Authorization:Bearer ${BRAVE_API_KEY}" + +# sse transport, routed to a specific agent +EvoSci mcp add my-sse sse http://localhost:9090/sse -e research-agent + +# With tool allowlist +EvoSci mcp add fs stdio npx -- -y @modelcontextprotocol/server-filesystem /tmp -t read_file,write_file +``` + +Or from the interactive CLI: + +``` +/mcp add filesystem stdio npx -y @modelcontextprotocol/server-filesystem /tmp +/mcp remove filesystem +/mcp list +``` + +**Options:** + +| Flag | Description | +|------|-------------| +| `--tools`, `-t` | Comma-separated tool allowlist (omit = all tools) | +| `--expose-to`, `-e` | Comma-separated target agents (default: `main`) | +| `--header`, `-H` | HTTP header as `Key:Value` (repeatable) | +| `--env` | Env var as `KEY=VALUE` for stdio (repeatable) | + +### YAML Configuration + +Servers are stored in `~/.config/evoscientist/mcp.yaml`. You can also edit this file directly: + +```yaml +filesystem: + transport: stdio + command: npx + args: ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/dir"] + tools: [read_file, write_file] # optional allowlist (omit = all tools) + expose_to: [main, code-agent] # optional routing (omit = ["main"]) + +brave-search: + transport: http + url: "http://localhost:8080/mcp" + headers: + Authorization: "Bearer ${BRAVE_API_KEY}" + expose_to: [research-agent] +``` + +Use `${VAR}` syntax to reference environment variables in config values. + +### Supported Transports + +| Transport | Config Fields | +|-----------|--------------| +| `stdio` | `command`, `args`, `env` (optional) | +| `http` | `url`, `headers` (optional) | +| `sse` | `url`, `headers` (optional) | +| `websocket` | `url` | + +### Tool Routing + +Use `expose_to` to control which agents receive each server's tools: + +- `main` — the main EvoScientist orchestrator agent +- Any subagent name (`code-agent`, `research-agent`, `debug-agent`, `planner-agent`, `data-analysis-agent`, `writing-agent`) + +Tools routed to subagents are injected automatically — no need to edit `subagent.yaml`. All MCP tools are also registered in the tool registry, so they can be referenced by name in `subagent.yaml` if needed. + +### Editing Servers + +Update individual fields on an existing server without re-adding it: + +```Shell +# Change routing +EvoSci mcp edit filesystem --expose-to main,code-agent + +# Set a tool allowlist +EvoSci mcp edit filesystem --tools read_file,write_file + +# Clear a tool allowlist (pass all tools) +EvoSci mcp edit filesystem --tools none + +# Change URL +EvoSci mcp edit my-api --url http://new-host:9090/mcp +``` + +Or interactively: `/mcp edit filesystem --expose-to main,code-agent` + +### Management Commands + +```Shell +EvoSci mcp # List configured servers +EvoSci mcp list # List configured servers +EvoSci mcp add ... # Add a server +EvoSci mcp edit ... # Edit an existing server +EvoSci mcp remove ... # Remove a server +``` + +All commands also work interactively: `/mcp`, `/mcp list`, `/mcp add ...`, `/mcp edit ...`, `/mcp remove `. + +## 📊 Evaluation > TODO diff --git a/pyproject.toml b/pyproject.toml index ab0324b..8f47799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,6 +33,7 @@ dependencies = [ "httpx>=0.27", "markdownify>=0.14", "nest-asyncio>=1.6", + "langchain-mcp-adapters>=0.1", ] [project.optional-dependencies] @@ -62,7 +63,7 @@ build-backend = "setuptools.build_meta" include = ["EvoScientist*"] [tool.setuptools.package-data] -EvoScientist = ["subagent.yaml", "skills/**/*"] +EvoScientist = ["subagent.yaml", "mcp.yaml", "skills/**/*"] [tool.pytest.ini_options] testpaths = ["tests"]