feat(mcp): implement MCP client

This commit is contained in:
Dinos Papakostas
2026-02-06 23:17:26 +08:00
parent 53d38ea0c6
commit 1898fdf33f
6 changed files with 974 additions and 30 deletions
+64 -28
View File
@@ -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,
+244
View File
@@ -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 <name> <transport> <command-or-url> [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 <name> <transport> <command-or-url> [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 <name> --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 <name> --<field> <value> ...")
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 <name>``."""
from .mcp_client import remove_mcp_server
if not name.strip():
console.print("[red]Usage:[/red] /mcp remove <name>")
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)
# =============================================================================
+26
View File
@@ -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]
+524
View File
@@ -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::
<name> <transport> <command-or-url> [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: <name> <transport> <command-or-url> [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::
<name> [--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: <name> [--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)
+114 -1
View File
@@ -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 <source>` | Install a skill from local path or GitHub |
| `/uninstall-skill <name>` | 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 <name>`.
## 📊 Evaluation
> TODO
+2 -1
View File
@@ -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"]