This commit is contained in:
X-iZhang
2026-02-09 02:54:41 +00:00
parent a32fe79bdd
commit 250a23682c
5 changed files with 330 additions and 58 deletions
+186 -19
View File
@@ -272,7 +272,7 @@ def _cmd_install_skill(source: str) -> None:
console.print(f"[dim]Description:[/dim] {result.get('description', '(none)')}")
console.print(f"[dim]Path:[/dim] [cyan]{_shorten_path(result['path'])}[/cyan]")
console.print()
console.print("[dim]Reload the agent with /new to use the skill.[/dim]")
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Failed:[/red] {result['error']}")
console.print()
@@ -292,7 +292,7 @@ def _cmd_uninstall_skill(name: str) -> None:
if result["success"]:
console.print(f"[green]Uninstalled:[/green] {name}")
console.print("[dim]Reload the agent with /new to apply changes.[/dim]")
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Failed:[/red] {result['error']}")
console.print()
@@ -388,6 +388,7 @@ 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 import load_mcp_config
from .mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
@@ -414,7 +415,7 @@ def _mcp_list_servers() -> None:
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(f"\n[dim]Config file: {USER_MCP_CONFIG}[/dim]")
console.print()
@@ -424,20 +425,22 @@ def _cmd_mcp_add(args_str: str) -> None:
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...]")
console.print("[bold]Usage:[/bold] /mcp add <name> <command-or-url> [args...]")
console.print()
console.print("[dim]Transports:[/dim] stdio, http, sse, websocket")
console.print("[dim]Transport is auto-detected: URLs → http, commands → stdio[/dim]")
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(" /mcp add sequential-thinking npx -y @modelcontextprotocol/server-sequential-thinking")
console.print(" /mcp add docs-langchain https://docs.langchain.com/mcp")
console.print(" /mcp add my-sse http://localhost:9090/sse --transport sse --expose-to research-agent")
console.print()
console.print("[dim]Options:[/dim]")
console.print(" --transport T Transport type (default: auto-detect)")
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(" --env-ref KEY Env var as runtime ${KEY} reference (repeatable)")
console.print()
return
@@ -446,7 +449,7 @@ def _cmd_mcp_add(args_str: str) -> None:
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]")
console.print("[dim]Reload with /new to apply.[/dim]")
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
console.print()
@@ -478,7 +481,7 @@ def _cmd_mcp_edit(args_str: str) -> None:
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]")
console.print("[dim]Reload with /new to apply.[/dim]")
except KeyError as exc:
console.print(f"[red]{exc}[/red]")
except ValueError as exc:
@@ -497,12 +500,67 @@ def _cmd_mcp_remove(name: str) -> None:
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]")
console.print("[dim]Reload with /new to apply.[/dim]")
else:
console.print(f"[red]Server not found:[/red] {name.strip()}")
console.print()
def _cmd_mcp_config(name: str) -> None:
"""Handle ``/mcp config [name]``."""
from .mcp import load_mcp_config
from .mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
console.print()
return
name = name.strip()
if name and name not in config:
console.print(f"[red]Server not found:[/red] {name}")
console.print()
return
servers = {name: config[name]} if name else config
for srv_name, srv in servers.items():
table = Table(
title=f"MCP Server: {srv_name}",
show_header=True,
title_style="bold cyan",
)
table.add_column("Setting", style="cyan")
table.add_column("Value")
table.add_row("transport", str(srv.get("transport", "(not set)")))
if srv.get("command"):
table.add_row("command", str(srv["command"]))
if srv.get("args"):
table.add_row("args", " ".join(str(a) for a in srv["args"]))
if srv.get("url"):
table.add_row("url", str(srv["url"]))
if srv.get("headers"):
for k, v in srv["headers"].items():
table.add_row(f"header: {k}", str(v))
if srv.get("env"):
for k, v in srv["env"].items():
table.add_row(f"env: {k}", str(v))
tools = srv.get("tools")
table.add_row("tools", ", ".join(tools) if tools else "[dim](all)[/dim]")
expose_to = srv.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
table.add_row("expose_to", ", ".join(expose_to))
console.print(table)
console.print()
console.print(f"[dim]Config file: {USER_MCP_CONFIG}[/dim]")
console.print()
def _cmd_mcp(args: str) -> None:
"""Dispatch ``/mcp`` subcommands."""
args = args.strip()
@@ -515,10 +573,13 @@ def _cmd_mcp(args: str) -> None:
_cmd_mcp_edit(args[4:].strip())
elif args.startswith("remove"):
_cmd_mcp_remove(args[6:].strip())
elif args.startswith("config"):
_cmd_mcp_config(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 config Show detailed server config")
console.print(" /mcp add ... Add a server")
console.print(" /mcp edit ... Edit an existing server")
console.print(" /mcp remove ... Remove a server")
@@ -982,7 +1043,37 @@ config_app = typer.Typer(help="Configuration management commands", invoke_withou
app.add_typer(config_app, name="config")
# MCP subcommand group
mcp_app = typer.Typer(help="MCP server management commands", invoke_without_command=True)
_MCP_HELP = """\
Configure and manage MCP servers.
Examples:
# Add a local MCP server (stdio auto-detected):
EvoSci mcp add local-server python -- /path/to/server.py
# Add an npx-based server:
EvoSci mcp add sequential-thinking npx -- -y @modelcontextprotocol/server-sequential-thinking
# Add an HTTP server (http auto-detected from URL):
EvoSci mcp add docs-langchain https://docs.langchain.com/mcp
# Add a stdio server with env vars (hardcoded):
EvoSci mcp add my-server node --env API_KEY=xxx -- server.js
# Add a server with runtime env ref (resolved from .env at startup):
EvoSci mcp add brave-search npx --env-ref BRAVE_API_KEY -- -y @modelcontextprotocol/server-brave-search
# Expose to a specific sub-agent (e.g. research-agent):
EvoSci mcp add brave-search npx --env-ref BRAVE_API_KEY -e research-agent -- -y @modelcontextprotocol/server-brave-search
# Expose to multiple agents:
EvoSci mcp add local-server python -e main,research-agent,code-agent -- /path/to/server.py
# Explicit transport override:
EvoSci mcp add my-sse https://example.com/sse --transport sse
Sub-agents (-e): planner-agent | research-agent | code-agent | debug-agent | data-analysis-agent | writing-agent
"""
mcp_app = typer.Typer(help=_MCP_HELP, invoke_without_command=True)
app.add_typer(mcp_app, name="mcp")
@@ -1131,26 +1222,100 @@ def mcp_list():
_mcp_list_servers()
@mcp_app.command("config")
def mcp_config(
name: Optional[str] = typer.Argument(None, help="Server name (omit to show all)"),
):
"""Show detailed configuration for MCP servers.
\b
Examples:
evosci mcp config # Show all servers in detail
evosci mcp config filesystem # Show one server
"""
from .mcp import load_mcp_config
from .mcp.client import USER_MCP_CONFIG
config = load_mcp_config()
if not config:
console.print("[dim]No MCP servers configured.[/dim]")
console.print("[dim]Add one with:[/dim] EvoSci mcp add <name> <transport> <command-or-url> [args...]")
return
if name and name not in config:
console.print(f"[red]Server not found:[/red] {name}")
raise typer.Exit(1)
servers = {name: config[name]} if name else config
for srv_name, srv in servers.items():
table = Table(
title=f"MCP Server: {srv_name}",
show_header=True,
title_style="bold cyan",
)
table.add_column("Setting", style="cyan")
table.add_column("Value")
table.add_row("transport", str(srv.get("transport", "(not set)")))
if srv.get("command"):
table.add_row("command", str(srv["command"]))
if srv.get("args"):
table.add_row("args", " ".join(str(a) for a in srv["args"]))
if srv.get("url"):
table.add_row("url", str(srv["url"]))
if srv.get("headers"):
for k, v in srv["headers"].items():
table.add_row(f"header: {k}", str(v))
if srv.get("env"):
for k, v in srv["env"].items():
table.add_row(f"env: {k}", str(v))
tools = srv.get("tools")
table.add_row("tools", ", ".join(tools) if tools else "[dim](all)[/dim]")
expose_to = srv.get("expose_to", ["main"])
if isinstance(expose_to, str):
expose_to = [expose_to]
table.add_row("expose_to", ", ".join(expose_to))
console.print(table)
console.print()
console.print(f"[dim]Config file: {USER_MCP_CONFIG}[/dim]")
@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)"),
target: str = typer.Argument(..., help="Command (stdio) or URL (http/sse)"),
args: Optional[list[str]] = typer.Argument(None, help="Extra args for stdio command"),
transport: Optional[str] = typer.Option(None, "--transport", "-T", help="Transport type (default: auto-detect)"),
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)"),
env_ref: Optional[list[str]] = typer.Option(None, "--env-ref", help="Env var name as ${NAME} runtime ref (repeatable)"),
):
"""Add an MCP server to user config.
\b
Transport is auto-detected: URLs default to http, commands default to stdio.
\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
evosci mcp add sequential-thinking npx -- -y @modelcontextprotocol/server-sequential-thinking
evosci mcp add docs-langchain https://docs.langchain.com/mcp
evosci mcp add my-sse https://example.com/sse --transport sse -e research-agent
evosci mcp add brave-search npx --env-ref BRAVE_API_KEY -- -y @modelcontextprotocol/server-brave-search
"""
from .mcp import add_mcp_server
from .mcp.client import _infer_transport
if transport is None:
transport = _infer_transport(target)
kwargs: dict = {
"name": name,
@@ -1160,12 +1325,14 @@ def mcp_add(
if transport == "stdio":
kwargs["command"] = target
kwargs["args"] = list(args) if args else []
if env:
if env or env_ref:
env_dict = {}
for e in env:
for e in (env or []):
if "=" in e:
k, v = e.split("=", 1)
env_dict[k.strip()] = v.strip()
for ref in (env_ref or []):
env_dict[ref] = "${" + ref + "}"
if env_dict:
kwargs["env"] = env_dict
else:
+31 -19
View File
@@ -215,37 +215,46 @@ def remove_mcp_server(name: str) -> bool:
# =============================================================================
def _infer_transport(target: str) -> str:
"""Return ``'http'`` if *target* looks like a URL, else ``'stdio'``."""
if target.startswith(("http://", "https://", "ws://", "wss://")):
return "http"
return "stdio"
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]...
<name> <command-or-url> [extra-args...]
[--transport T] [--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.
Transport defaults to ``stdio`` for commands and ``http`` for URLs.
"""
if len(tokens) < 3:
if len(tokens) < 2:
raise ValueError(
"Usage: <name> <transport> <command-or-url> [args...]\n"
" Options: --tools t1,t2 --expose-to agent1,agent2 --header Key:Value --env KEY=VALUE"
"Usage: <name> <command-or-url> [args...]\n"
" Options: --transport T --tools t1,t2 --expose-to agent1,agent2 --header Key:Value --env KEY=VALUE"
)
name = tokens[0]
transport = tokens[1]
positional: list[str] = []
transport: str | None = None
tools: list[str] | None = None
expose_to: list[str] | None = None
headers: dict[str, str] = {}
env: dict[str, str] = {}
i = 2
i = 1
while i < len(tokens):
tok = tokens[i]
if tok == "--tools" and i + 1 < len(tokens):
if tok in ("--transport", "-T") and i + 1 < len(tokens):
transport = tokens[i + 1]
i += 2
elif 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):
@@ -263,26 +272,29 @@ def parse_mcp_add_args(tokens: list[str]) -> dict:
k, v = kv.split("=", 1)
env[k.strip()] = v.strip()
i += 2
elif tok == "--env-ref" and i + 1 < len(tokens):
env[tokens[i + 1]] = "${" + tokens[i + 1] + "}"
i += 2
elif tok == "--":
i += 1 # skip -- separator (used by shells, not meaningful here)
else:
positional.append(tok)
i += 1
if not positional:
raise ValueError("A command or URL is required after the server name")
if transport is None:
transport = _infer_transport(positional[0])
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
+66 -2
View File
@@ -53,7 +53,7 @@ CONFIRM_STYLE = Style.from_dict({
"text": "",
})
STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Skills", "Channels"]
STEPS = ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Skills", "MCP Servers", "Channels"]
# =============================================================================
@@ -781,6 +781,67 @@ def _step_skills() -> list[str]:
return installed
_RECOMMENDED_MCP_SERVERS = [
{
"label": "Sequential Thinking (structured reasoning for non-reasoning models)",
"name": "sequential-thinking",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-sequential-thinking"],
},
{
"label": "Docs by LangChain (documentation for building agents)",
"name": "docs-langchain",
"url": "https://docs.langchain.com/mcp",
},
]
def _step_mcp_servers() -> list[str]:
"""Step 8: Optionally install recommended MCP servers.
Shows a checkbox list of recommended servers. Selected ones are added
to the user MCP config via ``add_mcp_server()``.
Returns:
List of server names that were installed.
"""
choices = [
Choice(title=srv["label"], value=srv["name"])
for srv in _RECOMMENDED_MCP_SERVERS
]
selected = questionary.checkbox(
"Install recommended MCP servers:",
choices=choices,
style=WIZARD_STYLE,
).ask()
if selected is None:
raise KeyboardInterrupt()
if not selected:
_print_step_skipped("MCP Servers", "none selected")
console.print(" [dim]Add later with: EvoSci mcp add <name> <command> [--env-ref KEY] -- [args][/dim]")
return []
from .mcp.client import add_mcp_server
installed = []
for name in selected:
srv = next(s for s in _RECOMMENDED_MCP_SERVERS if s["name"] == name)
try:
if "url" in srv:
add_mcp_server(name, "streamable_http", url=srv["url"])
else:
add_mcp_server(name, "stdio", command=srv["command"], args=srv["args"])
_print_step_result("MCP", f"{name}")
installed.append(name)
except Exception as e:
_print_step_result("MCP", f"{name} — {e}", success=False)
return installed
def validate_imessage() -> tuple[bool, str]:
"""Validate iMessage environment by checking for the imsg CLI.
@@ -1091,7 +1152,10 @@ def run_onboard(skip_validation: bool = False) -> bool:
# Step 7: Skills
_step_skills()
# Step 8: Channels
# Step 8: MCP Servers
_step_mcp_servers()
# Step 9: Channels
imessage_enabled, imessage_allowed_senders = _step_channels(config)
config.imessage_enabled = imessage_enabled
config.imessage_allowed_senders = imessage_allowed_senders
+43 -14
View File
@@ -381,47 +381,76 @@ class TestRemoveMcpServer:
class TestParseMcpAddArgs:
def test_stdio_basic(self):
r = parse_mcp_add_args(["fs", "stdio", "npx", "-y", "server", "/tmp"])
r = parse_mcp_add_args(["fs", "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"])
def test_http_auto_detected(self):
r = parse_mcp_add_args(["api", "http://localhost:8080/mcp"])
assert r["transport"] == "http"
assert r["url"] == "http://localhost:8080/mcp"
def test_https_auto_detected(self):
r = parse_mcp_add_args(["api", "https://example.com/mcp"])
assert r["transport"] == "http"
assert r["url"] == "https://example.com/mcp"
def test_ws_auto_detected(self):
r = parse_mcp_add_args(["ws", "ws://localhost:9090"])
assert r["transport"] == "http"
def test_explicit_transport_override(self):
r = parse_mcp_add_args(["srv", "https://example.com/sse", "--transport", "sse"])
assert r["transport"] == "sse"
assert r["url"] == "https://example.com/sse"
def test_explicit_transport_short_flag(self):
r = parse_mcp_add_args(["srv", "https://x", "-T", "websocket"])
assert r["transport"] == "websocket"
def test_tools_flag(self):
r = parse_mcp_add_args(["srv", "http", "http://x", "--tools", "a,b"])
r = parse_mcp_add_args(["srv", "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"]
["srv", "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"]
["srv", "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"])
r = parse_mcp_add_args(["srv", "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"])
parse_mcp_add_args(["fs"])
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_double_dash_ignored(self):
r = parse_mcp_add_args(["srv", "npx", "--", "-y", "pkg"])
assert r["command"] == "npx"
assert r["args"] == ["-y", "pkg"]
assert "--" not in r["args"]
def test_http_missing_url_raises(self):
with pytest.raises(ValueError, match="requires a url"):
parse_mcp_add_args(["srv", "http", "--tools", "a"])
def test_env_ref_flag(self):
r = parse_mcp_add_args(["srv", "cmd", "--env-ref", "FOO"])
assert r["env"] == {"FOO": "${FOO}"}
def test_env_ref_and_env_combined(self):
r = parse_mcp_add_args(["srv", "cmd", "--env", "DEBUG=true", "--env-ref", "API_KEY"])
assert r["env"] == {"DEBUG": "true", "API_KEY": "${API_KEY}"}
def test_missing_command_or_url_raises(self):
with pytest.raises(ValueError, match="command or URL is required"):
parse_mcp_add_args(["fs", "--tools", "a"])
# ---- edit_mcp_server ----
+4 -4
View File
@@ -22,10 +22,10 @@ from EvoScientist.config import EvoScientistConfig
class TestConstants:
def test_steps_has_eight_items(self):
"""Test that STEPS contains exactly 8 steps."""
assert len(STEPS) == 8
assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Skills", "Channels"]
def test_steps_has_nine_items(self):
"""Test that STEPS contains exactly 9 steps."""
assert len(STEPS) == 9
assert STEPS == ["Provider", "API Key", "Model", "Tavily Key", "Workspace", "Parameters", "Skills", "MCP Servers", "Channels"]
def test_wizard_style_is_style_instance(self):
"""Test that WIZARD_STYLE is a prompt_toolkit Style."""