@@ -14,6 +14,7 @@ build/
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
uv.lock
|
||||
|
||||
# IDE / Tools
|
||||
.vscode/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -53,6 +54,11 @@
|
||||
| OpenAI | `gpt-4o-mini` | `gpt-4o-mini` |
|
||||
| OpenAI | `o1` | `o1` |
|
||||
| OpenAI | `o1-mini` | `o1-mini` |
|
||||
| Google | `gemini-3-pro` | `gemini-3-pro-preview` |
|
||||
| Google | `gemini-3-flash` | `gemini-3-flash-preview` |
|
||||
| Google | `gemini-2.5-pro` | `gemini-2.5-pro` |
|
||||
| Google | `gemini-2.5-flash` | `gemini-2.5-flash` |
|
||||
| Google | `gemini-2.5-flash-lite` | `gemini-2.5-flash-lite` |
|
||||
| NVIDIA | `glm4.7` | `z-ai/glm4.7` |
|
||||
| NVIDIA | `deepseek-v3.1` | `deepseek-ai/deepseek-v3.1-terminus` |
|
||||
| NVIDIA | `nemotron-nano` | `nvidia/nemotron-3-nano-30b-a3b` |
|
||||
@@ -114,8 +120,9 @@ Set keys directly in your terminal session. Add these to your shell profile (`~/
|
||||
export ANTHROPIC_API_KEY="your_anthropic_api_key_here"
|
||||
export TAVILY_API_KEY="your_tavily_api_key_here"
|
||||
|
||||
# Optional: OpenAI or NVIDIA provider
|
||||
# Optional: OpenAI, Google, or NVIDIA provider
|
||||
export OPENAI_API_KEY="your_openai_api_key_here"
|
||||
export GOOGLE_API_KEY="your_google_api_key_here"
|
||||
export NVIDIA_API_KEY="your_nvidia_api_key_here"
|
||||
```
|
||||
|
||||
@@ -140,6 +147,7 @@ TAVILY_API_KEY=your_tavily_api_key_here
|
||||
| Key | Required | Description |
|
||||
|-----|----------|-------------|
|
||||
| `ANTHROPIC_API_KEY` | For Anthropic | Anthropic API key for Claude ([console.anthropic.com](https://console.anthropic.com/)) |
|
||||
| `GOOGLE_API_KEY` | For Google | Google API key for Gemini models ([aistudio.google.com](https://aistudio.google.com/api-keys)) |
|
||||
| `OPENAI_API_KEY` | For OpenAI | OpenAI API key for GPT models ([platform.openai.com](https://platform.openai.com/)) |
|
||||
| `NVIDIA_API_KEY` | For NVIDIA | NVIDIA API key for NIM models ([build.nvidia.com](https://build.nvidia.com/)) |
|
||||
| `TAVILY_API_KEY` | Yes | Tavily API key for web search ([app.tavily.com](https://app.tavily.com/)) |
|
||||
@@ -194,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:**
|
||||
|
||||
@@ -298,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
|
||||
|
||||
@@ -341,6 +461,15 @@ We thank the authors for their valuable contributions to the open-source communi
|
||||
<sub><b>Xi Zhang</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://din0s.me/">
|
||||
<img src="https://din0s.me/images/pk.jpg"
|
||||
width="100" height="100"
|
||||
style="object-fit: cover; border-radius: 20%;" alt="Dinos Papakostas"/>
|
||||
<br />
|
||||
<sub><b>Dinos Papakostas</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+2
-1
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
"""Tests for EvoScientist.mcp_client module."""
|
||||
|
||||
import textwrap
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from EvoScientist.mcp_client import (
|
||||
_interpolate_env,
|
||||
_filter_tools,
|
||||
_route_tools,
|
||||
_build_connections,
|
||||
load_mcp_config,
|
||||
add_mcp_server,
|
||||
edit_mcp_server,
|
||||
remove_mcp_server,
|
||||
parse_mcp_add_args,
|
||||
parse_mcp_edit_args,
|
||||
)
|
||||
|
||||
|
||||
# ---- _interpolate_env ----
|
||||
|
||||
|
||||
class TestInterpolateEnv:
|
||||
def test_substitutes_env_var(self, monkeypatch):
|
||||
monkeypatch.setenv("MY_KEY", "secret123")
|
||||
assert _interpolate_env("Bearer ${MY_KEY}") == "Bearer secret123"
|
||||
|
||||
def test_multiple_vars(self, monkeypatch):
|
||||
monkeypatch.setenv("HOST", "localhost")
|
||||
monkeypatch.setenv("PORT", "8080")
|
||||
assert _interpolate_env("${HOST}:${PORT}") == "localhost:8080"
|
||||
|
||||
def test_missing_var_returns_empty(self, monkeypatch):
|
||||
monkeypatch.delenv("NONEXISTENT_VAR_XYZ", raising=False)
|
||||
assert _interpolate_env("${NONEXISTENT_VAR_XYZ}") == ""
|
||||
|
||||
def test_no_vars_unchanged(self):
|
||||
assert _interpolate_env("plain text") == "plain text"
|
||||
|
||||
def test_empty_string(self):
|
||||
assert _interpolate_env("") == ""
|
||||
|
||||
|
||||
# ---- load_mcp_config ----
|
||||
|
||||
|
||||
@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",
|
||||
)
|
||||
|
||||
|
||||
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_valid_file_parses(self, tmp_path, _no_user_mcp):
|
||||
cfg = tmp_path / "mcp.yaml"
|
||||
cfg.write_text(textwrap.dedent("""\
|
||||
my-server:
|
||||
transport: stdio
|
||||
command: echo
|
||||
args: ["hello"]
|
||||
"""))
|
||||
result = load_mcp_config(cfg)
|
||||
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_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_env_var_interpolation(self, tmp_path, _no_user_mcp, monkeypatch):
|
||||
monkeypatch.setenv("TEST_TOKEN", "tok_abc")
|
||||
cfg = tmp_path / "mcp.yaml"
|
||||
cfg.write_text(textwrap.dedent("""\
|
||||
my-server:
|
||||
transport: http
|
||||
url: "http://localhost:8080/mcp"
|
||||
headers:
|
||||
Authorization: "Bearer ${TEST_TOKEN}"
|
||||
"""))
|
||||
result = load_mcp_config(cfg)
|
||||
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 ----
|
||||
|
||||
|
||||
class TestBuildConnections:
|
||||
def test_stdio_connection(self):
|
||||
config = {
|
||||
"fs": {
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "server"],
|
||||
}
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert "fs" in conns
|
||||
assert conns["fs"]["transport"] == "stdio"
|
||||
assert conns["fs"]["command"] == "npx"
|
||||
assert conns["fs"]["args"] == ["-y", "server"]
|
||||
|
||||
def test_stdio_with_env(self):
|
||||
config = {
|
||||
"fs": {
|
||||
"transport": "stdio",
|
||||
"command": "npx",
|
||||
"args": [],
|
||||
"env": {"FOO": "bar"},
|
||||
}
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert conns["fs"]["env"] == {"FOO": "bar"}
|
||||
|
||||
def test_http_connection(self):
|
||||
config = {
|
||||
"api": {
|
||||
"transport": "http",
|
||||
"url": "http://localhost:8080/mcp",
|
||||
"headers": {"Authorization": "Bearer xxx"},
|
||||
}
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert conns["api"]["transport"] == "http"
|
||||
assert conns["api"]["url"] == "http://localhost:8080/mcp"
|
||||
assert conns["api"]["headers"]["Authorization"] == "Bearer xxx"
|
||||
|
||||
def test_sse_connection(self):
|
||||
config = {
|
||||
"sse-srv": {
|
||||
"transport": "sse",
|
||||
"url": "http://localhost:9090/sse",
|
||||
}
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert conns["sse-srv"]["transport"] == "sse"
|
||||
assert conns["sse-srv"]["url"] == "http://localhost:9090/sse"
|
||||
|
||||
def test_websocket_connection(self):
|
||||
config = {
|
||||
"ws": {
|
||||
"transport": "websocket",
|
||||
"url": "ws://localhost:8765",
|
||||
}
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert conns["ws"]["transport"] == "websocket"
|
||||
|
||||
def test_unknown_transport_skipped(self):
|
||||
config = {
|
||||
"bad": {
|
||||
"transport": "carrier_pigeon",
|
||||
"url": "coo://rooftop",
|
||||
}
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert conns == {}
|
||||
|
||||
def test_mixed_transports(self):
|
||||
config = {
|
||||
"a": {"transport": "stdio", "command": "cmd", "args": []},
|
||||
"b": {"transport": "http", "url": "http://x"},
|
||||
"c": {"transport": "unknown"},
|
||||
}
|
||||
conns = _build_connections(config)
|
||||
assert set(conns.keys()) == {"a", "b"}
|
||||
|
||||
|
||||
# ---- _filter_tools ----
|
||||
|
||||
|
||||
def _make_tool(name: str):
|
||||
"""Create a minimal mock tool with a .name attribute."""
|
||||
return SimpleNamespace(name=name)
|
||||
|
||||
|
||||
class TestFilterTools:
|
||||
def test_none_allowlist_passes_all(self):
|
||||
tools = [_make_tool("a"), _make_tool("b"), _make_tool("c")]
|
||||
assert _filter_tools(tools, None) == tools
|
||||
|
||||
def test_allowlist_filters(self):
|
||||
tools = [_make_tool("a"), _make_tool("b"), _make_tool("c")]
|
||||
result = _filter_tools(tools, ["a", "c"])
|
||||
assert [t.name for t in result] == ["a", "c"]
|
||||
|
||||
def test_empty_allowlist_filters_all(self):
|
||||
tools = [_make_tool("a"), _make_tool("b")]
|
||||
assert _filter_tools(tools, []) == []
|
||||
|
||||
def test_allowlist_with_nonexistent_name(self):
|
||||
tools = [_make_tool("a")]
|
||||
result = _filter_tools(tools, ["a", "nonexistent"])
|
||||
assert [t.name for t in result] == ["a"]
|
||||
|
||||
def test_empty_tools_list(self):
|
||||
assert _filter_tools([], ["a"]) == []
|
||||
assert _filter_tools([], None) == []
|
||||
|
||||
|
||||
# ---- _route_tools ----
|
||||
|
||||
|
||||
class TestRouteTools:
|
||||
def test_default_routes_to_main(self):
|
||||
config = {"srv": {"transport": "stdio"}}
|
||||
server_tools = {"srv": [_make_tool("x")]}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert "main" in result
|
||||
assert [t.name for t in result["main"]] == ["x"]
|
||||
|
||||
def test_expose_to_named_agent(self):
|
||||
config = {"srv": {"transport": "stdio", "expose_to": ["code-agent"]}}
|
||||
server_tools = {"srv": [_make_tool("x"), _make_tool("y")]}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert "code-agent" in result
|
||||
assert "main" not in result
|
||||
assert [t.name for t in result["code-agent"]] == ["x", "y"]
|
||||
|
||||
def test_expose_to_multiple_agents(self):
|
||||
config = {"srv": {"transport": "stdio", "expose_to": ["main", "code-agent"]}}
|
||||
server_tools = {"srv": [_make_tool("x")]}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert [t.name for t in result["main"]] == ["x"]
|
||||
assert [t.name for t in result["code-agent"]] == ["x"]
|
||||
|
||||
def test_tool_filter_applied(self):
|
||||
config = {"srv": {"transport": "stdio", "tools": ["b"]}}
|
||||
server_tools = {"srv": [_make_tool("a"), _make_tool("b"), _make_tool("c")]}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert [t.name for t in result["main"]] == ["b"]
|
||||
|
||||
def test_multiple_servers(self):
|
||||
config = {
|
||||
"s1": {"transport": "stdio", "expose_to": ["main"]},
|
||||
"s2": {"transport": "http", "expose_to": ["research-agent"]},
|
||||
}
|
||||
server_tools = {
|
||||
"s1": [_make_tool("a")],
|
||||
"s2": [_make_tool("b")],
|
||||
}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert [t.name for t in result["main"]] == ["a"]
|
||||
assert [t.name for t in result["research-agent"]] == ["b"]
|
||||
|
||||
def test_expose_to_string_not_list(self):
|
||||
config = {"srv": {"transport": "stdio", "expose_to": "debug-agent"}}
|
||||
server_tools = {"srv": [_make_tool("x")]}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert "debug-agent" in result
|
||||
|
||||
def test_empty_server_tools(self):
|
||||
config = {"srv": {"transport": "stdio"}}
|
||||
server_tools = {"srv": []}
|
||||
result = _route_tools(config, server_tools)
|
||||
assert result.get("main", []) == []
|
||||
|
||||
|
||||
# ---- add_mcp_server / remove_mcp_server ----
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def user_mcp_dir(tmp_path, monkeypatch):
|
||||
"""Redirect user MCP config to a temp directory."""
|
||||
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)
|
||||
return cfg_file
|
||||
|
||||
|
||||
class TestAddMcpServer:
|
||||
def test_add_stdio_server(self, user_mcp_dir):
|
||||
entry = add_mcp_server(
|
||||
"fs", "stdio", command="npx", args=["-y", "server", "/tmp"]
|
||||
)
|
||||
assert entry["transport"] == "stdio"
|
||||
assert entry["command"] == "npx"
|
||||
assert entry["args"] == ["-y", "server", "/tmp"]
|
||||
# Verify persisted
|
||||
data = yaml.safe_load(user_mcp_dir.read_text())
|
||||
assert "fs" in data
|
||||
|
||||
def test_add_http_server(self, user_mcp_dir):
|
||||
entry = add_mcp_server(
|
||||
"api", "http",
|
||||
url="http://localhost:8080/mcp",
|
||||
headers={"Authorization": "Bearer tok"},
|
||||
)
|
||||
assert entry["url"] == "http://localhost:8080/mcp"
|
||||
assert entry["headers"]["Authorization"] == "Bearer tok"
|
||||
|
||||
def test_add_sse_server(self, user_mcp_dir):
|
||||
entry = add_mcp_server("sse-srv", "sse", url="http://localhost:9090/sse")
|
||||
assert entry["transport"] == "sse"
|
||||
|
||||
def test_add_websocket_server(self, user_mcp_dir):
|
||||
entry = add_mcp_server("ws", "websocket", url="ws://localhost:8765")
|
||||
assert entry["transport"] == "websocket"
|
||||
|
||||
def test_add_with_tools_and_expose_to(self, user_mcp_dir):
|
||||
entry = add_mcp_server(
|
||||
"fs", "stdio",
|
||||
command="npx", args=[],
|
||||
tools=["read_file"],
|
||||
expose_to=["main", "code-agent"],
|
||||
)
|
||||
assert entry["tools"] == ["read_file"]
|
||||
assert entry["expose_to"] == ["main", "code-agent"]
|
||||
|
||||
def test_add_replaces_existing(self, user_mcp_dir):
|
||||
add_mcp_server("srv", "stdio", command="old")
|
||||
add_mcp_server("srv", "http", url="http://new")
|
||||
data = yaml.safe_load(user_mcp_dir.read_text())
|
||||
assert data["srv"]["transport"] == "http"
|
||||
|
||||
def test_add_invalid_transport_raises(self, user_mcp_dir):
|
||||
with pytest.raises(ValueError, match="Unknown transport"):
|
||||
add_mcp_server("bad", "carrier_pigeon", url="coo://rooftop")
|
||||
|
||||
def test_stdio_without_command_raises(self, user_mcp_dir):
|
||||
with pytest.raises(ValueError, match="requires a command"):
|
||||
add_mcp_server("bad", "stdio")
|
||||
|
||||
def test_http_without_url_raises(self, user_mcp_dir):
|
||||
with pytest.raises(ValueError, match="requires a url"):
|
||||
add_mcp_server("bad", "http")
|
||||
|
||||
def test_add_with_env(self, user_mcp_dir):
|
||||
entry = add_mcp_server(
|
||||
"fs", "stdio", command="npx", args=[], env={"FOO": "bar"}
|
||||
)
|
||||
assert entry["env"] == {"FOO": "bar"}
|
||||
|
||||
def test_add_multiple_servers(self, user_mcp_dir):
|
||||
add_mcp_server("a", "stdio", command="cmd1")
|
||||
add_mcp_server("b", "http", url="http://x")
|
||||
data = yaml.safe_load(user_mcp_dir.read_text())
|
||||
assert "a" in data and "b" in data
|
||||
|
||||
|
||||
class TestRemoveMcpServer:
|
||||
def test_remove_existing(self, user_mcp_dir):
|
||||
add_mcp_server("fs", "stdio", command="npx")
|
||||
assert remove_mcp_server("fs") is True
|
||||
data = yaml.safe_load(user_mcp_dir.read_text()) or {}
|
||||
assert "fs" not in data
|
||||
|
||||
def test_remove_nonexistent(self, user_mcp_dir):
|
||||
assert remove_mcp_server("nope") is False
|
||||
|
||||
def test_remove_preserves_others(self, user_mcp_dir):
|
||||
add_mcp_server("a", "stdio", command="cmd1")
|
||||
add_mcp_server("b", "http", url="http://x")
|
||||
remove_mcp_server("a")
|
||||
data = yaml.safe_load(user_mcp_dir.read_text())
|
||||
assert "a" not in data
|
||||
assert "b" in data
|
||||
|
||||
|
||||
# ---- _parse_mcp_add_args (CLI arg parser) ----
|
||||
|
||||
|
||||
class TestParseMcpAddArgs:
|
||||
def test_stdio_basic(self):
|
||||
r = parse_mcp_add_args(["fs", "stdio", "npx", "-y", "server", "/tmp"])
|
||||
assert r["name"] == "fs"
|
||||
assert r["transport"] == "stdio"
|
||||
assert r["command"] == "npx"
|
||||
assert r["args"] == ["-y", "server", "/tmp"]
|
||||
|
||||
def test_http_basic(self):
|
||||
r = parse_mcp_add_args(["api", "http", "http://localhost:8080/mcp"])
|
||||
assert r["url"] == "http://localhost:8080/mcp"
|
||||
|
||||
def test_tools_flag(self):
|
||||
r = parse_mcp_add_args(["srv", "http", "http://x", "--tools", "a,b"])
|
||||
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"])
|
||||
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"])
|
||||
assert r["headers"] == {"Authorization": "Bearer tok"}
|
||||
|
||||
def test_env_flag(self):
|
||||
r = parse_mcp_add_args(["srv", "stdio", "cmd", "--env", "FOO=bar"])
|
||||
assert r["env"] == {"FOO": "bar"}
|
||||
|
||||
def test_too_few_tokens_raises(self):
|
||||
with pytest.raises(ValueError, match="Usage"):
|
||||
parse_mcp_add_args(["fs", "stdio"])
|
||||
|
||||
def test_stdio_missing_command_raises(self):
|
||||
with pytest.raises(ValueError, match="requires a command"):
|
||||
parse_mcp_add_args(["fs", "stdio", "--tools", "a"])
|
||||
|
||||
def test_http_missing_url_raises(self):
|
||||
with pytest.raises(ValueError, match="requires a url"):
|
||||
parse_mcp_add_args(["srv", "http", "--tools", "a"])
|
||||
|
||||
|
||||
# ---- edit_mcp_server ----
|
||||
|
||||
|
||||
class TestEditMcpServer:
|
||||
def test_edit_expose_to(self, user_mcp_dir):
|
||||
add_mcp_server("fs", "stdio", command="npx", args=[])
|
||||
entry = edit_mcp_server("fs", expose_to=["main", "code-agent"])
|
||||
assert entry["expose_to"] == ["main", "code-agent"]
|
||||
assert entry["command"] == "npx" # unchanged
|
||||
|
||||
def test_edit_tools(self, user_mcp_dir):
|
||||
add_mcp_server("fs", "stdio", command="npx", args=[])
|
||||
entry = edit_mcp_server("fs", tools=["read_file"])
|
||||
assert entry["tools"] == ["read_file"]
|
||||
|
||||
def test_edit_clear_tools(self, user_mcp_dir):
|
||||
add_mcp_server("fs", "stdio", command="npx", tools=["read_file"])
|
||||
entry = edit_mcp_server("fs", tools=None)
|
||||
assert "tools" not in entry
|
||||
|
||||
def test_edit_url(self, user_mcp_dir):
|
||||
add_mcp_server("api", "http", url="http://old:8080/mcp")
|
||||
entry = edit_mcp_server("api", url="http://new:9090/mcp")
|
||||
assert entry["url"] == "http://new:9090/mcp"
|
||||
assert entry["transport"] == "http" # unchanged
|
||||
|
||||
def test_edit_nonexistent_raises(self, user_mcp_dir):
|
||||
with pytest.raises(KeyError, match="not found"):
|
||||
edit_mcp_server("nope", tools=["a"])
|
||||
|
||||
def test_edit_invalid_transport_raises(self, user_mcp_dir):
|
||||
add_mcp_server("fs", "stdio", command="npx")
|
||||
with pytest.raises(ValueError, match="Unknown transport"):
|
||||
edit_mcp_server("fs", transport="carrier_pigeon")
|
||||
|
||||
def test_edit_removes_required_field_raises(self, user_mcp_dir):
|
||||
add_mcp_server("fs", "stdio", command="npx")
|
||||
with pytest.raises(ValueError, match="requires a command"):
|
||||
edit_mcp_server("fs", command=None)
|
||||
|
||||
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"],
|
||||
)
|
||||
entry = edit_mcp_server("fs", expose_to=["code-agent"])
|
||||
assert entry["tools"] == ["a"]
|
||||
assert entry["args"] == ["-y", "srv"]
|
||||
assert entry["expose_to"] == ["code-agent"]
|
||||
|
||||
|
||||
# ---- parse_mcp_edit_args ----
|
||||
|
||||
|
||||
class TestParseMcpEditArgs:
|
||||
def test_basic_field(self):
|
||||
name, fields = parse_mcp_edit_args(["srv", "--url", "http://new"])
|
||||
assert name == "srv"
|
||||
assert fields["url"] == "http://new"
|
||||
|
||||
def test_tools_none_clears(self):
|
||||
_, fields = parse_mcp_edit_args(["srv", "--tools", "none"])
|
||||
assert fields["tools"] is None
|
||||
|
||||
def test_expose_to_csv(self):
|
||||
_, fields = parse_mcp_edit_args(["srv", "--expose-to", "main,code-agent"])
|
||||
assert fields["expose_to"] == ["main", "code-agent"]
|
||||
|
||||
def test_multiple_fields(self):
|
||||
_, fields = parse_mcp_edit_args(["srv", "--url", "http://x", "--tools", "a,b"])
|
||||
assert fields["url"] == "http://x"
|
||||
assert fields["tools"] == ["a", "b"]
|
||||
|
||||
def test_empty_raises(self):
|
||||
with pytest.raises(ValueError, match="Usage"):
|
||||
parse_mcp_edit_args([])
|
||||
|
||||
def test_no_fields_raises(self):
|
||||
with pytest.raises(ValueError, match="No fields"):
|
||||
parse_mcp_edit_args(["srv"])
|
||||
Reference in New Issue
Block a user