mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 14:07:55 +00:00
CI's lint job ran ruff check but never ruff format --check, letting format drift land silently (79 files had drifted from the pinned ruff 0.15.1). Add the ruff format --check step to ci.yml, reformat the 79 drifted files with the pinned ruff (mechanical only — verified AST-identical to before across all files, no logic changes), and add a Makefile whose test target mirrors the actual CI lane. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
229 lines
8.3 KiB
Python
229 lines
8.3 KiB
Python
"""Regression tests for openjarvis.mcp.loader.load_mcp_tools_from_config.
|
|
|
|
Closes the gap that #461 surfaced — MCP tools were silently dropped on
|
|
`jarvis ask` and `jarvis serve` because neither path read
|
|
`config.tools.mcp.servers`. The loader is the shared helper they now
|
|
both call.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
|
|
def _make_mcp_cfg(*, enabled=True, servers):
|
|
"""Build a duck-typed MCPConfig with enabled flag + servers JSON."""
|
|
cfg = MagicMock()
|
|
cfg.enabled = enabled
|
|
cfg.servers = json.dumps(servers) if not isinstance(servers, str) else servers
|
|
return cfg
|
|
|
|
|
|
def _fake_tool(name):
|
|
t = MagicMock()
|
|
t.spec.name = name
|
|
return t
|
|
|
|
|
|
@pytest.fixture
|
|
def _mock_mcp_stack():
|
|
"""Patch MCPClient / transports / MCPToolProvider so no real I/O happens."""
|
|
with (
|
|
patch("openjarvis.mcp.client.MCPClient") as MockClient,
|
|
patch("openjarvis.mcp.transport.StreamableHTTPTransport") as MockHttp,
|
|
patch("openjarvis.mcp.transport.StdioTransport") as MockStdio,
|
|
patch("openjarvis.tools.mcp_adapter.MCPToolProvider") as MockProvider,
|
|
):
|
|
# Default: any provider discovers no tools (per-test overrides as needed)
|
|
MockProvider.return_value.discover.return_value = []
|
|
MockClient.return_value.initialize.return_value = None
|
|
yield {
|
|
"client": MockClient,
|
|
"http": MockHttp,
|
|
"stdio": MockStdio,
|
|
"provider": MockProvider,
|
|
}
|
|
|
|
|
|
class TestLoaderEarlyReturns:
|
|
def test_disabled_returns_empty(self, _mock_mcp_stack):
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = _make_mcp_cfg(enabled=False, servers=[{"url": "http://x"}])
|
|
tools, clients = load_mcp_tools_from_config(cfg)
|
|
assert tools == []
|
|
assert clients == []
|
|
# Critical: when disabled, we don't even instantiate transports
|
|
_mock_mcp_stack["http"].assert_not_called()
|
|
|
|
def test_empty_servers_returns_empty(self, _mock_mcp_stack):
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = _make_mcp_cfg(enabled=True, servers=[])
|
|
tools, clients = load_mcp_tools_from_config(cfg)
|
|
assert tools == []
|
|
assert clients == []
|
|
|
|
def test_malformed_json_logs_warning_returns_empty(self, _mock_mcp_stack, caplog):
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = MagicMock()
|
|
cfg.enabled = True
|
|
cfg.servers = "{not valid json"
|
|
with caplog.at_level("WARNING"):
|
|
tools, clients = load_mcp_tools_from_config(cfg)
|
|
assert tools == []
|
|
assert clients == []
|
|
assert any("parse MCP servers config" in r.message for r in caplog.records)
|
|
|
|
|
|
class TestLoaderTokenPlumbing:
|
|
def test_token_passed_to_streamable_http(self, _mock_mcp_stack):
|
|
"""Regression for #461 — token in cfg → StreamableHTTPTransport(token=...)."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[
|
|
{
|
|
"name": "home-assistant",
|
|
"url": "http://homeassistant.local:8123/mcp",
|
|
"token": "ha-llat-secret",
|
|
}
|
|
],
|
|
)
|
|
load_mcp_tools_from_config(cfg)
|
|
_mock_mcp_stack["http"].assert_called_once_with(
|
|
url="http://homeassistant.local:8123/mcp",
|
|
token="ha-llat-secret",
|
|
)
|
|
|
|
def test_no_token_passes_none(self, _mock_mcp_stack):
|
|
"""Missing token in cfg → token=None (not 'undefined' or KeyError)."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[{"name": "open", "url": "http://localhost:9583/mcp"}],
|
|
)
|
|
load_mcp_tools_from_config(cfg)
|
|
_mock_mcp_stack["http"].assert_called_once_with(
|
|
url="http://localhost:9583/mcp",
|
|
token=None,
|
|
)
|
|
|
|
def test_stdio_server_does_not_get_token_kwarg(self, _mock_mcp_stack):
|
|
"""StdioTransport doesn't take a token (unix-socket auth is OOB)."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[
|
|
{"name": "local-mcp", "command": "mcp-server-foo", "args": ["--flag"]}
|
|
],
|
|
)
|
|
load_mcp_tools_from_config(cfg)
|
|
_mock_mcp_stack["stdio"].assert_called_once_with(
|
|
command=["mcp-server-foo", "--flag"]
|
|
)
|
|
|
|
|
|
class TestLoaderFiltering:
|
|
def test_allowed_names_filter_applied(self, _mock_mcp_stack):
|
|
"""allowed_names limits the returned tools to that set."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
_mock_mcp_stack["provider"].return_value.discover.return_value = [
|
|
_fake_tool("alpha"),
|
|
_fake_tool("beta"),
|
|
_fake_tool("gamma"),
|
|
]
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[{"name": "x", "url": "http://x"}],
|
|
)
|
|
tools, _ = load_mcp_tools_from_config(cfg, allowed_names={"beta"})
|
|
assert [t.spec.name for t in tools] == ["beta"]
|
|
|
|
def test_include_tools_per_server(self, _mock_mcp_stack):
|
|
"""Per-server include_tools restricts to just those names."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
_mock_mcp_stack["provider"].return_value.discover.return_value = [
|
|
_fake_tool("alpha"),
|
|
_fake_tool("beta"),
|
|
]
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[{"name": "x", "url": "http://x", "include_tools": ["alpha"]}],
|
|
)
|
|
tools, _ = load_mcp_tools_from_config(cfg)
|
|
assert [t.spec.name for t in tools] == ["alpha"]
|
|
|
|
def test_exclude_tools_per_server(self, _mock_mcp_stack):
|
|
"""Per-server exclude_tools drops the named tools."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
_mock_mcp_stack["provider"].return_value.discover.return_value = [
|
|
_fake_tool("alpha"),
|
|
_fake_tool("beta"),
|
|
]
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[{"name": "x", "url": "http://x", "exclude_tools": ["alpha"]}],
|
|
)
|
|
tools, _ = load_mcp_tools_from_config(cfg)
|
|
assert [t.spec.name for t in tools] == ["beta"]
|
|
|
|
|
|
class TestLoaderClientLifetime:
|
|
def test_returns_live_clients_for_caller_to_hold(self, _mock_mcp_stack):
|
|
"""Critical lifetime contract — caller must hold `clients` so
|
|
the transports' httpx sessions stay open. (#461 adversarial
|
|
review caught this.) The list returned MUST contain a client
|
|
per successfully-initialized server."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[
|
|
{"name": "s1", "url": "http://x1"},
|
|
{"name": "s2", "url": "http://x2"},
|
|
],
|
|
)
|
|
_, clients = load_mcp_tools_from_config(cfg)
|
|
assert len(clients) == 2
|
|
|
|
|
|
class TestLoaderFailureIsolation:
|
|
def test_one_server_failure_doesnt_abort_others(self, _mock_mcp_stack, caplog):
|
|
"""When one server's initialize() raises, the loader logs and
|
|
moves on — the remaining servers still contribute tools."""
|
|
from openjarvis.mcp.loader import load_mcp_tools_from_config
|
|
|
|
# First initialize() raises, second succeeds
|
|
bad_client = MagicMock()
|
|
bad_client.initialize.side_effect = RuntimeError("can't reach server")
|
|
good_client = MagicMock()
|
|
good_client.initialize.return_value = None
|
|
_mock_mcp_stack["client"].side_effect = [bad_client, good_client]
|
|
_mock_mcp_stack["provider"].return_value.discover.return_value = [
|
|
_fake_tool("survivor")
|
|
]
|
|
|
|
cfg = _make_mcp_cfg(
|
|
enabled=True,
|
|
servers=[
|
|
{"name": "broken", "url": "http://broken"},
|
|
{"name": "working", "url": "http://working"},
|
|
],
|
|
)
|
|
with caplog.at_level("WARNING"):
|
|
tools, clients = load_mcp_tools_from_config(cfg)
|
|
assert [t.spec.name for t in tools] == ["survivor"]
|
|
assert len(clients) == 1
|
|
assert any("broken" in r.message for r in caplog.records)
|