932c934485
* fix(mcp): give stdio subprocess a real stderr fd under redirected streams (#418) On Windows the Textual TUI redirects sys.stderr to an in-memory capture (textual.app._PrintCapture) whose fileno() returns -1. The MCP SDK forwards that stderr to stdio server subprocesses via subprocess.Popen(stderr=...), and Popen rejects the invalid handle with OSError: [Errno 9] Bad file descriptor — so only stdio servers fail to load (HTTP/SSE are unaffected). Wrap mcp.client.stdio.stdio_client so that, whenever the configured errlog has no usable fileno, it falls back to sys.__stderr__ (or os.devnull in GUI hosts). Idempotent, no-op when the SDK is absent, warns if the SDK renames stdio_client. Adds 9 regression tests and a troubleshooting note. * fix(mcp): validate live fd and close fallback errlog after stdio session Address CodeRabbit review on #423: - _stdio_errlog_is_usable now os.fstat()s the fd to reject closed streams that still report their former positive fileno (prevents a deferred [Errno 9] from subprocess.Popen). - The stdio_client wrapper owns the devnull fallback it allocates and closes it once the session exits, so repeated MCP reloads no longer leak file descriptors. Caller-provided usable errlogs pass through untouched. - Tests cover the closed-fd case, the fd-leak/closure invariant, and confirm langchain-mcp-adapters binds the patched stdio_client. * fix(mcp): rebind adapter stdio_client, forward errlog by kw, harden tests Address CodeRabbit round-2 review on #423: - The patch now also rebinds langchain_mcp_adapters.sessions.stdio_client, which the adapter captures via a 'from' import at module load — so the wrapped function reaches the adapter regardless of import order. - errlog is forwarded to the SDK by keyword (original(server, *args, errlog=errlog, **kwargs)) so a future SDK inserting a positional parameter before errlog can't mis-bind the fallback. - The fallback stream is now allocated inside the async context manager, so it is closed on session exit even if the CM is constructed but never entered (narrower fd-leak path). - test_closed_fd_rejected now reaches the os.fstat branch (stale positive fd stub) instead of the ValueError path; test_adapter_binds_patched_stdio_client documents and asserts the import-order-independent rebind. * fix(mcp): close fallback errlog when stdio_client construction fails Address CodeRabbit round-3 review on #423: move the original(server, *args, errlog=errlog, **kwargs) construction inside the try block so a failure during subprocess/client setup still reaches the finally and closes the wrapper-owned os.devnull stream. Added test_fallback_closed_when_construction_fails covering the path. * refactor(mcp): track fallback ownership via (stream, opened_by_us) Address din0s review on #423: - _safe_stdio_errlog() now returns (stream, opened_by_us); the wrapper closes the fallback only when opened_by_us is True, instead of inferring ownership from needs_fallback + an identity check against sys.__stderr__. Simpler and less likely to regress. - Removed dead try/finally in test_closed_fd_rejected. - Added test_wrapped_stdio_client_swaps_explicit_bad_errlog covering the 'not _stdio_errlog_is_usable(errlog)' branch (explicit bad errlog, not the default sentinel). - Updated test_safe_errlog_returns_usable_stream for the tuple return. --------- Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
1906 lines
69 KiB
Python
1906 lines
69 KiB
Python
"""Tests for EvoScientist.mcp module."""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import textwrap
|
|
import threading
|
|
from contextlib import asynccontextmanager
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
import yaml
|
|
|
|
from EvoScientist.mcp.client import (
|
|
_build_connections,
|
|
_filter_tools,
|
|
_interpolate_env,
|
|
_resolve_command,
|
|
_route_tools,
|
|
add_mcp_server,
|
|
edit_mcp_server,
|
|
load_mcp_config,
|
|
load_mcp_tools,
|
|
parse_mcp_add_args,
|
|
parse_mcp_edit_args,
|
|
remove_mcp_server,
|
|
)
|
|
from EvoScientist.runtime import AsyncRuntime, AsyncRuntimeError
|
|
|
|
# ---- _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 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, mcp_config_file):
|
|
# File doesn't exist yet
|
|
assert load_mcp_config() == {}
|
|
|
|
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()
|
|
assert "my-server" in result
|
|
assert result["my-server"]["transport"] == "stdio"
|
|
|
|
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, 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, mcp_config_file, monkeypatch):
|
|
monkeypatch.setenv("TEST_TOKEN", "tok_abc")
|
|
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()
|
|
assert result["my-server"]["headers"]["Authorization"] == "Bearer tok_abc"
|
|
|
|
|
|
# ---- _build_connections ----
|
|
|
|
|
|
# ---- _resolve_command ----
|
|
|
|
|
|
class TestResolveCommand:
|
|
def test_absolute_path_returned_as_is(self, tmp_path):
|
|
"""Absolute paths are never modified, even if the file doesn't exist."""
|
|
fake = str(tmp_path / "mytool")
|
|
assert _resolve_command(fake) == fake
|
|
|
|
def test_found_on_path(self):
|
|
"""Commands found via shutil.which are returned as full paths."""
|
|
result = _resolve_command("python")
|
|
# Cross-platform: ``shutil.which`` may return ``python.exe`` /
|
|
# ``python3.exe`` (case may differ). Match the basename stem
|
|
# case-insensitively rather than pinning the suffix.
|
|
assert Path(result).stem.lower() in ("python", "python3")
|
|
assert result != "python" # resolved, not the bare name
|
|
|
|
def test_found_in_python_bin(self, tmp_path, monkeypatch):
|
|
"""Falls back to sys.executable's directory when not in PATH."""
|
|
|
|
# Create a fake executable next to sys.executable
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
fake_exe = bin_dir / "my-mcp-tool"
|
|
fake_exe.write_text("#!/bin/sh\n")
|
|
fake_exe.chmod(0o755)
|
|
|
|
monkeypatch.setattr("shutil.which", lambda _: None)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.mcp.client.sys.executable", str(bin_dir / "python")
|
|
)
|
|
|
|
assert _resolve_command("my-mcp-tool") == str(fake_exe)
|
|
|
|
def test_not_found_returns_original(self, monkeypatch):
|
|
"""Returns the original command when not found anywhere (let OS report the error)."""
|
|
monkeypatch.setattr("shutil.which", lambda _: None)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.mcp.client.sys.executable", "/nonexistent/bin/python"
|
|
)
|
|
assert _resolve_command("unknown-tool-xyz") == "unknown-tool-xyz"
|
|
|
|
def test_build_connections_resolves_command(self, monkeypatch):
|
|
"""_build_connections uses _resolve_command so the full path appears in output."""
|
|
monkeypatch.setattr(
|
|
"EvoScientist.mcp.client._resolve_command", lambda cmd: f"/resolved/{cmd}"
|
|
)
|
|
config = {"srv": {"transport": "stdio", "command": "mytool", "args": []}}
|
|
conns = _build_connections(config)
|
|
assert conns["srv"]["command"] == "/resolved/mytool"
|
|
|
|
|
|
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"].endswith("npx") or conns["fs"][
|
|
"command"
|
|
].lower().endswith("npx.cmd")
|
|
assert conns["fs"]["args"] == ["-y", "server"]
|
|
|
|
def test_stdio_with_env(self, monkeypatch):
|
|
for var in (
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"all_proxy",
|
|
"ALL_PROXY",
|
|
"no_proxy",
|
|
"NO_PROXY",
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
"REQUESTS_CA_BUNDLE",
|
|
"CURL_CA_BUNDLE",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
):
|
|
monkeypatch.delenv(var, raising=False)
|
|
config = {
|
|
"fs": {
|
|
"transport": "stdio",
|
|
"command": "npx",
|
|
"args": [],
|
|
"env": {"FOO": "bar"},
|
|
}
|
|
}
|
|
conns = _build_connections(config)
|
|
assert conns["fs"]["env"] == {"FOO": "bar"}
|
|
|
|
def test_stdio_forwards_proxy_and_cert_env(self, monkeypatch):
|
|
"""Proxy and CA bundle env vars are forwarded to stdio subprocesses."""
|
|
monkeypatch.setenv("https_proxy", "http://proxy:3128")
|
|
monkeypatch.setenv("SSL_CERT_FILE", "/etc/ssl/certs/ca.crt")
|
|
config = {"fs": {"transport": "stdio", "command": "npx", "args": []}}
|
|
conns = _build_connections(config)
|
|
assert conns["fs"]["env"]["https_proxy"] == "http://proxy:3128"
|
|
assert conns["fs"]["env"]["SSL_CERT_FILE"] == "/etc/ssl/certs/ca.crt"
|
|
|
|
def test_stdio_user_env_overrides_forwarded(self, monkeypatch):
|
|
"""User-configured env takes precedence over auto-forwarded values."""
|
|
monkeypatch.setenv("https_proxy", "http://host-proxy:3128")
|
|
config = {
|
|
"fs": {
|
|
"transport": "stdio",
|
|
"command": "npx",
|
|
"args": [],
|
|
"env": {"https_proxy": "http://user-proxy:9000"},
|
|
}
|
|
}
|
|
conns = _build_connections(config)
|
|
assert conns["fs"]["env"]["https_proxy"] == "http://user-proxy:9000"
|
|
|
|
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"}
|
|
|
|
|
|
# ---- stdio errlog safety patch (issue #418) ----
|
|
|
|
|
|
class _FakeTextualStderr:
|
|
"""Stand-in for ``textual.app._PrintCapture``.
|
|
|
|
Mirrors the TUI's redirected stderr: a file-like object whose ``fileno()``
|
|
returns ``-1`` (no real OS handle), which makes ``subprocess.Popen`` fail
|
|
with ``OSError: [Errno 9] Bad file descriptor`` when it is passed as
|
|
``stderr``.
|
|
"""
|
|
|
|
def write(self, data):
|
|
return len(data)
|
|
|
|
def flush(self): # pragma: no cover - trivial
|
|
pass
|
|
|
|
def fileno(self): # pragma: no cover - exercised via the helper
|
|
return -1
|
|
|
|
def isatty(self):
|
|
return True
|
|
|
|
|
|
class TestStdioErrlogSafetyPatch:
|
|
"""The stdio errlog guard (issue #418) keeps stdio MCP subprocesses alive
|
|
when the parent's ``sys.stderr`` is a redirected stream with no fileno."""
|
|
|
|
def test_bad_fileno_rejected(self):
|
|
from EvoScientist.mcp.client import _stdio_errlog_is_usable
|
|
|
|
assert _stdio_errlog_is_usable(_FakeTextualStderr()) is False
|
|
|
|
def test_negative_fileno_rejected(self):
|
|
from EvoScientist.mcp.client import _stdio_errlog_is_usable
|
|
|
|
class _Neg:
|
|
def fileno(self):
|
|
return -1
|
|
|
|
assert _stdio_errlog_is_usable(_Neg()) is False
|
|
|
|
def test_fileno_raising_rejected(self):
|
|
from EvoScientist.mcp.client import _stdio_errlog_is_usable
|
|
|
|
class _Raises:
|
|
def fileno(self):
|
|
raise OSError("no fileno")
|
|
|
|
assert _stdio_errlog_is_usable(_Raises()) is False
|
|
|
|
def test_missing_fileno_rejected(self):
|
|
from EvoScientist.mcp.client import _stdio_errlog_is_usable
|
|
|
|
assert _stdio_errlog_is_usable(object()) is False
|
|
|
|
def test_closed_fd_rejected(self):
|
|
"""A closed stream may still report its former positive fd; the helper
|
|
must reject it via os.fstat so subprocess.Popen doesn't fail later."""
|
|
from EvoScientist.mcp.client import _stdio_errlog_is_usable
|
|
|
|
r, w = os.pipe()
|
|
# Capture the descriptor number, then close both ends. The stub
|
|
# below still reports ``r`` (now closed) from fileno(), exercising
|
|
# the os.fstat branch rather than the ValueError path.
|
|
stale_fd = r
|
|
os.close(r)
|
|
os.close(w)
|
|
|
|
class _StaleFd:
|
|
"""Reports a positive fd number that is no longer open."""
|
|
|
|
def fileno(self):
|
|
return stale_fd
|
|
|
|
assert _stdio_errlog_is_usable(_StaleFd()) is False
|
|
|
|
def test_real_stderr_accepted(self):
|
|
|
|
from EvoScientist.mcp.client import _stdio_errlog_is_usable
|
|
|
|
# sys.__stderr__ is the original console handle; usable unless the
|
|
# process is a GUI host (pythonw). Skip there since there's nothing
|
|
# usable to assert.
|
|
if getattr(sys, "__stderr__", None) is None:
|
|
pytest.skip("no console stderr in this host")
|
|
assert _stdio_errlog_is_usable(sys.__stderr__) is True
|
|
|
|
def test_safe_errlog_returns_usable_stream(self):
|
|
|
|
from EvoScientist.mcp.client import _safe_stdio_errlog, _stdio_errlog_is_usable
|
|
|
|
stream, opened_by_us = _safe_stdio_errlog()
|
|
try:
|
|
assert _stdio_errlog_is_usable(stream) is True
|
|
# sys.__stderr__ path is not owned; devnull path is.
|
|
assert opened_by_us is (stream is not sys.__stderr__)
|
|
finally:
|
|
if opened_by_us:
|
|
stream.close()
|
|
|
|
def test_patch_wraps_stdio_client(self):
|
|
"""Importing the MCP client wraps ``mcp.client.stdio.stdio_client``."""
|
|
import mcp.client.stdio as stdio_mod
|
|
|
|
# Importing EvoScientist.mcp.client applies the patch at module load.
|
|
import EvoScientist.mcp.client # noqa: F401
|
|
|
|
assert getattr(stdio_mod.stdio_client, "_evosci_errlog_safe", False) is True
|
|
|
|
def test_wrapped_stdio_client_swaps_bad_errlog(self, monkeypatch):
|
|
"""The wrapped ``stdio_client`` substitutes a usable errlog when the
|
|
caller's default has no fileno (the issue #418 condition), and closes
|
|
the wrapper-created fallback after the session exits."""
|
|
import asyncio
|
|
|
|
from EvoScientist.mcp import client as mcp_client
|
|
|
|
captured = {}
|
|
sentinel_server = object()
|
|
|
|
@asynccontextmanager
|
|
async def fake_original(server, errlog, *args, **kwargs):
|
|
captured["server"] = server
|
|
captured["errlog"] = errlog
|
|
yield ("read", "write")
|
|
|
|
import mcp.client.stdio as stdio_mod
|
|
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
# Re-run the patch logic against the fake.
|
|
mcp_client._patch_mcp_stdio_errlog_safe()
|
|
try:
|
|
cm = stdio_mod.stdio_client(sentinel_server)
|
|
|
|
async def _run():
|
|
async with cm as streams:
|
|
assert streams == ("read", "write")
|
|
# Inside the context the fallback errlog is usable.
|
|
assert mcp_client._stdio_errlog_is_usable(captured["errlog"])
|
|
fallback = captured["errlog"]
|
|
# After exit, a wrapper-owned devnull fallback is closed.
|
|
return fallback
|
|
|
|
fallback = asyncio.run(_run())
|
|
assert captured["server"] is sentinel_server
|
|
import sys
|
|
|
|
if fallback is not sys.__stderr__:
|
|
# closed stream is no longer usable
|
|
assert mcp_client._stdio_errlog_is_usable(fallback) is False
|
|
finally:
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
|
|
def test_wrapped_stdio_client_keeps_good_errlog(self, monkeypatch):
|
|
"""An explicitly-passed usable errlog is forwarded unchanged and is NOT
|
|
closed by the wrapper (it's caller-owned)."""
|
|
import asyncio
|
|
|
|
from EvoScientist.mcp import client as mcp_client
|
|
|
|
if getattr(sys, "__stderr__", None) is None:
|
|
pytest.skip("no console stderr in this host")
|
|
|
|
captured = {}
|
|
|
|
@asynccontextmanager
|
|
async def fake_original(server, errlog, *args, **kwargs):
|
|
captured["errlog"] = errlog
|
|
yield ("read", "write")
|
|
|
|
import mcp.client.stdio as stdio_mod
|
|
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
mcp_client._patch_mcp_stdio_errlog_safe()
|
|
try:
|
|
|
|
async def _run():
|
|
async with stdio_mod.stdio_client(object(), errlog=sys.__stderr__):
|
|
pass
|
|
|
|
asyncio.run(_run())
|
|
assert captured["errlog"] is sys.__stderr__
|
|
# Caller-owned errlog must remain usable (not closed by wrapper).
|
|
assert mcp_client._stdio_errlog_is_usable(sys.__stderr__) is True
|
|
finally:
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
|
|
def test_wrapped_stdio_client_swaps_explicit_bad_errlog(self, monkeypatch):
|
|
"""Cover the ``not _stdio_errlog_is_usable(errlog)`` branch: when the
|
|
caller explicitly passes an errlog whose fileno is unusable (e.g. a
|
|
redirected stream like Textual's _PrintCapture), the wrapper substitutes
|
|
a usable fallback rather than forwarding the broken stream."""
|
|
import asyncio
|
|
|
|
from EvoScientist.mcp import client as mcp_client
|
|
|
|
captured = {}
|
|
|
|
@asynccontextmanager
|
|
async def fake_original(server, *args, **kwargs):
|
|
captured["errlog"] = kwargs["errlog"]
|
|
yield ("read", "write")
|
|
|
|
import mcp.client.stdio as stdio_mod
|
|
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
mcp_client._patch_mcp_stdio_errlog_safe()
|
|
try:
|
|
# Explicitly pass the unusable redirected stream (NOT relying on
|
|
# the `errlog is ...` default sentinel).
|
|
bad_errlog = _FakeTextualStderr()
|
|
|
|
async def _run():
|
|
async with stdio_mod.stdio_client(object(), errlog=bad_errlog):
|
|
pass
|
|
|
|
asyncio.run(_run())
|
|
# The wrapper must have swapped in a usable fallback, not forwarded
|
|
# the broken _FakeTextualStderr.
|
|
assert captured["errlog"] is not bad_errlog
|
|
assert mcp_client._stdio_errlog_is_usable(captured["errlog"]) is True
|
|
finally:
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
|
|
def test_fallback_devnull_closed_after_session(self, monkeypatch):
|
|
"""Forcing the devnull fallback path closes the handle once the stdio
|
|
session exits — no fd leak across MCP reloads."""
|
|
import asyncio
|
|
|
|
from EvoScientist.mcp import client as mcp_client
|
|
|
|
# Force the devnull fallback by making sys.__stderr__ unusable.
|
|
monkeypatch.setattr("sys.__stderr__", None, raising=False)
|
|
|
|
opened = {}
|
|
|
|
real_open = open
|
|
|
|
def tracking_open(path, *args, **kwargs):
|
|
f = real_open(path, *args, **kwargs)
|
|
if str(path) == os.devnull:
|
|
opened["stream"] = f
|
|
return f
|
|
|
|
monkeypatch.setattr("builtins.open", tracking_open)
|
|
|
|
@asynccontextmanager
|
|
async def fake_original(server, errlog, *args, **kwargs):
|
|
yield ("read", "write")
|
|
|
|
import mcp.client.stdio as stdio_mod
|
|
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
mcp_client._patch_mcp_stdio_errlog_safe()
|
|
try:
|
|
cm = stdio_mod.stdio_client(object())
|
|
|
|
async def _run():
|
|
async with cm:
|
|
assert "stream" in opened, "fallback devnull was opened"
|
|
fd = opened["stream"].fileno()
|
|
assert fd >= 0
|
|
|
|
asyncio.run(_run())
|
|
# After the session exits the devnull stream must be closed.
|
|
assert opened["stream"].closed is True
|
|
finally:
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
|
|
def test_fallback_closed_when_construction_fails(self, monkeypatch):
|
|
"""If the SDK's ``stdio_client`` raises during construction, the
|
|
wrapper-owned fallback stream is still closed — no fd leak on the
|
|
construction-failure path."""
|
|
import asyncio
|
|
|
|
from EvoScientist.mcp import client as mcp_client
|
|
|
|
# Force the devnull fallback by making sys.__stderr__ unusable.
|
|
monkeypatch.setattr("sys.__stderr__", None, raising=False)
|
|
|
|
opened = {}
|
|
real_open = open
|
|
|
|
def tracking_open(path, *args, **kwargs):
|
|
f = real_open(path, *args, **kwargs)
|
|
if str(path) == os.devnull:
|
|
opened["stream"] = f
|
|
return f
|
|
|
|
monkeypatch.setattr("builtins.open", tracking_open)
|
|
|
|
@asynccontextmanager
|
|
async def fake_original(server, *args, **kwargs):
|
|
# Construction itself fails (e.g. bad command / SDK error).
|
|
raise RuntimeError("construction failed")
|
|
yield # pragma: no cover - unreachable
|
|
|
|
import mcp.client.stdio as stdio_mod
|
|
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
mcp_client._patch_mcp_stdio_errlog_safe()
|
|
try:
|
|
cm = stdio_mod.stdio_client(object())
|
|
|
|
async def _run():
|
|
with pytest.raises(RuntimeError, match="construction failed"):
|
|
async with cm:
|
|
pass
|
|
|
|
asyncio.run(_run())
|
|
# The fallback stream must be closed despite the construction error.
|
|
assert "stream" in opened, "fallback devnull was opened"
|
|
assert opened["stream"].closed is True
|
|
finally:
|
|
monkeypatch.setattr(stdio_mod, "stdio_client", fake_original)
|
|
|
|
def test_adapter_binds_patched_stdio_client(self):
|
|
"""``langchain-mcp-adapters`` binds ``stdio_client`` via a ``from``
|
|
import at its module load, so it could capture the unwrapped function
|
|
if imported before the patch. The patch re-binds the adapter's
|
|
reference too, so either import order reaches the wrapped function.
|
|
|
|
Verifies the binding rather than spawning a real server, which keeps
|
|
the test deterministic and free of handshake deadlocks.
|
|
"""
|
|
import mcp.client.stdio as stdio_mod
|
|
from langchain_mcp_adapters import sessions as adapter_sessions
|
|
|
|
import EvoScientist.mcp.client # noqa: F401 — applies the patch
|
|
|
|
# Both the SDK module and the adapter must resolve to the same wrapped
|
|
# object, regardless of which was imported first.
|
|
assert adapter_sessions.stdio_client is stdio_mod.stdio_client
|
|
assert getattr(adapter_sessions.stdio_client, "_evosci_errlog_safe", False)
|
|
|
|
|
|
# ---- _filter_tools ----
|
|
|
|
|
|
def _make_tool(name: str):
|
|
"""Create a minimal mock tool with a .name attribute."""
|
|
return SimpleNamespace(name=name)
|
|
|
|
|
|
class TestOwnedRuntimeLoading:
|
|
def test_reuses_caller_runtime_for_discovery(self, monkeypatch):
|
|
executions: list[tuple[str, asyncio.AbstractEventLoop]] = []
|
|
tool = _make_tool("search")
|
|
|
|
async def fake_load(_config, *, on_progress=None):
|
|
executions.append(
|
|
(threading.current_thread().name, asyncio.get_running_loop())
|
|
)
|
|
return {"server": [tool]}
|
|
|
|
monkeypatch.setattr("EvoScientist.mcp.client._load_tools", fake_load)
|
|
config = {"server": {"transport": "http", "url": "http://example.test"}}
|
|
|
|
with AsyncRuntime(thread_name="test-mcp-runtime") as runtime:
|
|
first = load_mcp_tools(config, runtime=runtime)
|
|
second = load_mcp_tools(config, runtime=runtime)
|
|
|
|
assert first == {"main": [tool]}
|
|
assert second == {"main": [tool]}
|
|
assert [thread for thread, _loop in executions] == [
|
|
"test-mcp-runtime",
|
|
"test-mcp-runtime",
|
|
]
|
|
assert executions[0][1] is executions[1][1]
|
|
|
|
def test_direct_caller_gets_a_scoped_runtime(self, monkeypatch):
|
|
execution: dict[str, object] = {}
|
|
|
|
async def fake_load(_config, *, on_progress=None):
|
|
execution["thread"] = threading.current_thread().name
|
|
execution["loop"] = asyncio.get_running_loop()
|
|
return {"server": []}
|
|
|
|
monkeypatch.setattr("EvoScientist.mcp.client._load_tools", fake_load)
|
|
config = {"server": {"transport": "http", "url": "http://example.test"}}
|
|
|
|
assert load_mcp_tools(config) == {"main": []}
|
|
assert execution["thread"] == "evosci-mcp-runtime"
|
|
assert isinstance(execution["loop"], asyncio.AbstractEventLoop)
|
|
assert not any(
|
|
thread.name == "evosci-mcp-runtime" and thread.is_alive()
|
|
for thread in threading.enumerate()
|
|
)
|
|
|
|
async def test_direct_caller_does_not_hide_running_loop_violation(self):
|
|
config = {"server": {"transport": "http", "url": "http://example.test"}}
|
|
|
|
with pytest.raises(AsyncRuntimeError, match=r"await aload_mcp_tools\(config"):
|
|
load_mcp_tools(config)
|
|
|
|
|
|
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) == []
|
|
|
|
# Wildcard tests
|
|
|
|
def test_wildcard_star_suffix(self):
|
|
"""Test *_exa pattern matching."""
|
|
tools = [
|
|
_make_tool("web_search_exa"),
|
|
_make_tool("get_code_context_exa"),
|
|
_make_tool("company_research_exa"),
|
|
_make_tool("unrelated_tool"),
|
|
]
|
|
result = _filter_tools(tools, ["*_exa"])
|
|
assert [t.name for t in result] == [
|
|
"web_search_exa",
|
|
"get_code_context_exa",
|
|
"company_research_exa",
|
|
]
|
|
|
|
def test_wildcard_star_prefix(self):
|
|
"""Test read_* pattern matching."""
|
|
tools = [
|
|
_make_tool("read_file"),
|
|
_make_tool("read_directory"),
|
|
_make_tool("read_link"),
|
|
_make_tool("write_file"),
|
|
]
|
|
result = _filter_tools(tools, ["read_*"])
|
|
assert [t.name for t in result] == [
|
|
"read_file",
|
|
"read_directory",
|
|
"read_link",
|
|
]
|
|
|
|
def test_wildcard_star_middle(self):
|
|
"""Test pattern with * in the middle."""
|
|
tools = [
|
|
_make_tool("get_user_data"),
|
|
_make_tool("get_admin_data"),
|
|
_make_tool("get_file"),
|
|
]
|
|
result = _filter_tools(tools, ["get_*_data"])
|
|
assert [t.name for t in result] == ["get_user_data", "get_admin_data"]
|
|
|
|
def test_wildcard_star_only(self):
|
|
"""Test * matches everything."""
|
|
tools = [_make_tool("a"), _make_tool("b"), _make_tool("c")]
|
|
result = _filter_tools(tools, ["*"])
|
|
assert [t.name for t in result] == ["a", "b", "c"]
|
|
|
|
def test_wildcard_question_mark(self):
|
|
"""Test ? matches single character."""
|
|
tools = [
|
|
_make_tool("tool_1"),
|
|
_make_tool("tool_2"),
|
|
_make_tool("tool_10"),
|
|
]
|
|
result = _filter_tools(tools, ["tool_?"])
|
|
assert [t.name for t in result] == ["tool_1", "tool_2"]
|
|
|
|
def test_wildcard_character_class(self):
|
|
"""Test [seq] matches characters in sequence."""
|
|
tools = [
|
|
_make_tool("tool_a"),
|
|
_make_tool("tool_b"),
|
|
_make_tool("tool_c"),
|
|
_make_tool("tool_d"),
|
|
]
|
|
result = _filter_tools(tools, ["tool_[abc]"])
|
|
assert [t.name for t in result] == ["tool_a", "tool_b", "tool_c"]
|
|
|
|
def test_wildcard_character_class_range(self):
|
|
"""Test [0-9] matches digit range."""
|
|
tools = [
|
|
_make_tool("tool_0"),
|
|
_make_tool("tool_5"),
|
|
_make_tool("tool_9"),
|
|
_make_tool("tool_a"),
|
|
]
|
|
result = _filter_tools(tools, ["tool_[0-9]"])
|
|
assert [t.name for t in result] == ["tool_0", "tool_5", "tool_9"]
|
|
|
|
def test_wildcard_negated_character_class(self):
|
|
"""Test [!seq] matches characters not in sequence."""
|
|
tools = [
|
|
_make_tool("tool_a"),
|
|
_make_tool("tool_b"),
|
|
_make_tool("tool_1"),
|
|
_make_tool("tool_2"),
|
|
]
|
|
result = _filter_tools(tools, ["tool_[!0-9]"])
|
|
assert [t.name for t in result] == ["tool_a", "tool_b"]
|
|
|
|
def test_wildcard_mixed_with_exact(self):
|
|
"""Test mixing wildcard and exact patterns."""
|
|
tools = [
|
|
_make_tool("web_search_exa"),
|
|
_make_tool("get_code_context_exa"),
|
|
_make_tool("specific_tool"),
|
|
_make_tool("another_tool"),
|
|
]
|
|
result = _filter_tools(tools, ["*_exa", "specific_tool"])
|
|
assert [t.name for t in result] == [
|
|
"web_search_exa",
|
|
"get_code_context_exa",
|
|
"specific_tool",
|
|
]
|
|
|
|
def test_wildcard_multiple_patterns(self):
|
|
"""Test multiple wildcard patterns."""
|
|
tools = [
|
|
_make_tool("read_file"),
|
|
_make_tool("write_file"),
|
|
_make_tool("delete_file"),
|
|
_make_tool("search_database"),
|
|
]
|
|
result = _filter_tools(tools, ["read_*", "write_*"])
|
|
assert [t.name for t in result] == ["read_file", "write_file"]
|
|
|
|
def test_wildcard_no_match(self):
|
|
"""Test wildcard pattern that doesn't match anything."""
|
|
tools = [_make_tool("foo"), _make_tool("bar")]
|
|
result = _filter_tools(tools, ["baz_*"])
|
|
assert result == []
|
|
|
|
def test_wildcard_overlapping_patterns(self):
|
|
"""Test overlapping patterns don't duplicate results."""
|
|
tools = [_make_tool("tool_abc"), _make_tool("tool_xyz")]
|
|
result = _filter_tools(tools, ["tool_*", "*_abc"])
|
|
# Should include each tool only once
|
|
assert [t.name for t in result] == ["tool_abc", "tool_xyz"]
|
|
|
|
def test_wildcard_complex_pattern(self):
|
|
"""Test complex wildcard pattern."""
|
|
tools = [
|
|
_make_tool("get_user_info_v1"),
|
|
_make_tool("get_user_info_v2"),
|
|
_make_tool("get_admin_info_v1"),
|
|
_make_tool("set_user_info"),
|
|
]
|
|
result = _filter_tools(tools, ["get_*_info_v?"])
|
|
assert [t.name for t in result] == [
|
|
"get_user_info_v1",
|
|
"get_user_info_v2",
|
|
"get_admin_info_v1",
|
|
]
|
|
|
|
def test_exact_match_performance_path(self):
|
|
"""Test exact matching still works (fast path)."""
|
|
# This test verifies backward compatibility with exact matching
|
|
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"]
|
|
|
|
|
|
# ---- _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
|
|
assert "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", "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_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"] == "websocket"
|
|
|
|
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://x", "--tools", "a,b"])
|
|
assert r["tools"] == ["a", "b"]
|
|
|
|
def test_expose_to_flag(self):
|
|
r = parse_mcp_add_args(["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://x", "--header", "Authorization:Bearer tok"]
|
|
)
|
|
assert r["headers"] == {"Authorization": "Bearer tok"}
|
|
|
|
def test_env_flag(self):
|
|
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"])
|
|
|
|
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_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 ----
|
|
|
|
|
|
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"])
|
|
|
|
|
|
# ---- uv tool compatibility ----
|
|
|
|
|
|
class TestUvToolCompat:
|
|
"""Tests for uv tool environment detection and compatible install helpers."""
|
|
|
|
# -- _is_uv_tool_env --
|
|
|
|
def test_is_uv_tool_env_false_when_no_virtual_env(self, monkeypatch):
|
|
from EvoScientist.mcp.registry import _is_uv_tool_env
|
|
|
|
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
|
assert _is_uv_tool_env() is False
|
|
|
|
def test_is_uv_tool_env_false_for_regular_venv(self, monkeypatch):
|
|
from EvoScientist.mcp.registry import _is_uv_tool_env
|
|
|
|
monkeypatch.setenv("VIRTUAL_ENV", "/home/user/projects/myapp/.venv")
|
|
assert _is_uv_tool_env() is False
|
|
|
|
def test_is_uv_tool_env_true_unix(self, monkeypatch):
|
|
from EvoScientist.mcp.registry import _is_uv_tool_env
|
|
|
|
monkeypatch.setenv(
|
|
"VIRTUAL_ENV", "/home/user/.local/share/uv/tools/evoscientist"
|
|
)
|
|
assert _is_uv_tool_env() is True
|
|
|
|
def test_is_uv_tool_env_true_windows_backslashes(self, monkeypatch):
|
|
from EvoScientist.mcp.registry import _is_uv_tool_env
|
|
|
|
monkeypatch.setenv(
|
|
"VIRTUAL_ENV", r"C:\Users\user\AppData\Local\uv\tools\evoscientist"
|
|
)
|
|
assert _is_uv_tool_env() is True
|
|
|
|
# -- _uv_tool_name --
|
|
|
|
def test_uv_tool_name_returns_name(self, monkeypatch):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.setenv(
|
|
"VIRTUAL_ENV", "/home/user/.local/share/uv/tools/evoscientist"
|
|
)
|
|
assert reg._uv_tool_name() == "evoscientist"
|
|
|
|
def test_uv_tool_name_returns_none_when_not_uv(self, monkeypatch):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.setenv("VIRTUAL_ENV", "/home/user/projects/myapp/.venv")
|
|
assert reg._uv_tool_name() is None
|
|
|
|
def test_uv_tool_name_returns_none_when_no_virtual_env(self, monkeypatch):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
|
|
assert reg._uv_tool_name() is None
|
|
|
|
# -- _uv_tool_existing_requirements --
|
|
|
|
def test_existing_requirements_from_receipt(self, monkeypatch, tmp_path):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
"[tool]\nrequirements = [\n"
|
|
' { name = "evoscientist" },\n'
|
|
' { name = "arxiv-mcp-server" },\n'
|
|
' { name = "rich" },\n'
|
|
"]\n"
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
result = reg._uv_tool_existing_requirements()
|
|
assert result == {"arxiv-mcp-server": "arxiv-mcp-server", "rich": "rich"}
|
|
|
|
def test_existing_requirements_preserves_specifiers_and_extras(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
"[tool]\nrequirements = [\n"
|
|
' { name = "evoscientist" },\n'
|
|
' { name = "rich", specifier = ">=13.0" },\n'
|
|
' { name = "requests", extras = ["socks"] },\n'
|
|
' { name = "lark-oapi", specifier = ">=1.4.0", extras = ["oauth"] },\n'
|
|
"]\n"
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
result = reg._uv_tool_existing_requirements()
|
|
assert result == {
|
|
"rich": "rich>=13.0",
|
|
"requests": "requests[socks]",
|
|
"lark-oapi": "lark-oapi[oauth]>=1.4.0",
|
|
}
|
|
|
|
def test_existing_requirements_excludes_tool_name(self, monkeypatch, tmp_path):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
'[tool]\nrequirements = [\n { name = "evoscientist" },\n]\n'
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
assert reg._uv_tool_existing_requirements() == {}
|
|
|
|
def test_existing_requirements_no_receipt(self, monkeypatch, tmp_path):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
assert reg._uv_tool_existing_requirements() == {}
|
|
|
|
# -- pip_install_hint --
|
|
|
|
def test_pip_install_hint_uv_tool(self, monkeypatch):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: True)
|
|
hint = reg.pip_install_hint()
|
|
assert "uv tool install --reinstall evoscientist --with" in hint
|
|
|
|
def test_pip_install_hint_uv_no_tool(self, monkeypatch):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
assert reg.pip_install_hint() == "uv pip install"
|
|
|
|
def test_pip_install_hint_plain_pip(self, monkeypatch):
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
|
|
assert reg.pip_install_hint() == "pip install"
|
|
|
|
# -- install_library / install_cli_tool --
|
|
|
|
def test_install_library_uv_tool_env_uses_uv_tool_install(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""In a uv tool env, should use ``uv tool install --with`` for durability."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
# Set up a fake uv tool env with receipt
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
"[tool]\nrequirements = [\n"
|
|
' { name = "evoscientist" },\n'
|
|
' { name = "existing-pkg" },\n'
|
|
"]\n"
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(list(cmd))
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
result = reg.install_library("new-mcp-server")
|
|
assert result is True
|
|
assert len(captured) == 1
|
|
cmd = captured[0]
|
|
assert cmd[:3] == ["uv", "tool", "install"]
|
|
assert "evoscientist" in cmd
|
|
# Must preserve existing --with requirement
|
|
assert "--with" in cmd
|
|
with_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "--with"]
|
|
assert "existing-pkg" in with_args
|
|
assert "new-mcp-server" in with_args
|
|
|
|
def test_install_library_uv_tool_env_no_duplicate_with(self, monkeypatch, tmp_path):
|
|
"""If the package is already in the receipt, don't add it twice."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
"[tool]\nrequirements = [\n"
|
|
' { name = "evoscientist" },\n'
|
|
' { name = "arxiv-mcp-server" },\n'
|
|
"]\n"
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(list(cmd))
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
reg.install_library("arxiv-mcp-server")
|
|
cmd = captured[0]
|
|
with_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "--with"]
|
|
assert with_args.count("arxiv-mcp-server") == 1
|
|
|
|
def test_install_library_uv_tool_preserves_specifiers(self, monkeypatch, tmp_path):
|
|
"""Existing --with specs with extras/versions must be preserved."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
"[tool]\nrequirements = [\n"
|
|
' { name = "evoscientist" },\n'
|
|
' { name = "rich", specifier = ">=13.0" },\n'
|
|
' { name = "requests", extras = ["socks"] },\n'
|
|
"]\n"
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(list(cmd))
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
reg.install_library("new-pkg")
|
|
cmd = captured[0]
|
|
with_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "--with"]
|
|
assert "rich>=13.0" in with_args
|
|
assert "requests[socks]" in with_args
|
|
assert "new-pkg" in with_args
|
|
|
|
def test_install_library_uv_tool_dedup_with_version_spec(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""Dedup must match bare name even if package arg has version spec."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
"[tool]\nrequirements = [\n"
|
|
' { name = "evoscientist" },\n'
|
|
' { name = "rich", specifier = ">=13.0" },\n'
|
|
"]\n"
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(list(cmd))
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
# package arg has version constraint — should still dedup against "rich"
|
|
reg.install_library("rich>=14.0")
|
|
cmd = captured[0]
|
|
with_args = [cmd[i + 1] for i, v in enumerate(cmd) if v == "--with"]
|
|
# Should keep the existing spec, not add a duplicate
|
|
assert with_args.count("rich>=13.0") == 1
|
|
assert "rich>=14.0" not in with_args
|
|
|
|
def test_install_library_uv_tool_falls_back_on_failure(self, monkeypatch, tmp_path):
|
|
"""install_library: if ``uv tool install --with`` fails, fall back
|
|
to ``uv pip install`` — standalone uv tool install is NEVER tried."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
'[tool]\nrequirements = [\n { name = "evoscientist" },\n]\n'
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
commands.append(cmd)
|
|
if cmd[:3] == ["uv", "tool", "install"]:
|
|
return type("R", (), {"returncode": 1})()
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
result = reg.install_library("some-package")
|
|
assert result is True
|
|
# Order: uv tool install --with (fail), uv pip install (ok).
|
|
assert len(commands) == 2
|
|
assert commands[0][:3] == ["uv", "tool", "install"]
|
|
assert "--with" in commands[0]
|
|
assert commands[1][:3] == ["uv", "pip", "install"]
|
|
|
|
def test_install_cli_tool_uv_tool_env_tries_standalone_on_failure(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""install_cli_tool: uv-tool env, --with fails → standalone uv tool
|
|
install is tried, then pip fallback."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
venv = tmp_path / "uv" / "tools" / "evoscientist"
|
|
venv.mkdir(parents=True)
|
|
receipt = venv / "uv-receipt.toml"
|
|
receipt.write_text(
|
|
'[tool]\nrequirements = [\n { name = "evoscientist" },\n]\n'
|
|
)
|
|
monkeypatch.setenv("VIRTUAL_ENV", str(venv))
|
|
|
|
commands: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
commands.append(cmd)
|
|
if cmd[:3] == ["uv", "tool", "install"]:
|
|
return type("R", (), {"returncode": 1})()
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
result = reg.install_cli_tool("some-cli", verify_command="some-cli")
|
|
assert result is True
|
|
assert len(commands) == 3
|
|
assert commands[0][:3] == ["uv", "tool", "install"]
|
|
assert "--with" in commands[0]
|
|
assert commands[1][:3] == ["uv", "tool", "install"]
|
|
assert "--with" not in commands[1]
|
|
assert commands[2][:3] == ["uv", "pip", "install"]
|
|
|
|
def test_install_library_goes_straight_to_pip_outside_uv_tool(self, monkeypatch):
|
|
"""install_library outside a uv-tool env must skip ``uv tool install
|
|
<pkg>`` entirely — standalone uv tools aren't importable."""
|
|
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(cmd)
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
result = reg.install_library("some-library")
|
|
assert result is True
|
|
assert len(captured) == 1
|
|
assert captured[0][:3] == ["uv", "pip", "install"]
|
|
assert sys.executable in captured[0]
|
|
|
|
def test_install_cli_tool_prefers_standalone_uv_tool_install(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""install_cli_tool outside a uv-tool env: standalone ``uv tool
|
|
install <pkg>`` is preferred so the binary survives uv sync."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(cmd)
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
fake_bin = tmp_path / "some-cli"
|
|
fake_bin.write_text("#!/bin/sh\n")
|
|
fake_bin.chmod(0o755)
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(
|
|
reg, "_uv_tool_bin", lambda cmd: fake_bin if cmd == "some-cli" else None
|
|
)
|
|
result = reg.install_cli_tool("some-cli", verify_command="some-cli")
|
|
assert result is True
|
|
assert len(captured) == 1
|
|
assert captured[0][:3] == ["uv", "tool", "install"]
|
|
assert "some-cli" in captured[0]
|
|
|
|
def test_install_cli_tool_missing_bin_triggers_pip_fallback(self, monkeypatch):
|
|
"""install_cli_tool: if the binary isn't in uv's tool bin dir after
|
|
``uv tool install`` (e.g. package has no console-script), fall
|
|
through to ``uv pip install``."""
|
|
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(cmd)
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(
|
|
reg.shutil, "which", lambda x: "/usr/bin/uv" if x == "uv" else None
|
|
)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(reg, "_uv_tool_bin", lambda cmd: None)
|
|
result = reg.install_cli_tool(
|
|
"lib-without-entrypoint", verify_command="ghost-cli"
|
|
)
|
|
assert result is True
|
|
assert len(captured) == 2
|
|
assert captured[0][:3] == ["uv", "tool", "install"]
|
|
assert "--python" in captured[1]
|
|
assert sys.executable in captured[1]
|
|
|
|
def test_install_cli_tool_bin_present_short_circuits(self, monkeypatch, tmp_path):
|
|
"""install_cli_tool: ``uv tool install`` success + binary present in
|
|
uv-tool bin dir ⇒ no pip fallback, even if a stale copy exists in
|
|
the venv on PATH."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(cmd)
|
|
return type("R", (), {"returncode": 0})()
|
|
|
|
fake_bin = tmp_path / "arxiv-mcp-server"
|
|
fake_bin.write_text("#!/bin/sh\n")
|
|
fake_bin.chmod(0o755)
|
|
|
|
def fake_which(x):
|
|
if x == "uv":
|
|
return "/usr/bin/uv"
|
|
if x == "arxiv-mcp-server":
|
|
return "/venv/bin/arxiv-mcp-server" # stale, should NOT be trusted
|
|
return None
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(reg.shutil, "which", fake_which)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
monkeypatch.setattr(
|
|
reg,
|
|
"_uv_tool_bin",
|
|
lambda cmd: fake_bin if cmd == "arxiv-mcp-server" else None,
|
|
)
|
|
result = reg.install_cli_tool(
|
|
"arxiv-mcp-server", verify_command="arxiv-mcp-server"
|
|
)
|
|
assert result is True
|
|
assert len(captured) == 1
|
|
assert captured[0][:3] == ["uv", "tool", "install"]
|
|
|
|
def test_install_library_falls_back_to_pip_when_no_uv(self, monkeypatch):
|
|
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
captured: list[list[str]] = []
|
|
|
|
def fake_run(cmd, **kwargs):
|
|
captured.append(cmd)
|
|
ns = type("R", (), {"returncode": 0})()
|
|
return ns
|
|
|
|
monkeypatch.setattr(reg, "_is_uv_tool_env", lambda: False)
|
|
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
|
|
monkeypatch.setattr(reg.subprocess, "run", fake_run)
|
|
reg.install_library("some-package")
|
|
assert len(captured) == 1
|
|
assert sys.executable in captured[0]
|
|
assert "-m" in captured[0]
|
|
assert "pip" in captured[0]
|
|
|
|
# -- _resolve_command_path --
|
|
|
|
def test_resolve_command_path_absolute_passthrough(self):
|
|
from EvoScientist.mcp.registry import _resolve_command_path
|
|
|
|
assert _resolve_command_path("/usr/bin/my-tool") == "/usr/bin/my-tool"
|
|
|
|
def test_resolve_command_path_found_in_bin_dir(self, monkeypatch, tmp_path):
|
|
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
# Create a fake executable in a temp bin dir
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
fake_exe = bin_dir / "my-mcp-server"
|
|
fake_exe.touch()
|
|
fake_exe.chmod(0o755)
|
|
|
|
# Point sys.executable to something in that bin dir
|
|
fake_python = bin_dir / "python"
|
|
fake_python.touch()
|
|
monkeypatch.setattr(sys, "executable", str(fake_python))
|
|
# Ensure shutil.which won't find it on PATH
|
|
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
|
|
|
|
result = reg._resolve_command_path("my-mcp-server")
|
|
assert result == str(fake_exe)
|
|
|
|
def test_resolve_command_path_windows_exe_suffix(self, monkeypatch, tmp_path):
|
|
import os
|
|
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
if os.name != "nt":
|
|
pytest.skip("Windows-only behaviour")
|
|
|
|
bin_dir = tmp_path / "bin"
|
|
bin_dir.mkdir()
|
|
fake_exe = bin_dir / "my-mcp-server.exe"
|
|
fake_exe.touch()
|
|
monkeypatch.setattr(sys, "executable", str(bin_dir / "python.exe"))
|
|
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
|
|
|
|
result = reg._resolve_command_path("my-mcp-server")
|
|
assert result == str(fake_exe)
|
|
|
|
def test_resolve_command_path_returns_bare_when_not_found(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
monkeypatch.setattr(reg.shutil, "which", lambda x: None)
|
|
monkeypatch.setattr(reg, "_uv_tool_bin", lambda cmd: None)
|
|
monkeypatch.setattr(sys, "executable", str(tmp_path / "bin" / "python"))
|
|
result = reg._resolve_command_path("nonexistent-tool")
|
|
assert result == "nonexistent-tool"
|
|
|
|
def test_resolve_command_path_prefers_uv_tool_over_venv_shadow(
|
|
self, monkeypatch, tmp_path
|
|
):
|
|
"""When both ``uv tool dir --bin`` and a venv's ``bin/`` contain the
|
|
command, prefer the uv-tool location so the path written to mcp.yaml
|
|
survives ``uv sync``."""
|
|
import EvoScientist.mcp.registry as reg
|
|
|
|
uv_bin_dir = tmp_path / "uv-bin"
|
|
uv_bin_dir.mkdir()
|
|
uv_copy = uv_bin_dir / "arxiv-mcp-server"
|
|
uv_copy.write_text("#!/bin/sh\n")
|
|
uv_copy.chmod(0o755)
|
|
|
|
# Stale venv copy that `uv run` would surface first via PATH.
|
|
venv_copy = tmp_path / "venv-bin" / "arxiv-mcp-server"
|
|
venv_copy.parent.mkdir()
|
|
venv_copy.write_text("#!/bin/sh\n")
|
|
venv_copy.chmod(0o755)
|
|
|
|
monkeypatch.setattr(reg, "_uv_tool_bin_dir", lambda: uv_bin_dir)
|
|
monkeypatch.setattr(reg.shutil, "which", lambda x: str(venv_copy))
|
|
result = reg._resolve_command_path("arxiv-mcp-server")
|
|
assert result == str(uv_copy)
|
|
|
|
|
|
# ---- _load_tools progress callback ----
|
|
|
|
|
|
class TestLoadToolsProgressCallback:
|
|
"""Verify per-server ``on_progress`` events fire in the expected order."""
|
|
|
|
@staticmethod
|
|
def _patch_client(monkeypatch, behavior):
|
|
"""Install a fake MultiServerMCPClient whose ``get_tools`` delegates
|
|
to *behavior* — a ``dict[server_name, list | Exception]``. Each
|
|
success value is returned; Exception values are raised.
|
|
"""
|
|
|
|
class _FakeClient:
|
|
def __init__(self, connections):
|
|
self.connections = connections
|
|
|
|
async def get_tools(self, server_name):
|
|
outcome = behavior[server_name]
|
|
if isinstance(outcome, Exception):
|
|
raise outcome
|
|
return outcome
|
|
|
|
import langchain_mcp_adapters.client as lc_client
|
|
|
|
monkeypatch.setattr(lc_client, "MultiServerMCPClient", _FakeClient)
|
|
|
|
async def test_success_emits_start_then_success_with_tool_count(self, monkeypatch):
|
|
from EvoScientist.mcp.client import _load_tools
|
|
|
|
events: list[tuple[str, str, str]] = []
|
|
self._patch_client(
|
|
monkeypatch,
|
|
{"srv": ["tool1", "tool2", "tool3"]},
|
|
)
|
|
|
|
config = {"srv": {"transport": "stdio", "command": "demo"}}
|
|
|
|
def record(event, name, detail):
|
|
events.append((event, name, detail))
|
|
|
|
await _load_tools(config, on_progress=record)
|
|
|
|
assert events == [
|
|
("start", "srv", ""),
|
|
("success", "srv", "3"),
|
|
]
|
|
|
|
async def test_failure_emits_start_then_error_with_detail(self, monkeypatch):
|
|
from EvoScientist.mcp.client import _load_tools
|
|
|
|
events: list[tuple[str, str, str]] = []
|
|
self._patch_client(monkeypatch, {"srv": RuntimeError("boom")})
|
|
|
|
config = {"srv": {"transport": "stdio", "command": "demo"}}
|
|
|
|
def record(event, name, detail):
|
|
events.append((event, name, detail))
|
|
|
|
await _load_tools(config, on_progress=record)
|
|
|
|
assert events == [
|
|
("start", "srv", ""),
|
|
("error", "srv", "boom"),
|
|
]
|
|
|
|
async def test_mixed_fleet_reports_each_server_independently(self, monkeypatch):
|
|
from EvoScientist.mcp.client import _load_tools
|
|
|
|
events: list[tuple[str, str, str]] = []
|
|
self._patch_client(
|
|
monkeypatch,
|
|
{
|
|
"ok_srv": ["a"],
|
|
"bad_srv": ConnectionError("refused"),
|
|
},
|
|
)
|
|
|
|
config = {
|
|
"ok_srv": {"transport": "stdio", "command": "demo"},
|
|
"bad_srv": {"transport": "stdio", "command": "demo"},
|
|
}
|
|
|
|
def record(event, name, detail):
|
|
events.append((event, name, detail))
|
|
|
|
await _load_tools(config, on_progress=record)
|
|
|
|
by_server = {}
|
|
for ev, name, detail in events:
|
|
by_server.setdefault(name, []).append((ev, detail))
|
|
assert by_server["ok_srv"] == [("start", ""), ("success", "1")]
|
|
assert by_server["bad_srv"] == [("start", ""), ("error", "refused")]
|
|
|
|
async def test_callback_errors_do_not_break_the_load(self, monkeypatch):
|
|
from EvoScientist.mcp.client import _load_tools
|
|
|
|
self._patch_client(monkeypatch, {"srv": ["tool1"]})
|
|
|
|
config = {"srv": {"transport": "stdio", "command": "demo"}}
|
|
|
|
def bad_callback(event, name, detail):
|
|
raise RuntimeError("callback bug")
|
|
|
|
result = await _load_tools(config, on_progress=bad_callback)
|
|
assert result == {"srv": ["tool1"]}
|
|
|
|
async def test_semaphore_caps_concurrent_connections(self, monkeypatch):
|
|
"""Many configured servers must not all spawn at once."""
|
|
import asyncio
|
|
|
|
from EvoScientist.mcp import client as mcp_client
|
|
|
|
inflight = {"count": 0, "peak": 0}
|
|
|
|
class _TrackingClient:
|
|
def __init__(self, connections):
|
|
self.connections = connections
|
|
|
|
async def get_tools(self, server_name):
|
|
inflight["count"] += 1
|
|
inflight["peak"] = max(inflight["peak"], inflight["count"])
|
|
# Yield so sibling tasks can try to enter concurrently.
|
|
await asyncio.sleep(0)
|
|
inflight["count"] -= 1
|
|
return [f"tool-of-{server_name}"]
|
|
|
|
import langchain_mcp_adapters.client as lc_client
|
|
|
|
monkeypatch.setattr(lc_client, "MultiServerMCPClient", _TrackingClient)
|
|
# Force a small cap so the test doesn't depend on the default.
|
|
monkeypatch.setattr(mcp_client, "_MAX_CONCURRENT_CONNECTIONS", 3)
|
|
|
|
config = {
|
|
f"srv{i}": {"transport": "stdio", "command": "demo"} for i in range(10)
|
|
}
|
|
await mcp_client._load_tools(config)
|
|
|
|
assert inflight["peak"] <= 3
|
|
assert inflight["peak"] > 1 # sanity: we *are* parallelizing
|