Some checks failed
Release / semantic-release (push) Successful in 59s
tests / Unit tests (Linux, Python 3.11) (push) Successful in 13m45s
Release / build-linux (push) Failing after 7m47s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Transform isair/jarvis into a Discord-controlled voice assistant running on the Ubuntu VNC desktop, keeping the mature ~39k-line Python brain intact. - bot/ (Node + bun, discord.js): /자비스 slash commands (ephemeral), voice channel join + voice receive/playback, pluggable VNC screen broadcast (selfbot live / noVNC / screenshot) - bridge/ (Python, Flask): wraps jarvis STT + run_reply_engine + Piper TTS behind a thin localhost HTTP API - .env.example, scripts/ (start_bridge/start_bot/dev), README rewrite, docs/language-comparison.md and docs/vnc-xfce-setup.md Language decision: hybrid (Python brain + Node/bun Discord layer) because Discord blocks bot video; native screen broadcast only works via a Node selfbot library.
663 lines
23 KiB
Python
663 lines
23 KiB
Python
import asyncio
|
|
import os
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture
|
|
def shutdown_persistent_runtime():
|
|
"""Tear down the persistent MCP runtime singleton between tests."""
|
|
yield
|
|
try:
|
|
from jarvis.tools.external.mcp_runtime import shutdown_runtime
|
|
shutdown_runtime()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def _make_tracked_doubles(call_count, enter_count, exit_count, *, fail_on_call=None,
|
|
tools_payload=None):
|
|
"""Build patchable doubles for ``stdio_client`` and ``ClientSession``.
|
|
|
|
``fail_on_call`` may be a list whose values trigger ``call_tool`` to
|
|
raise ``RuntimeError(value)`` on the matching invocation index. A
|
|
``None`` entry means succeed normally.
|
|
"""
|
|
fail_on_call = list(fail_on_call or [])
|
|
|
|
class TrackedConn:
|
|
async def __aenter__(self_):
|
|
enter_count["n"] += 1
|
|
return object(), object()
|
|
|
|
async def __aexit__(self_, *a):
|
|
exit_count["n"] += 1
|
|
return False
|
|
|
|
class TrackedSession:
|
|
def __init__(self_, read, write):
|
|
pass
|
|
|
|
async def __aenter__(self_):
|
|
class _S:
|
|
async def initialize(_self):
|
|
return None
|
|
|
|
async def call_tool(_self, name, arguments):
|
|
idx = call_count["n"]
|
|
call_count["n"] += 1
|
|
if idx < len(fail_on_call) and fail_on_call[idx] is not None:
|
|
raise RuntimeError(fail_on_call[idx])
|
|
return type(
|
|
"R",
|
|
(),
|
|
{"content": f"called:{name}:{arguments}", "isError": False, "meta": None},
|
|
)()
|
|
|
|
async def list_tools(_self):
|
|
payload = tools_payload or []
|
|
fake_tools = [
|
|
type("T", (), {"name": n, "description": d, "inputSchema": s})()
|
|
for (n, d, s) in payload
|
|
]
|
|
return type("LR", (), {"tools": fake_tools})()
|
|
|
|
return _S()
|
|
|
|
async def __aexit__(self_, *a):
|
|
return False
|
|
|
|
return TrackedConn, TrackedSession
|
|
|
|
|
|
def _patch_mcp_doubles(monkeypatch, TrackedConn, TrackedSession):
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._resolve_command", lambda c: c
|
|
)
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client.stdio_client",
|
|
lambda params, **kw: TrackedConn(),
|
|
)
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client.ClientSession", TrackedSession
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_invoke_tool_keeps_mcp_session_alive_across_calls(monkeypatch, shutdown_persistent_runtime):
|
|
"""Stateful MCP servers (e.g. chrome-devtools-mcp) launch child processes
|
|
such as a browser that die when the server's stdio session is torn down.
|
|
Two consecutive invocations on the same server must share a single
|
|
long-lived stdio session, not spawn the server subprocess twice.
|
|
"""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
enter_count = {"n": 0}
|
|
exit_count = {"n": 0}
|
|
call_count = {"n": 0}
|
|
|
|
TrackedConn, TrackedSession = _make_tracked_doubles(
|
|
call_count, enter_count, exit_count
|
|
)
|
|
_patch_mcp_doubles(monkeypatch, TrackedConn, TrackedSession)
|
|
|
|
mcps = {"persist": {"transport": "stdio", "command": "/bin/true", "args": []}}
|
|
client = MCPClient(mcps)
|
|
|
|
r1 = client.invoke_tool("persist", "alpha", {"x": 1})
|
|
r2 = client.invoke_tool("persist", "beta", {"y": 2})
|
|
|
|
assert call_count["n"] == 2
|
|
assert enter_count["n"] == 1, (
|
|
f"stdio connection must be opened once for stateful MCP servers, "
|
|
f"was opened {enter_count['n']} times"
|
|
)
|
|
assert exit_count["n"] == 0, (
|
|
"stdio connection must remain open across invocations"
|
|
)
|
|
# Sanity: results pass through unchanged
|
|
assert r1["isError"] is False
|
|
assert r2["isError"] is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_invoke_tool_retries_on_transient_session_loss(
|
|
monkeypatch, shutdown_persistent_runtime
|
|
):
|
|
"""If a worker raises ``_WorkerDeadError`` (its stdio session ended
|
|
mid-call), the runtime must drop it, spawn a fresh one and retry
|
|
once. Observable behaviour: the second invocation succeeds even
|
|
though the first underlying worker call failed with the sentinel.
|
|
"""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
from jarvis.tools.external import mcp_runtime as _runtime_mod
|
|
|
|
enter_count = {"n": 0}
|
|
exit_count = {"n": 0}
|
|
call_count = {"n": 0}
|
|
|
|
TrackedConn, TrackedSession = _make_tracked_doubles(
|
|
call_count, enter_count, exit_count
|
|
)
|
|
_patch_mcp_doubles(monkeypatch, TrackedConn, TrackedSession)
|
|
|
|
real_invoke = _runtime_mod._ServerWorker.invoke
|
|
invoke_calls = {"n": 0}
|
|
|
|
def flaky_invoke(self, tool_name, arguments, timeout):
|
|
invoke_calls["n"] += 1
|
|
if invoke_calls["n"] == 1:
|
|
# Simulate the worker discovering its session is dead.
|
|
raise _runtime_mod._WorkerDeadError("simulated session loss")
|
|
return real_invoke(self, tool_name, arguments, timeout)
|
|
|
|
monkeypatch.setattr(_runtime_mod._ServerWorker, "invoke", flaky_invoke)
|
|
|
|
client = MCPClient(
|
|
{"flaky": {"transport": "stdio", "command": "/bin/true", "args": []}}
|
|
)
|
|
|
|
res = client.invoke_tool("flaky", "alpha", {"x": 1})
|
|
|
|
assert res["isError"] is False
|
|
assert invoke_calls["n"] == 2, (
|
|
"runtime should retry exactly once after _WorkerDeadError"
|
|
)
|
|
assert enter_count["n"] == 2, (
|
|
"the retry must spawn a fresh stdio connection (new worker)"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_get_worker_replaces_on_config_change(
|
|
monkeypatch, shutdown_persistent_runtime
|
|
):
|
|
"""Changing a server's config (e.g. updated args) must cause the
|
|
runtime to replace the existing worker with a fresh one so the new
|
|
subprocess actually receives the new arguments.
|
|
"""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
enter_count = {"n": 0}
|
|
exit_count = {"n": 0}
|
|
call_count = {"n": 0}
|
|
|
|
TrackedConn, TrackedSession = _make_tracked_doubles(
|
|
call_count, enter_count, exit_count
|
|
)
|
|
_patch_mcp_doubles(monkeypatch, TrackedConn, TrackedSession)
|
|
|
|
cfg_v1 = {"transport": "stdio", "command": "/bin/true", "args": []}
|
|
cfg_v2 = {"transport": "stdio", "command": "/bin/true", "args": ["--flag"]}
|
|
|
|
client_v1 = MCPClient({"swap": cfg_v1})
|
|
client_v1.invoke_tool("swap", "alpha", {})
|
|
assert enter_count["n"] == 1
|
|
|
|
client_v2 = MCPClient({"swap": cfg_v2})
|
|
client_v2.invoke_tool("swap", "alpha", {})
|
|
assert enter_count["n"] == 2, "config change must spawn a new stdio session"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_worker_startup_failure_propagates(monkeypatch, shutdown_persistent_runtime):
|
|
"""If session initialisation fails (e.g. subprocess cannot start),
|
|
the failure must propagate to the caller rather than hang.
|
|
"""
|
|
from jarvis.tools.external.mcp_client import (
|
|
MCPClient,
|
|
MCPServerSessionError,
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._resolve_command", lambda c: c
|
|
)
|
|
|
|
def _broken_stdio_client(params, **kw):
|
|
raise FileNotFoundError("simulated subprocess spawn failure")
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client.stdio_client", _broken_stdio_client
|
|
)
|
|
|
|
client = MCPClient(
|
|
{"broken": {"transport": "stdio", "command": "/bin/true", "args": []}}
|
|
)
|
|
|
|
with pytest.raises((FileNotFoundError, MCPServerSessionError, RuntimeError)):
|
|
client.invoke_tool("broken", "alpha", {})
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_runtime_isolates_workers_per_server(
|
|
monkeypatch, shutdown_persistent_runtime
|
|
):
|
|
"""Two distinct servers must each have their own stdio session;
|
|
invoking one must not interfere with the other.
|
|
"""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
enter_count = {"n": 0}
|
|
exit_count = {"n": 0}
|
|
call_count = {"n": 0}
|
|
|
|
TrackedConn, TrackedSession = _make_tracked_doubles(
|
|
call_count, enter_count, exit_count
|
|
)
|
|
_patch_mcp_doubles(monkeypatch, TrackedConn, TrackedSession)
|
|
|
|
mcps = {
|
|
"alpha": {"transport": "stdio", "command": "/bin/true", "args": []},
|
|
"beta": {"transport": "stdio", "command": "/bin/true", "args": []},
|
|
}
|
|
client = MCPClient(mcps)
|
|
|
|
client.invoke_tool("alpha", "x", {})
|
|
client.invoke_tool("beta", "y", {})
|
|
client.invoke_tool("alpha", "x", {})
|
|
|
|
assert call_count["n"] == 3
|
|
assert enter_count["n"] == 2, (
|
|
"each server should open exactly one stdio connection regardless of "
|
|
"the order calls arrive in"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_list_tools_uses_persistent_session(
|
|
monkeypatch, shutdown_persistent_runtime
|
|
):
|
|
"""Discovery and the first ``invoke_tool`` should share a single
|
|
stdio session — listing then invoking must not spawn the server
|
|
twice.
|
|
"""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
enter_count = {"n": 0}
|
|
exit_count = {"n": 0}
|
|
call_count = {"n": 0}
|
|
|
|
TrackedConn, TrackedSession = _make_tracked_doubles(
|
|
call_count,
|
|
enter_count,
|
|
exit_count,
|
|
tools_payload=[
|
|
("alpha", "first tool", {"type": "object"}),
|
|
("beta", "second tool", {"type": "object"}),
|
|
],
|
|
)
|
|
_patch_mcp_doubles(monkeypatch, TrackedConn, TrackedSession)
|
|
|
|
client = MCPClient(
|
|
{"shared": {"transport": "stdio", "command": "/bin/true", "args": []}}
|
|
)
|
|
|
|
listed = client.list_tools("shared")
|
|
assert {t["name"] for t in listed} == {"alpha", "beta"}
|
|
|
|
client.invoke_tool("shared", "alpha", {})
|
|
|
|
assert enter_count["n"] == 1, (
|
|
"list_tools and invoke_tool should reuse the same stdio session"
|
|
)
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_absolute_path_command_skips_which(monkeypatch, tmp_path):
|
|
"""Absolute paths to executables should use os.path.isfile, not shutil.which."""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
# Create a fake executable file at an absolute path
|
|
fake_exe = tmp_path / "node.exe"
|
|
fake_exe.write_text("fake")
|
|
fake_exe.chmod(0o755)
|
|
|
|
mcps = {
|
|
"test": {
|
|
"command": str(fake_exe),
|
|
"args": ["server.js"],
|
|
}
|
|
}
|
|
|
|
client = MCPClient(mcps)
|
|
|
|
# shutil.which should NOT be called for absolute paths
|
|
which_called = False
|
|
original_which = __import__("shutil").which
|
|
|
|
def tracking_which(cmd):
|
|
nonlocal which_called
|
|
which_called = True
|
|
return original_which(cmd)
|
|
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", tracking_which)
|
|
|
|
# We need to mock stdio_client to avoid actually connecting
|
|
class FakeCM:
|
|
async def __aenter__(self):
|
|
return object(), object()
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
class FakeSession:
|
|
def __init__(self, r, w):
|
|
pass
|
|
async def __aenter__(self):
|
|
s = type("S", (), {"initialize": lambda self: asyncio.sleep(0), "list_tools": lambda self: asyncio.sleep(0)})()
|
|
return s
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.stdio_client", lambda params, **kw: FakeCM())
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.ClientSession", FakeSession)
|
|
|
|
try:
|
|
asyncio.run(client.list_tools_async("test"))
|
|
except Exception:
|
|
pass # We only care that the path validation passed
|
|
|
|
assert not which_called, "shutil.which should not be called for absolute paths"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_absolute_path_not_found_gives_clear_error(tmp_path):
|
|
"""Non-existent absolute path should raise FileNotFoundError with clear message."""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
fake_path = str(tmp_path / "nonexistent" / "node.exe")
|
|
mcps = {
|
|
"test": {
|
|
"command": fake_path,
|
|
"args": [],
|
|
}
|
|
}
|
|
|
|
client = MCPClient(mcps)
|
|
|
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
|
client._connect_stdio(mcps["test"])
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_mcp_client_list_and_invoke(monkeypatch):
|
|
# Import the real client and patch its external dependencies
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
# Prepare fake server config (command won't actually run because we mock stdio_client)
|
|
mcps = {
|
|
"fake": {
|
|
"transport": "stdio",
|
|
"command": "fake-cmd",
|
|
"args": ["--flag"],
|
|
"env": {},
|
|
}
|
|
}
|
|
|
|
client = MCPClient(mcps)
|
|
|
|
# Create fake tool objects that the MCP client expects
|
|
class FakeTool:
|
|
def __init__(self, name, description, inputSchema):
|
|
self.name = name
|
|
self.description = description
|
|
self.inputSchema = inputSchema
|
|
|
|
# Create fake session object implementing the observable API used by MCPClient
|
|
class FakeSession:
|
|
async def initialize(self):
|
|
return None
|
|
|
|
async def list_tools(self):
|
|
return [
|
|
FakeTool("alpha", "desc", {"type": "object"}),
|
|
FakeTool("beta", "desc", {"type": "object"}),
|
|
]
|
|
|
|
async def call_tool(self, name, arguments):
|
|
# Create a response object with attributes that the MCP client expects
|
|
class FakeResponse:
|
|
def __init__(self):
|
|
self.content = f"called:{name}:{arguments}"
|
|
self.isError = False
|
|
self.meta = None
|
|
return FakeResponse()
|
|
|
|
# Mock stdio_client context manager to yield (read, write)
|
|
class FakeCM:
|
|
def __init__(self, session):
|
|
self._session = session
|
|
|
|
async def __aenter__(self):
|
|
# Return reader, writer placeholders; session is consumed by ClientSession wrapper
|
|
return object(), object()
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
# Mock ClientSession to wrap our FakeSession directly
|
|
class FakeClientSession:
|
|
def __init__(self, read, write):
|
|
self._session = FakeSession()
|
|
|
|
async def __aenter__(self):
|
|
await self._session.initialize()
|
|
return self._session
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
# Patch public imports inside the module (observable seams)
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.stdio_client", lambda params, **kw: FakeCM(FakeSession()))
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.ClientSession", FakeClientSession)
|
|
# Avoid PATH check failing in _connect_stdio
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", lambda cmd: cmd)
|
|
|
|
tools = asyncio.run(client.list_tools_async("fake"))
|
|
assert isinstance(tools, list) and {t["name"] for t in tools} == {"alpha", "beta"}
|
|
|
|
res = asyncio.run(client.invoke_tool_async("fake", "alpha", {"x": 1}))
|
|
assert res["content"] == "called:alpha:{'x': 1}"
|
|
assert res.get("isError") is False
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestResolveCommand:
|
|
"""Tests for _resolve_command PATH fallback logic."""
|
|
|
|
def test_finds_command_on_path(self, monkeypatch):
|
|
"""When shutil.which succeeds, returns that path."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", lambda cmd: "/usr/bin/npx")
|
|
assert _resolve_command("npx") == "/usr/bin/npx"
|
|
|
|
def test_finds_command_in_extra_dirs(self, monkeypatch, tmp_path):
|
|
"""When shutil.which fails, probes extra directories."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", lambda cmd: None)
|
|
|
|
# Create a fake executable in a temp dir
|
|
fake_npx = tmp_path / "npx"
|
|
fake_npx.write_text("#!/bin/sh")
|
|
fake_npx.chmod(0o755)
|
|
|
|
# Inject our temp dir into the extra paths list
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._EXTRA_PATH_DIRS",
|
|
[str(tmp_path)],
|
|
)
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._EXTRA_PATH_GLOBS", [])
|
|
# Skip login shell fallback
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._sys.platform", "win32")
|
|
|
|
assert _resolve_command("npx") == str(fake_npx)
|
|
|
|
def test_falls_back_to_login_shell(self, monkeypatch):
|
|
"""When extra dirs fail, tries bash -lc which."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
import subprocess
|
|
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", lambda cmd: None)
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._EXTRA_PATH_DIRS", [])
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._EXTRA_PATH_GLOBS", [])
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._sys.platform", "darwin")
|
|
|
|
mock_result = type("R", (), {"returncode": 0, "stdout": "/opt/homebrew/bin/npx\n"})()
|
|
monkeypatch.setattr(
|
|
"subprocess.run",
|
|
lambda *a, **kw: mock_result,
|
|
)
|
|
assert _resolve_command("npx") == "/opt/homebrew/bin/npx"
|
|
|
|
def test_finds_command_via_nvm_glob(self, monkeypatch, tmp_path):
|
|
"""When shutil.which and static dirs fail, probes nvm-style version dirs."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", lambda cmd: None)
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._EXTRA_PATH_DIRS", [])
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._sys.platform", "win32")
|
|
|
|
# Create nvm-style version dirs with npx
|
|
v18 = tmp_path / "v18.0.0" / "bin"
|
|
v22 = tmp_path / "v22.22.0" / "bin"
|
|
v18.mkdir(parents=True)
|
|
v22.mkdir(parents=True)
|
|
(v18 / "npx").write_text("#!/bin/sh")
|
|
(v18 / "npx").chmod(0o755)
|
|
(v22 / "npx").write_text("#!/bin/sh")
|
|
(v22 / "npx").chmod(0o755)
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._EXTRA_PATH_GLOBS",
|
|
[str(tmp_path / "*/bin")],
|
|
)
|
|
# Should prefer the highest version (v22) due to reverse sort
|
|
result = _resolve_command("npx")
|
|
assert "v22.22.0" in result
|
|
|
|
def test_raises_when_not_found_anywhere(self, monkeypatch):
|
|
"""When all resolution methods fail, raises FileNotFoundError."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client.shutil.which", lambda cmd: None)
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._EXTRA_PATH_DIRS", [])
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._EXTRA_PATH_GLOBS", [])
|
|
monkeypatch.setattr("jarvis.tools.external.mcp_client._sys.platform", "win32")
|
|
|
|
with pytest.raises(FileNotFoundError, match="not found on PATH"):
|
|
_resolve_command("nonexistent-command")
|
|
|
|
def test_absolute_path_verified_directly(self, tmp_path):
|
|
"""Absolute paths bypass PATH lookup entirely."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
|
|
fake = tmp_path / "my-server"
|
|
fake.write_text("#!/bin/sh")
|
|
fake.chmod(0o755)
|
|
assert _resolve_command(str(fake)) == str(fake)
|
|
|
|
def test_absolute_path_missing_raises(self, tmp_path):
|
|
"""Non-existent absolute path raises FileNotFoundError."""
|
|
from jarvis.tools.external.mcp_client import _resolve_command
|
|
|
|
with pytest.raises(FileNotFoundError, match="does not exist"):
|
|
_resolve_command(str(tmp_path / "nope"))
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestConnectStdioPathInjection:
|
|
"""Tests that _connect_stdio injects the resolved command's dir into PATH."""
|
|
|
|
def test_command_dir_added_to_env_path(self, monkeypatch, tmp_path):
|
|
"""The directory of the resolved command should be prepended to env PATH."""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
fake_npx = tmp_path / "npx"
|
|
fake_npx.write_text("#!/bin/sh")
|
|
fake_npx.chmod(0o755)
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._resolve_command",
|
|
lambda cmd: str(fake_npx),
|
|
)
|
|
|
|
captured_params = {}
|
|
|
|
def fake_stdio_client(params, **kw):
|
|
captured_params["env"] = params.env
|
|
captured_params["command"] = params.command
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client.stdio_client",
|
|
fake_stdio_client,
|
|
)
|
|
|
|
client = MCPClient({"test": {"command": "npx", "args": ["-y", "server"]}})
|
|
client._connect_stdio(client.server_configs["test"])
|
|
|
|
env = captured_params["env"]
|
|
assert env is not None
|
|
path_dirs = env["PATH"].split(os.pathsep)
|
|
assert str(tmp_path) == path_dirs[0], "Command dir should be first in PATH"
|
|
# Full parent environment should also be present
|
|
assert "HOME" in env or "USER" in env, "Parent env vars should be inherited"
|
|
|
|
def test_user_env_preserved_alongside_path(self, monkeypatch, tmp_path):
|
|
"""User-supplied env vars should be preserved when PATH is injected."""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
fake_npx = tmp_path / "npx"
|
|
fake_npx.write_text("#!/bin/sh")
|
|
fake_npx.chmod(0o755)
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._resolve_command",
|
|
lambda cmd: str(fake_npx),
|
|
)
|
|
|
|
captured_params = {}
|
|
|
|
def fake_stdio_client(params, **kw):
|
|
captured_params["env"] = params.env
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client.stdio_client",
|
|
fake_stdio_client,
|
|
)
|
|
|
|
cfg = {"command": "npx", "args": [], "env": {"MY_TOKEN": "secret"}}
|
|
client = MCPClient({"test": cfg})
|
|
client._connect_stdio(client.server_configs["test"])
|
|
|
|
env = captured_params["env"]
|
|
assert env["MY_TOKEN"] == "secret"
|
|
assert str(tmp_path) in env["PATH"]
|
|
|
|
def test_no_env_override_when_command_already_on_path(self, monkeypatch):
|
|
"""When command dir is already on PATH and no user env, env should be None."""
|
|
from jarvis.tools.external.mcp_client import MCPClient
|
|
|
|
# Resolve to a path that's already on the system PATH
|
|
system_path_dir = os.environ.get("PATH", "").split(os.pathsep)[0]
|
|
fake_cmd = os.path.join(system_path_dir, "fake-cmd")
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client._resolve_command",
|
|
lambda cmd: fake_cmd,
|
|
)
|
|
|
|
captured_params = {}
|
|
|
|
def fake_stdio_client(params, **kw):
|
|
captured_params["env"] = params.env
|
|
return None
|
|
|
|
monkeypatch.setattr(
|
|
"jarvis.tools.external.mcp_client.stdio_client",
|
|
fake_stdio_client,
|
|
)
|
|
|
|
client = MCPClient({"test": {"command": "fake-cmd", "args": []}})
|
|
client._connect_stdio(client.server_configs["test"])
|
|
|
|
assert captured_params["env"] is None, "No env override needed when dir already on PATH"
|
|
|