Add Discord-native hybrid front-end for Jarvis (bot + bridge)
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.
This commit is contained in:
javis-bot
2026-06-09 14:51:05 +09:00
parent a5bf8d1826
commit c4abf63f38
308 changed files with 94135 additions and 1 deletions

662
tests/test_mcp_client.py Normal file
View File

@@ -0,0 +1,662 @@
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"