Files
OpenJarvis/tests/mcp/test_loader.py
T
7506bcc0e4 fix(mcp): send Authorization: Bearer; auto-load MCP tools in ask/serve (#494)
Closes #461. Reported and empirically validated by @swilliams76360.

Two bugs prevented authenticated MCP servers (e.g. Home Assistant) from working with OpenJarvis:

1. StreamableHTTPTransport never sent Authorization: Bearer <token> — constructor didn't accept a token kwarg and _build_headers() never set the header. Authenticated MCP servers always returned 401.

2. jarvis ask and jarvis serve never iterated config.tools.mcp.servers — only loaded tools from ToolRegistry. MCP tools were silently dropped on every CLI invocation.

The reporter's 3-file fix was correct; the workflow investigation surfaced a 4th file (agent_manager_routes.py:695, identical broken code) and an adversarial-review catch (MCP clients in _build_tools would be GC'd on function return, closing transports mid-request — fixed by stashing on agent._mcp_clients).

Edits:

- transport.py: token kwarg + Authorization header (skips on empty/None — avoids malformed "Bearer " that triggers confusing 400s).
- mcp/loader.py (NEW): shared load_mcp_tools_from_config helper returning (tools, clients). Caller MUST hold the clients reference.
- builder.py + agent_manager_routes.py: extract cfg.get("token"), forward to transport.
- cli/ask.py: _run_agent calls the loader, dedupes by spec.name (registry wins), stashes clients on agent._mcp_clients.
- cli/serve.py: same pattern in main-agent AND channel-agent paths; mcp_clients initialised before the accepts_tools branch so the post-instantiation reference is always valid.

22 new tests (transport + loader + discovery updates), 179 total cli/server/mcp tests pass on this branch.

Adversarial review interrogated 10 angles — slotted-class attr safety, MCPConfig duck-typing, config.tools.mcp AttributeError risk, dedup precedence, token leak via str(exc), logger scope in serve.py, _mcp_clients shadowing, _channel_mcp_clients lifetime, empty-token future-compat, lazy-import cost shift. Nine non-issues; the tenth (theoretical token leak via httpx exception str()) assessed as low actual risk because the token is a header value, not URL-embedded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 18:27:00 -07:00

236 lines
8.4 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)