mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-28 05:12:26 +00:00
Rebased and cleaned-up version of PR #113 by @mricharz, resolved against current main (including Codex engine, Gemini thought_signature, and agent manager fixes merged since the original PR). MCP Transport & Client: - StreamableHTTPTransport with session tracking, SSE parsing, timeouts - MCPClient.initialize() sends proper MCP handshake (protocolVersion, capabilities, clientInfo) + notifications/initialized - Fix StdioTransport constructor: command=[command] + args - MCPRequest.to_dict() with notification support (id=None) External MCP Discovery: - _discover_external_mcp supports both url (HTTP) and command (stdio) - Per-server include_tools / exclude_tools filtering - MCP clients persisted on JarvisSystem for runtime lifetime Streaming Tool-Call Support (stream_full): - StreamChunk dataclass in _stubs.py (content, tool_calls, finish_reason, usage) - Default stream_full() on InferenceEngine ABC wraps stream() for backward compat - _OpenAICompatibleEngine.stream_full() with SSE parsing - CloudEngine: _stream_full_openai (OpenAI/OpenRouter/MiniMax/Codex routing) _stream_full_anthropic (event-based → OpenAI delta format) - InstrumentedEngine, MultiEngine: stream_full delegation - GuardrailsEngine: stream_full with post-hoc security scanning (FIXED: original PR bypassed output scanning — now accumulates and scans like stream()) Other improvements: - _prepare_anthropic_messages() extracted to eliminate duplication - Default tool_choice=auto when tools are provided (OpenAI compat engines) - MCP tool injection into managed agent streaming path - Documentation: docs/user-guide/mcp-external-servers.md Tests: ~59 new tests across 8 test files, all passing. Closes PR #113 Co-Authored-By: mricharz <mricharz@users.noreply.github.com> Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
155 lines
5.1 KiB
Python
155 lines
5.1 KiB
Python
"""Tests for _get_mcp_tools() caching in agent_manager_routes."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
pytest.importorskip("fastapi", reason="fastapi required for server route tests")
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _FakeAppState:
|
|
"""Minimal app_state substitute with dynamic attributes."""
|
|
|
|
pass
|
|
|
|
|
|
def _make_config(*, enabled: bool = True, servers_json: str = "[]") -> MagicMock:
|
|
"""Build a mock config with tools.mcp.enabled and tools.mcp.servers."""
|
|
config = MagicMock()
|
|
config.tools.mcp.enabled = enabled
|
|
config.tools.mcp.servers = servers_json
|
|
return config
|
|
|
|
|
|
def _make_tool_spec(name: str, description: str = "") -> MagicMock:
|
|
spec = MagicMock()
|
|
spec.name = name
|
|
spec.description = description
|
|
spec.parameters = {"type": "object", "properties": {}}
|
|
return spec
|
|
|
|
|
|
def _make_adapter(name: str) -> MagicMock:
|
|
adapter = MagicMock()
|
|
adapter.spec = _make_tool_spec(name)
|
|
return adapter
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@patch("openjarvis.core.config.load_config")
|
|
def test_returns_tools_from_mcp_server(mock_load_config: MagicMock):
|
|
"""With a mocked MCP server, discovered tools are returned."""
|
|
from openjarvis.server.agent_manager_routes import _get_mcp_tools
|
|
|
|
server_cfg = [{"name": "test-server", "url": "http://localhost:9999"}]
|
|
mock_load_config.return_value = _make_config(
|
|
servers_json=json.dumps(server_cfg),
|
|
)
|
|
|
|
mock_adapter = _make_adapter("get_weather")
|
|
|
|
with (
|
|
patch("openjarvis.mcp.transport.StreamableHTTPTransport"),
|
|
patch("openjarvis.mcp.client.MCPClient"),
|
|
patch("openjarvis.tools.mcp_adapter.MCPToolProvider") as MockProvider,
|
|
):
|
|
MockProvider.return_value.discover.return_value = [mock_adapter]
|
|
|
|
app_state = _FakeAppState()
|
|
tools, adapters = _get_mcp_tools(app_state)
|
|
|
|
assert len(tools) == 1
|
|
assert tools[0]["function"]["name"] == "get_weather"
|
|
assert "get_weather" in adapters
|
|
|
|
|
|
@patch("openjarvis.core.config.load_config")
|
|
def test_caches_successful_discovery(mock_load_config: MagicMock):
|
|
"""Second call returns cached result without re-discovering."""
|
|
from openjarvis.server.agent_manager_routes import _get_mcp_tools
|
|
|
|
server_cfg = [{"name": "test-server", "url": "http://localhost:9999"}]
|
|
mock_load_config.return_value = _make_config(
|
|
servers_json=json.dumps(server_cfg),
|
|
)
|
|
|
|
mock_adapter = _make_adapter("cached_tool")
|
|
|
|
with (
|
|
patch("openjarvis.mcp.transport.StreamableHTTPTransport"),
|
|
patch("openjarvis.mcp.client.MCPClient"),
|
|
patch("openjarvis.tools.mcp_adapter.MCPToolProvider") as MockProvider,
|
|
):
|
|
MockProvider.return_value.discover.return_value = [mock_adapter]
|
|
|
|
app_state = _FakeAppState()
|
|
|
|
# First call discovers
|
|
tools1, _ = _get_mcp_tools(app_state)
|
|
assert len(tools1) == 1
|
|
|
|
# Second call should use cache (discover not called again)
|
|
discover_call_count = MockProvider.return_value.discover.call_count
|
|
tools2, _ = _get_mcp_tools(app_state)
|
|
assert len(tools2) == 1
|
|
assert MockProvider.return_value.discover.call_count == discover_call_count
|
|
|
|
|
|
@patch("openjarvis.core.config.load_config")
|
|
def test_does_not_cache_empty_results(mock_load_config: MagicMock):
|
|
"""Failed/empty discovery is not cached so it can be retried."""
|
|
from openjarvis.server.agent_manager_routes import _get_mcp_tools
|
|
|
|
server_cfg = [{"name": "failing-server", "url": "http://localhost:9999"}]
|
|
mock_load_config.return_value = _make_config(
|
|
servers_json=json.dumps(server_cfg),
|
|
)
|
|
|
|
with (
|
|
patch("openjarvis.mcp.transport.StreamableHTTPTransport"),
|
|
patch("openjarvis.mcp.client.MCPClient"),
|
|
patch("openjarvis.tools.mcp_adapter.MCPToolProvider") as MockProvider,
|
|
):
|
|
# First call: discovery returns empty
|
|
MockProvider.return_value.discover.return_value = []
|
|
app_state = _FakeAppState()
|
|
|
|
tools1, _ = _get_mcp_tools(app_state)
|
|
assert len(tools1) == 0
|
|
|
|
# Verify no cache was set (empty result)
|
|
assert getattr(app_state, "_mcp_tools_cache", None) is None
|
|
|
|
# Second call: discovery now returns something
|
|
mock_adapter = _make_adapter("retry_tool")
|
|
MockProvider.return_value.discover.return_value = [mock_adapter]
|
|
|
|
tools2, _ = _get_mcp_tools(app_state)
|
|
assert len(tools2) == 1
|
|
assert tools2[0]["function"]["name"] == "retry_tool"
|
|
|
|
|
|
@patch("openjarvis.core.config.load_config")
|
|
def test_handles_config_load_failure(mock_load_config: MagicMock):
|
|
"""Config load failure returns empty, no crash."""
|
|
from openjarvis.server.agent_manager_routes import _get_mcp_tools
|
|
|
|
mock_load_config.side_effect = RuntimeError("config broken")
|
|
|
|
app_state = _FakeAppState()
|
|
tools, adapters = _get_mcp_tools(app_state)
|
|
|
|
assert tools == []
|
|
assert adapters == {}
|