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