Files
wehub-resource-sync fed8b2eed7
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Backend release / release (push) Has been cancelled
Bandit Security Scan / bandit_scan (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push multi-arch DocsGPT Docker image / manifest (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Has been cancelled
Build and push DocsGPT FE Docker image for development / manifest (push) Has been cancelled
Python linting / ruff (push) Has been cancelled
Run python tests with pytest / Run tests and count coverage (3.12) (push) Has been cancelled
React Widget Build / build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

2661 lines
93 KiB
Python

"""Comprehensive tests for application/agents/tools/mcp_tool.py
Covers: MCPTool init, cache key generation, transport creation, tool formatting,
result formatting, execute_action, discover_tools, test_connection,
get_actions_metadata, DocsGPTOAuth, NonInteractiveOAuth, DBTokenStorage,
MCPOAuthManager.
"""
import asyncio
import concurrent.futures
import json
from unittest.mock import MagicMock, patch
import pytest
# ---- Fixtures to isolate module-level side effects ----
@pytest.fixture(autouse=True)
def _patch_mcp_globals(monkeypatch):
"""Patch module-level cache to avoid real connections.
MongoDB is no longer used at module level; DBTokenStorage now backs
onto the ``connector_sessions`` Postgres repository. The cache patch
is still required to avoid hitting real Redis.
"""
import sys
if "application.agents.tools.mcp_tool" in sys.modules:
mcp_mod = sys.modules["application.agents.tools.mcp_tool"]
else:
mock_tasks = MagicMock()
monkeypatch.setitem(sys.modules, "application.api.user.tasks", mock_tasks)
import application.agents.tools.mcp_tool as mcp_mod
monkeypatch.setattr(mcp_mod, "_mcp_clients_cache", {})
# Bypass DNS-resolving URL validation for tests using fake hostnames.
monkeypatch.setattr(mcp_mod, "validate_url", lambda u, **kw: u)
@pytest.fixture
def mcp_config():
return {
"server_url": "https://mcp.example.com/api",
"transport_type": "http",
"auth_type": "none",
"timeout": 10,
}
@pytest.fixture
def bearer_config():
return {
"server_url": "https://mcp.example.com/api",
"transport_type": "http",
"auth_type": "bearer",
"auth_credentials": {"bearer_token": "tok_123"},
"timeout": 10,
}
def _make_tool(config, **kwargs):
from application.agents.tools.mcp_tool import MCPTool
with patch.object(MCPTool, "_setup_client"):
return MCPTool(config, **kwargs)
# =====================================================================
# MCPTool Initialization
# =====================================================================
@pytest.mark.unit
class TestMCPToolInit:
def test_basic_init(self, mcp_config):
tool = _make_tool(mcp_config)
assert tool.server_url == "https://mcp.example.com/api"
assert tool.transport_type == "http"
assert tool.auth_type == "none"
assert tool.timeout == 10
assert tool.available_tools == []
def test_bearer_auth_credentials(self, bearer_config):
tool = _make_tool(bearer_config)
assert tool.auth_credentials["bearer_token"] == "tok_123"
def test_no_server_url_skips_setup(self):
from application.agents.tools.mcp_tool import MCPTool
with patch.object(MCPTool, "_setup_client") as mock_setup:
MCPTool({"server_url": "", "auth_type": "none"})
mock_setup.assert_not_called()
def test_oauth_skips_setup(self):
from application.agents.tools.mcp_tool import MCPTool
with patch.object(MCPTool, "_setup_client") as mock_setup:
MCPTool({
"server_url": "https://mcp.example.com",
"auth_type": "oauth",
})
mock_setup.assert_not_called()
def test_encrypted_credentials_decryption(self):
from application.agents.tools.mcp_tool import MCPTool
with patch.object(MCPTool, "_setup_client"), \
patch("application.agents.tools.mcp_tool.decrypt_credentials",
return_value={"bearer_token": "decrypted_tok"}):
tool = MCPTool(
{
"server_url": "https://mcp.example.com",
"auth_type": "bearer",
"encrypted_credentials": "enc_data",
},
user_id="user1",
)
assert tool.auth_credentials == {"bearer_token": "decrypted_tok"}
def test_query_mode_default_false(self, mcp_config):
tool = _make_tool(mcp_config)
assert tool.query_mode is False
def test_query_mode_explicit_true(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
"query_mode": True,
})
assert tool.query_mode is True
def test_custom_headers_stored(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
"headers": {"X-Custom": "val"},
})
assert tool.custom_headers == {"X-Custom": "val"}
def test_rejects_metadata_ip(self, monkeypatch):
from application.agents.tools.mcp_tool import MCPTool
from application.core.url_validation import validate_url as real_validate_url
import application.agents.tools.mcp_tool as mcp_mod
monkeypatch.setattr(mcp_mod, "validate_url", real_validate_url)
with pytest.raises(ValueError, match="Invalid MCP server URL"):
MCPTool(config={"server_url": "http://169.254.169.254/latest/meta-data", "auth_type": "none"})
def test_rejects_localhost(self, monkeypatch):
from application.agents.tools.mcp_tool import MCPTool
from application.core.url_validation import validate_url as real_validate_url
import application.agents.tools.mcp_tool as mcp_mod
monkeypatch.setattr(mcp_mod, "validate_url", real_validate_url)
with pytest.raises(ValueError, match="Invalid MCP server URL"):
MCPTool(config={"server_url": "http://localhost:8080/mcp", "auth_type": "none"})
def test_rejects_private_ip(self, monkeypatch):
from application.agents.tools.mcp_tool import MCPTool
from application.core.url_validation import validate_url as real_validate_url
import application.agents.tools.mcp_tool as mcp_mod
monkeypatch.setattr(mcp_mod, "validate_url", real_validate_url)
with pytest.raises(ValueError, match="Invalid MCP server URL"):
MCPTool(config={"server_url": "http://10.0.0.1/mcp", "auth_type": "none"})
def test_accepts_public_url(self):
tool = _make_tool({
"server_url": "https://mcp.example.com/api",
"auth_type": "none",
})
assert tool.server_url == "https://mcp.example.com/api"
def test_empty_server_url_allowed(self):
from application.agents.tools.mcp_tool import MCPTool
with patch.object(MCPTool, "_setup_client"):
tool = MCPTool(config={"server_url": "", "auth_type": "none"})
assert tool.server_url == ""
# =====================================================================
# Redirect URI Resolution
# =====================================================================
@pytest.mark.unit
class TestResolveRedirectUri:
def test_configured_redirect_uri_used(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
"redirect_uri": "https://my.app/callback/",
})
assert tool.redirect_uri == "https://my.app/callback"
def test_fallback_to_settings(self, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "API_URL", "https://api.docsgpt.co")
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
})
assert "/api/mcp_server/callback" in tool.redirect_uri
# =====================================================================
# Cache Key Generation
# =====================================================================
@pytest.mark.unit
class TestGenerateCacheKey:
def test_none_auth(self, mcp_config):
tool = _make_tool(mcp_config)
assert "none" in tool._cache_key
assert "mcp.example.com" in tool._cache_key
def test_bearer_auth(self, bearer_config):
tool = _make_tool(bearer_config)
assert "bearer:" in tool._cache_key
def test_api_key_auth(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "api_key",
"auth_credentials": {"api_key": "sk-test12345678"},
})
assert "apikey:" in tool._cache_key
def test_basic_auth(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "basic",
"auth_credentials": {"username": "user1", "password": "pass"},
})
assert "basic:user1" in tool._cache_key
def test_oauth_auth_includes_scopes(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "oauth",
"oauth_scopes": ["read", "write"],
})
assert "oauth:" in tool._cache_key
assert "read,write" in tool._cache_key
def test_bearer_empty_token(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "bearer",
"auth_credentials": {},
})
assert "bearer:none" in tool._cache_key
def test_api_key_empty(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "api_key",
"auth_credentials": {},
})
assert "apikey:none" in tool._cache_key
# =====================================================================
# Transport Creation
# =====================================================================
@pytest.mark.unit
class TestCreateTransport:
def test_http_transport(self, mcp_config):
tool = _make_tool(mcp_config)
transport = tool._create_transport()
assert transport is not None
def test_sse_transport(self):
tool = _make_tool({
"server_url": "https://mcp.example.com/sse",
"transport_type": "sse",
"auth_type": "none",
})
transport = tool._create_transport()
assert transport is not None
def test_auto_detects_sse(self):
tool = _make_tool({
"server_url": "https://mcp.example.com/sse",
"transport_type": "auto",
"auth_type": "none",
})
transport = tool._create_transport()
assert transport is not None
def test_auto_defaults_to_http(self):
tool = _make_tool({
"server_url": "https://mcp.example.com/api",
"transport_type": "auto",
"auth_type": "none",
})
transport = tool._create_transport()
assert transport is not None
def test_stdio_transport_disabled(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"transport_type": "stdio",
"auth_type": "none",
})
with pytest.raises(ValueError, match="STDIO transport is disabled"):
tool._create_transport()
def test_api_key_header_injected(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"transport_type": "http",
"auth_type": "api_key",
"auth_credentials": {
"api_key": "sk-test",
"api_key_header": "X-Custom-Key",
},
})
transport = tool._create_transport()
assert transport is not None
def test_basic_auth_header_injected(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"transport_type": "http",
"auth_type": "basic",
"auth_credentials": {"username": "user", "password": "pass"},
})
transport = tool._create_transport()
assert transport is not None
def test_unknown_transport_type_defaults_to_http(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"transport_type": "grpc",
"auth_type": "none",
})
transport = tool._create_transport()
assert transport is not None
# =====================================================================
# Format Tools
# =====================================================================
@pytest.mark.unit
class TestFormatTools:
def test_format_list_of_dicts(self, mcp_config):
tool = _make_tool(mcp_config)
result = tool._format_tools([{"name": "tool1", "description": "desc"}])
assert len(result) == 1
assert result[0]["name"] == "tool1"
def test_format_tools_with_name_attribute(self, mcp_config):
tool = _make_tool(mcp_config)
mock_tool = MagicMock()
mock_tool.name = "my_tool"
mock_tool.description = "A tool"
mock_tool.inputSchema = {"type": "object", "properties": {}}
result = tool._format_tools([mock_tool])
assert len(result) == 1
assert result[0]["name"] == "my_tool"
assert result[0]["inputSchema"] == {"type": "object", "properties": {}}
def test_format_tools_with_model_dump(self, mcp_config):
tool = _make_tool(mcp_config)
mock_tool = MagicMock(spec=[])
mock_tool.model_dump = MagicMock(
return_value={"name": "dumped", "description": "from dump"}
)
# Ensure no "name" attribute
result = tool._format_tools([mock_tool])
assert len(result) == 1
assert result[0]["name"] == "dumped"
def test_format_tools_fallback_str(self, mcp_config):
tool = _make_tool(mcp_config)
class BareTool:
def __str__(self):
return "bare_tool"
result = tool._format_tools([BareTool()])
assert len(result) == 1
assert result[0]["name"] == "bare_tool"
assert result[0]["description"] == ""
def test_format_tools_response_object(self, mcp_config):
tool = _make_tool(mcp_config)
resp = MagicMock()
resp.tools = [{"name": "t1", "description": "d1"}]
result = tool._format_tools(resp)
assert len(result) == 1
def test_format_tools_without_input_schema(self, mcp_config):
tool = _make_tool(mcp_config)
mock_tool = MagicMock()
mock_tool.name = "simple"
mock_tool.description = "no schema"
del mock_tool.inputSchema
result = tool._format_tools([mock_tool])
assert "inputSchema" not in result[0]
def test_format_empty(self, mcp_config):
tool = _make_tool(mcp_config)
assert tool._format_tools([]) == []
assert tool._format_tools("unexpected") == []
# =====================================================================
# Format Result
# =====================================================================
@pytest.mark.unit
class TestFormatResult:
def test_format_result_with_text_content(self, mcp_config):
tool = _make_tool(mcp_config)
mock_result = MagicMock()
text_item = MagicMock()
text_item.text = "Hello"
del text_item.data
mock_result.content = [text_item]
mock_result.isError = False
result = tool._format_result(mock_result)
assert result["content"][0]["type"] == "text"
assert result["content"][0]["text"] == "Hello"
assert result["isError"] is False
def test_format_result_with_data_content(self, mcp_config):
tool = _make_tool(mcp_config)
mock_result = MagicMock()
data_item = MagicMock()
del data_item.text
data_item.data = {"key": "value"}
mock_result.content = [data_item]
mock_result.isError = False
result = tool._format_result(mock_result)
assert result["content"][0]["type"] == "data"
assert result["content"][0]["data"] == {"key": "value"}
def test_format_result_unknown_content_type(self, mcp_config):
tool = _make_tool(mcp_config)
mock_result = MagicMock()
unknown_item = MagicMock()
del unknown_item.text
del unknown_item.data
mock_result.content = [unknown_item]
mock_result.isError = False
result = tool._format_result(mock_result)
assert result["content"][0]["type"] == "unknown"
def test_format_raw_result(self, mcp_config):
tool = _make_tool(mcp_config)
raw = {"key": "value"}
assert tool._format_result(raw) == raw
# =====================================================================
# Execute Action
# =====================================================================
@pytest.mark.unit
class TestExecuteAction:
def test_no_server_raises(self):
tool = _make_tool({"server_url": "", "auth_type": "none"})
with pytest.raises(Exception, match="No MCP server configured"):
tool.execute_action("test_action")
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_successful_execute(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.return_value = {"key": "value"}
result = tool.execute_action("test_action", param1="val1")
mock_run.assert_called_once_with("call_tool", "test_action", param1="val1")
assert result == {"key": "value"}
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_empty_kwargs_cleaned(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.return_value = {}
tool.execute_action("test", param1="", param2=None, param3="real")
call_kwargs = mock_run.call_args[1]
assert "param1" not in call_kwargs
assert "param2" not in call_kwargs
assert call_kwargs["param3"] == "real"
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_auth_error_retries_for_non_oauth(self, mock_run, mcp_config):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [
Exception("401 Unauthorized"),
{"key": "retry_ok"},
]
with patch.object(MCPTool, "_setup_client"):
result = tool.execute_action("act")
assert result == {"key": "retry_ok"}
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_auth_error_raises_for_oauth(self, mock_run):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "oauth",
})
tool._client = MagicMock()
mock_run.side_effect = Exception("401 Unauthorized")
with pytest.raises(Exception, match="OAuth session expired"):
tool.execute_action("act")
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_non_auth_error_raises(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = Exception("Something weird happened")
with pytest.raises(Exception, match="Failed to execute action"):
tool.execute_action("act")
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_no_client_calls_setup(self, mock_run, mcp_config):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool(mcp_config)
tool._client = None
mock_run.return_value = {"ok": True}
with patch.object(MCPTool, "_setup_client") as mock_setup:
tool.execute_action("act")
mock_setup.assert_called()
# =====================================================================
# Discover Tools
# =====================================================================
@pytest.mark.unit
class TestDiscoverTools:
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_discover_tools_success(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.return_value = [{"name": "t1", "description": "d1"}]
result = tool.discover_tools()
assert len(result) == 1
assert result[0]["name"] == "t1"
def test_discover_tools_no_server_url(self):
tool = _make_tool({"server_url": "", "auth_type": "none"})
assert tool.discover_tools() == []
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_discover_tools_error(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = Exception("connection lost")
with pytest.raises(Exception, match="Failed to discover tools"):
tool.discover_tools()
# =====================================================================
# Test Connection
# =====================================================================
@pytest.mark.unit
class TestTestConnection:
def test_no_server_url(self):
tool = _make_tool({"server_url": "", "auth_type": "none"})
result = tool.test_connection()
assert result["success"] is False
assert "No server URL" in result["message"]
def test_invalid_scheme(self):
tool = _make_tool({"server_url": "ftp://bad.com", "auth_type": "none"})
result = tool.test_connection()
assert result["success"] is False
assert "Invalid URL scheme" in result["message"]
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_regular_connection_success(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [
None, # ping
[{"name": "t1", "description": "d1"}], # list_tools
]
result = tool.test_connection()
assert result["success"] is True
assert result["tools_count"] == 1
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_regular_connection_ping_fails_tools_work(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [
Exception("ping failed"), # ping
[{"name": "t1", "description": "d1"}], # list_tools
]
result = tool.test_connection()
assert result["success"] is True
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_regular_connection_both_fail(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [
Exception("ping failed"),
Exception("tools failed"),
]
result = tool.test_connection()
assert result["success"] is False
def test_client_init_failure(self):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool({
"server_url": "https://good.example.com",
"auth_type": "none",
})
tool._client = None
with patch.object(MCPTool, "_setup_client", side_effect=Exception("init fail")):
result = tool.test_connection()
assert result["success"] is False
assert "Client init failed" in result["message"]
# =====================================================================
# Map Error
# =====================================================================
@pytest.mark.unit
class TestMapError:
def test_timeout_error(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test", concurrent.futures.TimeoutError())
assert "Timed out" in str(err)
def test_connection_refused(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test", ConnectionRefusedError())
assert "Connection refused" in str(err)
def test_403_forbidden(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test", Exception("403 Forbidden"))
assert "Access denied" in str(err)
def test_401_unauthorized(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test", Exception("401 Unauthorized"))
assert "Authentication failed" in str(err)
def test_econnrefused_pattern(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test", Exception("ECONNREFUSED error"))
assert "Connection refused" in str(err)
def test_ssl_error(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test", Exception("SSL certificate verify failed"))
assert "SSL" in str(err)
def test_unknown_error_passthrough(self, mcp_config):
tool = _make_tool(mcp_config)
original = RuntimeError("something weird")
err = tool._map_error("test", original)
assert err is original
# =====================================================================
# Get Actions Metadata
# =====================================================================
@pytest.mark.unit
class TestGetActionsMetadata:
def test_empty_tools(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = []
assert tool.get_actions_metadata() == []
def test_tools_with_input_schema(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [
{
"name": "search",
"description": "Search things",
"inputSchema": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
"additionalProperties": False,
"description": "Search params",
},
}
]
meta = tool.get_actions_metadata()
assert len(meta) == 1
assert meta[0]["name"] == "search"
assert "query" in meta[0]["parameters"]["properties"]
assert meta[0]["parameters"]["additionalProperties"] is False
def test_tools_without_schema(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [{"name": "ping", "description": "Ping"}]
meta = tool.get_actions_metadata()
assert meta[0]["parameters"]["properties"] == {}
def test_tools_with_flat_schema(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [
{
"name": "flat",
"description": "Flat schema",
"inputSchema": {"query": {"type": "string"}},
}
]
meta = tool.get_actions_metadata()
assert "query" in meta[0]["parameters"]["properties"]
def test_tools_with_alternate_schema_keys(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [
{
"name": "alt",
"description": "Alt",
"parameters": {
"type": "object",
"properties": {"x": {"type": "number"}},
},
}
]
meta = tool.get_actions_metadata()
assert "x" in meta[0]["parameters"]["properties"]
def test_config_requirements(self, mcp_config):
tool = _make_tool(mcp_config)
reqs = tool.get_config_requirements()
assert "server_url" in reqs
assert "auth_type" in reqs
assert reqs["server_url"]["required"] is True
assert "timeout" in reqs
# =====================================================================
# Setup Client (caching)
# =====================================================================
@pytest.mark.unit
class TestSetupClient:
def test_setup_client_caches_client(self, mcp_config):
from application.agents.tools.mcp_tool import MCPTool
tool = MCPTool.__new__(MCPTool)
tool.config = mcp_config
tool.server_url = mcp_config["server_url"]
tool.transport_type = "http"
tool.auth_type = "none"
tool.timeout = 10
tool.custom_headers = {}
tool.auth_credentials = {}
tool.oauth_scopes = []
tool.oauth_task_id = None
tool.oauth_client_name = "DocsGPT-MCP"
tool.redirect_uri = "https://example.com/callback"
tool.query_mode = False
tool._cache_key = "test_cache_key"
tool._client = None
tool.available_tools = []
mock_client = MagicMock()
with patch.object(MCPTool, "_create_transport", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.Client", return_value=mock_client):
tool._setup_client()
assert tool._client is mock_client
# =====================================================================
# MCPOAuthManager
# =====================================================================
@pytest.mark.unit
class TestMCPOAuthManager:
def test_handle_callback_success(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
manager = MCPOAuthManager(mock_redis)
result = manager.handle_oauth_callback(state="abc123", code="auth_code")
assert result is True
mock_redis.setex.assert_called()
def test_handle_callback_no_redis(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
manager = MCPOAuthManager(None)
result = manager.handle_oauth_callback(state="abc", code="code")
assert result is False
def test_handle_callback_no_state(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
manager = MCPOAuthManager(mock_redis)
result = manager.handle_oauth_callback(state="", code="code")
assert result is False
def test_handle_callback_error(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
manager = MCPOAuthManager(mock_redis)
result = manager.handle_oauth_callback(
state="abc", code="", error="access_denied"
)
assert result is False
def test_get_oauth_status_no_task(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
manager = MCPOAuthManager(MagicMock())
result = manager.get_oauth_status("", "alice")
assert result["status"] == "not_started"
def test_get_oauth_status_no_user(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
manager = MCPOAuthManager(MagicMock())
result = manager.get_oauth_status("task123", "")
# Without a user id we can't address the per-user SSE journal,
# so the manager refuses rather than scanning every user's
# stream.
assert result["status"] == "not_found"
def test_get_oauth_status_reads_completed_envelope_from_journal(self):
"""The manager walks the user's SSE Streams journal and
surfaces the latest ``mcp.oauth.*`` envelope for the task.
Verifies the full polling-contract surface: ``status`` is
derived from the event-type suffix, and the completed
payload's ``tools`` / ``tools_count`` fields are passed
through unchanged so ``mcp.py``'s ``connect_mcp`` can use them
without further plumbing.
"""
from application.agents.tools.mcp_tool import MCPOAuthManager
completed_envelope = json.dumps(
{
"type": "mcp.oauth.completed",
"scope": {"kind": "mcp_oauth", "id": "task123"},
"payload": {
"task_id": "task123",
"tools": [{"name": "t1", "description": "d"}],
"tools_count": 1,
},
}
).encode("utf-8")
# xrevrange yields newest-first; an unrelated older entry
# should be skipped on the way to the matching one.
unrelated_envelope = json.dumps(
{
"type": "source.ingest.completed",
"scope": {"kind": "source", "id": "abc"},
"payload": {},
}
).encode("utf-8")
mock_redis = MagicMock()
mock_redis.xrevrange.return_value = [
(b"1735682400000-0", {b"event": completed_envelope}),
(b"1735682300000-0", {b"event": unrelated_envelope}),
]
manager = MCPOAuthManager(mock_redis)
result = manager.get_oauth_status("task123", "alice")
assert result["status"] == "completed"
assert result["tools"] == [{"name": "t1", "description": "d"}]
assert result["tools_count"] == 1
# The stream key is scoped per-user — never global.
mock_redis.xrevrange.assert_called_once()
call_args = mock_redis.xrevrange.call_args
assert "user:alice:stream" in call_args.args
# Scan window must cover the full bounded stream so a flood of
# concurrent source-ingest events between popup-completed and
# Save can't push the OAuth envelope out of view.
from application.core.settings import settings
assert call_args.kwargs.get("count") >= settings.EVENTS_STREAM_MAXLEN
def test_get_oauth_status_scan_covers_events_stream_maxlen(self):
"""Regression: count must scale with ``EVENTS_STREAM_MAXLEN``
so the OAuth completion envelope is reachable even after
concurrent source-ingest events flood the user stream."""
from application.agents.tools.mcp_tool import MCPOAuthManager
from application.core.settings import settings
mock_redis = MagicMock()
mock_redis.xrevrange.return_value = []
manager = MCPOAuthManager(mock_redis)
manager.get_oauth_status("task123", "alice")
call_args = mock_redis.xrevrange.call_args
assert call_args.kwargs.get("count") >= settings.EVENTS_STREAM_MAXLEN
def test_get_oauth_status_returns_not_found_when_no_match(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
# Stream is non-empty but has nothing matching this task.
unrelated_envelope = json.dumps(
{
"type": "mcp.oauth.completed",
"scope": {"kind": "mcp_oauth", "id": "other-task"},
"payload": {"task_id": "other-task"},
}
).encode("utf-8")
mock_redis.xrevrange.return_value = [
(b"1735682400000-0", {b"event": unrelated_envelope}),
]
manager = MCPOAuthManager(mock_redis)
result = manager.get_oauth_status("task123", "alice")
assert result["status"] == "not_found"
# =====================================================================
# DBTokenStorage
# =====================================================================
@pytest.mark.unit
class TestDBTokenStorage:
"""Covers the repository-backed DBTokenStorage post-PG migration.
Round-trip tests use the ephemeral ``pg_conn`` fixture and patch
``db_session``/``db_readonly`` in ``application.agents.tools.mcp_tool``
so the real INSERT/SELECT SQL runs. This is the shape that caught the
``server_url`` NULL-column regression — ``get_tokens`` only succeeds
if ``set_tokens`` populated the scalar column.
"""
@staticmethod
def _patch_db(monkeypatch, pg_conn):
from contextlib import contextmanager
import application.agents.tools.mcp_tool as mcp_mod
@contextmanager
def _yield():
yield pg_conn
monkeypatch.setattr(mcp_mod, "db_session", _yield, raising=False)
monkeypatch.setattr(mcp_mod, "db_readonly", _yield, raising=False)
# mcp_tool imports db_session/db_readonly *inside* the helper
# methods, so also patch the origin module they come from.
import application.storage.db.session as session_mod
monkeypatch.setattr(session_mod, "db_session", _yield)
monkeypatch.setattr(session_mod, "db_readonly", _yield)
def test_get_base_url(self):
from application.agents.tools.mcp_tool import DBTokenStorage
assert (
DBTokenStorage.get_base_url("https://mcp.example.com/api/v1")
== "https://mcp.example.com"
)
def test_get_base_url_with_port(self):
from application.agents.tools.mcp_tool import DBTokenStorage
assert (
DBTokenStorage.get_base_url("http://localhost:8080/path")
== "http://localhost:8080"
)
def test_pg_provider_includes_base_url(self):
from application.agents.tools.mcp_tool import DBTokenStorage
storage = DBTokenStorage(
server_url="https://mcp.example.com/api",
user_id="user1",
)
assert storage._pg_provider() == "mcp:https://mcp.example.com"
def test_serialize_client_info(self):
from application.agents.tools.mcp_tool import DBTokenStorage
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
)
info = {"redirect_uris": ["https://example.com/cb"]}
result = storage._serialize_client_info(info)
assert result["redirect_uris"] == ["https://example.com/cb"]
def test_get_tokens_none_when_no_row(self, monkeypatch, pg_conn):
from application.agents.tools.mcp_tool import DBTokenStorage
self._patch_db(monkeypatch, pg_conn)
storage = DBTokenStorage(
server_url="https://mcp.example.com/api",
user_id="user-empty",
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_tokens())
assert result is None
finally:
loop.close()
def test_set_then_get_tokens_roundtrip(self, monkeypatch, pg_conn):
"""Regression: ``set_tokens`` must populate the scalar
``server_url`` column so ``get_tokens`` (which goes through
``get_by_user_and_server_url``) can resolve the row."""
from mcp.shared.auth import OAuthToken
from application.agents.tools.mcp_tool import DBTokenStorage
self._patch_db(monkeypatch, pg_conn)
storage = DBTokenStorage(
server_url="https://mcp.example.com/api/v1",
user_id="user-roundtrip",
)
tokens_in = OAuthToken(
access_token="at-abc",
token_type="Bearer",
expires_in=3600,
refresh_token="rt-xyz",
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(storage.set_tokens(tokens_in))
tokens_out = loop.run_until_complete(storage.get_tokens())
finally:
loop.close()
assert tokens_out is not None
assert tokens_out.access_token == "at-abc"
assert tokens_out.refresh_token == "rt-xyz"
def test_set_tokens_populates_scalar_server_url(
self, monkeypatch, pg_conn,
):
"""Direct check that the fix writes ``server_url`` to the scalar
column, not only into the JSONB blob."""
from mcp.shared.auth import OAuthToken
from application.agents.tools.mcp_tool import DBTokenStorage
from application.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
self._patch_db(monkeypatch, pg_conn)
base_url = "https://mcp.scalar.example.com"
storage = DBTokenStorage(
server_url=f"{base_url}/api",
user_id="user-scalar",
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
storage.set_tokens(
OAuthToken(access_token="at", token_type="Bearer"),
),
)
finally:
loop.close()
row = ConnectorSessionsRepository(pg_conn).get_by_user_and_server_url(
"user-scalar", base_url,
)
assert row is not None
assert row["server_url"] == base_url
# ``server_url`` must NOT be duplicated inside the JSONB blob.
session_data = row["session_data"] or {}
assert "server_url" not in session_data
assert session_data.get("tokens", {}).get("access_token") == "at"
def test_clear_removes_row(self, monkeypatch, pg_conn):
from mcp.shared.auth import OAuthToken
from application.agents.tools.mcp_tool import DBTokenStorage
from application.storage.db.repositories.connector_sessions import (
ConnectorSessionsRepository,
)
self._patch_db(monkeypatch, pg_conn)
base_url = "https://mcp.clear.example.com"
storage = DBTokenStorage(
server_url=f"{base_url}/api",
user_id="user-clear",
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
storage.set_tokens(
OAuthToken(access_token="at", token_type="Bearer"),
),
)
loop.run_until_complete(storage.clear())
finally:
loop.close()
assert (
ConnectorSessionsRepository(pg_conn).get_by_user_and_server_url(
"user-clear", base_url,
)
is None
)
# =====================================================================
# NonInteractiveOAuth
# =====================================================================
@pytest.mark.skip(reason="OAuth class signatures changed post-PG migration (db kwarg removed); needs rewrite")
@pytest.mark.unit
class TestNonInteractiveOAuth:
def test_redirect_handler_raises(self):
from application.agents.tools.mcp_tool import NonInteractiveOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = NonInteractiveOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=None,
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
loop = asyncio.new_event_loop()
try:
with pytest.raises(Exception, match="OAuth session expired"):
loop.run_until_complete(
oauth.redirect_handler("https://auth.example.com/authorize?state=x")
)
finally:
loop.close()
def test_callback_handler_raises(self):
from application.agents.tools.mcp_tool import NonInteractiveOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = NonInteractiveOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=None,
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
loop = asyncio.new_event_loop()
try:
with pytest.raises(Exception, match="OAuth session expired"):
loop.run_until_complete(oauth.callback_handler())
finally:
loop.close()
# =====================================================================
# Run Async Operation
# =====================================================================
@pytest.mark.unit
class TestRunAsyncOperation:
def test_run_in_new_loop(self, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
async def fake_execute(*args, **kwargs):
return "ok"
with patch.object(tool, "_execute_with_client", side_effect=fake_execute):
result = tool._run_in_new_loop("ping")
assert result == "ok"
# =====================================================================
# Resolve Redirect URI (additional coverage)
# =====================================================================
@pytest.mark.unit
class TestResolveRedirectUriExtended:
def test_mcp_oauth_redirect_uri_setting(self, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "MCP_OAUTH_REDIRECT_URI", "https://custom.redirect/callback/")
# Ensure no configured redirect_uri in config
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
})
assert tool.redirect_uri == "https://custom.redirect/callback"
def test_connector_redirect_base_uri_setting(self, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "MCP_OAUTH_REDIRECT_URI", None, raising=False)
monkeypatch.setattr(
settings, "CONNECTOR_REDIRECT_BASE_URI",
"https://connector.example.com/some/path",
)
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
})
assert tool.redirect_uri == "https://connector.example.com/api/mcp_server/callback"
def test_connector_redirect_base_uri_invalid_url(self, monkeypatch):
from application.core.settings import settings
monkeypatch.setattr(settings, "MCP_OAUTH_REDIRECT_URI", None, raising=False)
# Provide a base URI that has no scheme
monkeypatch.setattr(
settings, "CONNECTOR_REDIRECT_BASE_URI", "no-scheme-url",
)
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "none",
})
# Falls through to API_URL fallback
assert "/api/mcp_server/callback" in tool.redirect_uri
# =====================================================================
# _setup_client additional coverage (cache expiry, OAuth branches)
# =====================================================================
@pytest.mark.unit
class TestSetupClientExtended:
def test_cache_hit_returns_cached_client(self):
import application.agents.tools.mcp_tool as mcp_mod
from application.agents.tools.mcp_tool import MCPTool
tool = MCPTool.__new__(MCPTool)
tool.config = {"server_url": "https://mcp.example.com", "auth_type": "none"}
tool.server_url = "https://mcp.example.com"
tool.transport_type = "http"
tool.auth_type = "none"
tool.timeout = 10
tool.custom_headers = {}
tool.auth_credentials = {}
tool.oauth_scopes = []
tool.oauth_task_id = None
tool.oauth_client_name = "DocsGPT-MCP"
tool.redirect_uri = "https://example.com/callback"
tool.query_mode = False
tool._cache_key = "cache_hit_test_key"
tool._client = None
tool.available_tools = []
cached_client = MagicMock()
mcp_mod._mcp_clients_cache["cache_hit_test_key"] = {
"client": cached_client,
"created_at": __import__("time").time(),
}
tool._setup_client()
assert tool._client is cached_client
def test_expired_cache_creates_new_client(self):
import application.agents.tools.mcp_tool as mcp_mod
from application.agents.tools.mcp_tool import MCPTool
tool = MCPTool.__new__(MCPTool)
tool.config = {"server_url": "https://mcp.example.com", "auth_type": "none"}
tool.server_url = "https://mcp.example.com"
tool.transport_type = "http"
tool.auth_type = "none"
tool.timeout = 10
tool.custom_headers = {}
tool.auth_credentials = {}
tool.oauth_scopes = []
tool.oauth_task_id = None
tool.oauth_client_name = "DocsGPT-MCP"
tool.redirect_uri = "https://example.com/callback"
tool.query_mode = False
tool._cache_key = "expired_cache_key"
tool._client = None
tool.available_tools = []
old_client = MagicMock()
mcp_mod._mcp_clients_cache["expired_cache_key"] = {
"client": old_client,
"created_at": __import__("time").time() - 600,
}
new_client = MagicMock()
with patch.object(MCPTool, "_create_transport", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.Client", return_value=new_client):
tool._setup_client()
assert tool._client is new_client
assert "expired_cache_key" not in mcp_mod._mcp_clients_cache or \
mcp_mod._mcp_clients_cache["expired_cache_key"]["client"] is new_client
def test_setup_client_oauth_query_mode(self):
from application.agents.tools.mcp_tool import MCPTool
tool = MCPTool.__new__(MCPTool)
tool.config = {"server_url": "https://mcp.example.com", "auth_type": "oauth"}
tool.server_url = "https://mcp.example.com"
tool.transport_type = "http"
tool.auth_type = "oauth"
tool.timeout = 10
tool.custom_headers = {}
tool.auth_credentials = {}
tool.oauth_scopes = ["read"]
tool.oauth_task_id = None
tool.oauth_client_name = "DocsGPT-MCP"
tool.redirect_uri = "https://example.com/callback"
tool.query_mode = True
tool._cache_key = "oauth_qm_key"
tool._client = None
tool.available_tools = []
tool.user_id = "user1"
mock_client = MagicMock()
with patch.object(MCPTool, "_create_transport", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.Client", return_value=mock_client), \
patch("application.agents.tools.mcp_tool.get_redis_instance", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.NonInteractiveOAuth"):
tool._setup_client()
assert tool._client is mock_client
def test_setup_client_oauth_interactive_mode(self):
from application.agents.tools.mcp_tool import MCPTool
tool = MCPTool.__new__(MCPTool)
tool.config = {"server_url": "https://mcp.example.com", "auth_type": "oauth"}
tool.server_url = "https://mcp.example.com"
tool.transport_type = "http"
tool.auth_type = "oauth"
tool.timeout = 10
tool.custom_headers = {}
tool.auth_credentials = {}
tool.oauth_scopes = ["read"]
tool.oauth_task_id = "task123"
tool.oauth_client_name = "DocsGPT-MCP"
tool.redirect_uri = "https://example.com/callback"
tool.query_mode = False
tool._cache_key = "oauth_interactive_key"
tool._client = None
tool.available_tools = []
tool.user_id = "user1"
tool.oauth_redirect_publish = None
mock_client = MagicMock()
with patch.object(MCPTool, "_create_transport", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.Client", return_value=mock_client), \
patch("application.agents.tools.mcp_tool.get_redis_instance", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.DocsGPTOAuth"):
tool._setup_client()
assert tool._client is mock_client
def test_setup_client_bearer_auth(self):
from application.agents.tools.mcp_tool import MCPTool
tool = MCPTool.__new__(MCPTool)
tool.config = {"server_url": "https://mcp.example.com", "auth_type": "bearer"}
tool.server_url = "https://mcp.example.com"
tool.transport_type = "http"
tool.auth_type = "bearer"
tool.timeout = 10
tool.custom_headers = {}
tool.auth_credentials = {"bearer_token": "my_token"}
tool.oauth_scopes = []
tool.oauth_task_id = None
tool.oauth_client_name = "DocsGPT-MCP"
tool.redirect_uri = "https://example.com/callback"
tool.query_mode = False
tool._cache_key = "bearer_setup_key"
tool._client = None
tool.available_tools = []
mock_client = MagicMock()
with patch.object(MCPTool, "_create_transport", return_value=MagicMock()), \
patch("application.agents.tools.mcp_tool.Client", return_value=mock_client), \
patch("application.agents.tools.mcp_tool.BearerAuth") as mock_bearer_auth:
tool._setup_client()
mock_bearer_auth.assert_called_once_with("my_token")
assert tool._client is mock_client
# =====================================================================
# _execute_with_client async coverage
# =====================================================================
@pytest.mark.unit
class TestExecuteWithClient:
@staticmethod
def _make_async_client():
"""Create a mock client that supports async context manager."""
from unittest.mock import AsyncMock as AM
mock_client = MagicMock()
mock_client.__aenter__ = AM(return_value=mock_client)
mock_client.__aexit__ = AM(return_value=None)
return mock_client
def test_ping_operation(self, mcp_config):
from unittest.mock import AsyncMock as AM
tool = _make_tool(mcp_config)
mock_client = self._make_async_client()
mock_client.ping = AM(return_value="pong")
tool._client = mock_client
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(tool._execute_with_client("ping"))
assert result == "pong"
finally:
loop.close()
def test_list_tools_operation(self, mcp_config):
from unittest.mock import AsyncMock as AM
tool = _make_tool(mcp_config)
mock_client = self._make_async_client()
mock_client.list_tools = AM(return_value=[{"name": "t1", "description": "d1"}])
tool._client = mock_client
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(tool._execute_with_client("list_tools"))
assert len(result) == 1
finally:
loop.close()
def test_call_tool_operation(self, mcp_config):
from unittest.mock import AsyncMock as AM
tool = _make_tool(mcp_config)
mock_client = self._make_async_client()
mock_client.call_tool = AM(return_value={"result": "called my_action"})
tool._client = mock_client
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(
tool._execute_with_client("call_tool", "my_action", key="val")
)
assert result == {"result": "called my_action"}
finally:
loop.close()
def test_list_resources_operation(self, mcp_config):
from unittest.mock import AsyncMock as AM
tool = _make_tool(mcp_config)
mock_client = self._make_async_client()
mock_client.list_resources = AM(return_value=["r1", "r2"])
tool._client = mock_client
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(tool._execute_with_client("list_resources"))
assert result == ["r1", "r2"]
finally:
loop.close()
def test_list_prompts_operation(self, mcp_config):
from unittest.mock import AsyncMock as AM
tool = _make_tool(mcp_config)
mock_client = self._make_async_client()
mock_client.list_prompts = AM(return_value=["p1"])
tool._client = mock_client
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(tool._execute_with_client("list_prompts"))
assert result == ["p1"]
finally:
loop.close()
def test_unknown_operation_raises(self, mcp_config):
tool = _make_tool(mcp_config)
mock_client = self._make_async_client()
tool._client = mock_client
loop = asyncio.new_event_loop()
try:
with pytest.raises(Exception, match="Unknown operation"):
loop.run_until_complete(tool._execute_with_client("bogus_op"))
finally:
loop.close()
def test_no_client_raises(self, mcp_config):
tool = _make_tool(mcp_config)
tool._client = None
loop = asyncio.new_event_loop()
try:
with pytest.raises(Exception, match="not initialized"):
loop.run_until_complete(tool._execute_with_client("ping"))
finally:
loop.close()
# =====================================================================
# _run_async_operation (error mapping path)
# =====================================================================
@pytest.mark.unit
class TestRunAsyncOperationExtended:
def test_error_is_mapped_and_raised(self, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
with patch.object(tool, "_run_in_new_loop", side_effect=ConnectionRefusedError()):
with pytest.raises(Exception, match="Connection refused"):
tool._run_async_operation("ping")
def test_inside_running_loop_uses_thread_pool(self, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
# Simulate being inside a running event loop
with patch("asyncio.get_running_loop", return_value=MagicMock()), \
patch("concurrent.futures.ThreadPoolExecutor") as mock_tp:
mock_future = MagicMock()
mock_future.result.return_value = "thread_result"
mock_executor = MagicMock()
mock_executor.__enter__ = MagicMock(return_value=mock_executor)
mock_executor.__exit__ = MagicMock(return_value=False)
mock_executor.submit.return_value = mock_future
mock_tp.return_value = mock_executor
result = tool._run_async_operation("ping")
assert result == "thread_result"
# =====================================================================
# test_connection additional coverage
# =====================================================================
@pytest.mark.unit
class TestTestConnectionExtended:
def test_url_parse_exception(self):
"""Test that an unparseable URL returns failure."""
tool = _make_tool({"server_url": "://bad", "auth_type": "none"})
result = tool.test_connection()
assert result["success"] is False
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_no_tools_and_no_ping_fails(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
# ping succeeds but discover_tools returns empty
mock_run.side_effect = [
None, # ping ok
]
with patch.object(tool, "discover_tools", return_value=[]):
result = tool.test_connection()
# ping_ok is True but tools is empty, should still succeed
assert result["success"] is True
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_ping_fails_no_tools_fails(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [
Exception("ping failed"),
]
with patch.object(tool, "discover_tools", return_value=[]):
result = tool.test_connection()
assert result["success"] is False
assert "ping failed" in result["message"]
def test_oauth_connection_with_valid_tokens(self):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "oauth",
"oauth_scopes": ["read"],
})
tool.user_id = "user1"
tool._client = MagicMock()
mock_token = MagicMock()
mock_token.access_token = "valid_token"
with patch("application.agents.tools.mcp_tool.DBTokenStorage") as mock_storage_cls:
mock_storage = MagicMock()
async def fake_get_tokens():
return mock_token
mock_storage.get_tokens = fake_get_tokens
mock_storage_cls.return_value = mock_storage
with patch.object(tool, "discover_tools", return_value=[{"name": "t1", "description": "d1"}]), \
patch.object(MCPTool, "_setup_client"):
result = tool.test_connection()
assert result["success"] is True
assert result["tools_count"] == 1
def test_oauth_connection_with_expired_tokens_starts_task(self):
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "oauth",
"oauth_scopes": ["read"],
})
tool.user_id = "user1"
tool._client = MagicMock()
with patch("application.agents.tools.mcp_tool.DBTokenStorage") as mock_storage_cls:
mock_storage = MagicMock()
async def fake_get_tokens():
return None
mock_storage.get_tokens = fake_get_tokens
mock_storage_cls.return_value = mock_storage
mock_task_result = MagicMock()
mock_task_result.id = "task_abc"
with patch("application.agents.tools.mcp_tool.mcp_oauth_task") as mock_task:
mock_task.delay.return_value = mock_task_result
result = tool.test_connection()
assert result["success"] is False
assert result["requires_oauth"] is True
assert result["task_id"] == "task_abc"
def test_oauth_connection_token_validation_fails(self):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "oauth",
"oauth_scopes": ["read"],
})
tool.user_id = "user1"
tool._client = MagicMock()
mock_token = MagicMock()
mock_token.access_token = "expired_token"
with patch("application.agents.tools.mcp_tool.DBTokenStorage") as mock_storage_cls:
mock_storage = MagicMock()
async def fake_get_tokens():
return mock_token
mock_storage.get_tokens = fake_get_tokens
mock_storage_cls.return_value = mock_storage
mock_task_result = MagicMock()
mock_task_result.id = "task_retry"
with patch.object(tool, "discover_tools", side_effect=Exception("401 Unauthorized")), \
patch.object(MCPTool, "_setup_client"), \
patch("application.agents.tools.mcp_tool.mcp_oauth_task") as mock_task:
mock_task.delay.return_value = mock_task_result
result = tool.test_connection()
assert result["success"] is False
assert result["requires_oauth"] is True
# =====================================================================
# execute_action extended (format_result path)
# =====================================================================
@pytest.mark.unit
class TestExecuteActionExtended:
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_execute_formats_result(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_result = MagicMock()
text_item = MagicMock()
text_item.text = "result text"
del text_item.data
mock_result.content = [text_item]
mock_result.isError = False
mock_run.return_value = mock_result
result = tool.execute_action("test_action", query="hello")
assert result["content"][0]["type"] == "text"
assert result["isError"] is False
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_execute_auth_retry_second_attempt_fails(self, mock_run):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool({
"server_url": "https://mcp.example.com",
"auth_type": "bearer",
"auth_credentials": {"bearer_token": "tok"},
})
tool._client = MagicMock()
mock_run.side_effect = Exception("401 Unauthorized")
with patch.object(MCPTool, "_setup_client"):
with pytest.raises(Exception, match="failed after re-auth attempt"):
tool.execute_action("act")
# =====================================================================
# discover_tools extended
# =====================================================================
@pytest.mark.unit
class TestDiscoverToolsExtended:
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_discover_tools_no_client_calls_setup(self, mock_run, mcp_config):
from application.agents.tools.mcp_tool import MCPTool
tool = _make_tool(mcp_config)
tool._client = None
mock_run.return_value = [{"name": "t1", "description": "d1"}]
with patch.object(MCPTool, "_setup_client") as mock_setup:
result = tool.discover_tools()
mock_setup.assert_called_once()
assert len(result) == 1
# =====================================================================
# _test_regular_connection extended
# =====================================================================
@pytest.mark.unit
class TestRegularConnectionExtended:
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_regular_connection_message_format(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [None] # ping ok
with patch.object(tool, "discover_tools", return_value=[
{"name": "single_tool", "description": "only one"},
]):
result = tool.test_connection()
assert result["success"] is True
assert "1 tool" in result["message"]
# Singular form for 1 tool
assert "tools" not in result["message"]
@patch("application.agents.tools.mcp_tool.MCPTool._run_async_operation")
def test_regular_connection_multiple_tools(self, mock_run, mcp_config):
tool = _make_tool(mcp_config)
tool._client = MagicMock()
mock_run.side_effect = [None] # ping ok
with patch.object(tool, "discover_tools", return_value=[
{"name": "t1", "description": "d1"},
{"name": "t2", "description": "d2"},
]):
result = tool.test_connection()
assert result["success"] is True
assert "2 tools" in result["message"]
assert len(result["tools"]) == 2
# =====================================================================
# DocsGPTOAuth extended
# =====================================================================
@pytest.mark.skip(reason="OAuth class signatures changed post-PG migration (db kwarg removed); needs rewrite")
@pytest.mark.unit
class TestDocsGPTOAuthExtended:
def test_process_auth_url_success(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read", "write"],
redis_client=MagicMock(),
redirect_uri="https://example.com/callback",
task_id="task1",
db=mock_db,
user_id="user1",
)
url, state = oauth._process_auth_url(
"https://auth.example.com/authorize?state=abc123&client_id=xyz"
)
assert state == "abc123"
assert url == "https://auth.example.com/authorize?state=abc123&client_id=xyz"
def test_process_auth_url_no_state(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes="read",
redis_client=MagicMock(),
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
with pytest.raises(Exception, match="Failed to process auth URL"):
oauth._process_auth_url("https://auth.example.com/authorize?client_id=xyz")
def test_redirect_handler_stores_in_redis(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
mock_redis = MagicMock()
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=mock_redis,
redirect_uri="https://example.com/callback",
task_id="task1",
db=mock_db,
user_id="user1",
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
oauth.redirect_handler(
"https://auth.example.com/authorize?state=mystate&code=123"
)
)
finally:
loop.close()
assert oauth.auth_url == "https://auth.example.com/authorize?state=mystate&code=123"
assert oauth.extracted_state == "mystate"
# Redis setex should have been called for auth_url and status
assert mock_redis.setex.call_count >= 2
def test_redirect_handler_no_redis(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=None,
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(
oauth.redirect_handler(
"https://auth.example.com/authorize?state=s1"
)
)
finally:
loop.close()
assert oauth.extracted_state == "s1"
def test_callback_handler_no_redis_raises(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=None,
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
loop = asyncio.new_event_loop()
try:
with pytest.raises(Exception, match="Redis client or state not configured"):
loop.run_until_complete(oauth.callback_handler())
finally:
loop.close()
def test_callback_handler_receives_code(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
mock_redis = MagicMock()
# First get returns the code
mock_redis.get.return_value = b"auth_code_123"
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=mock_redis,
redirect_uri="https://example.com/callback",
task_id="task1",
db=mock_db,
user_id="user1",
)
oauth.extracted_state = "mystate"
oauth.auth_url = "https://auth.example.com/authorize"
loop = asyncio.new_event_loop()
try:
code, state = loop.run_until_complete(oauth.callback_handler())
assert code == "auth_code_123"
assert state == "mystate"
finally:
loop.close()
def test_callback_handler_receives_error(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
mock_redis = MagicMock()
# First get for code returns None, second get for error returns error
mock_redis.get.side_effect = [None, b"access_denied"]
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes=["read"],
redis_client=mock_redis,
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
oauth.extracted_state = "mystate"
oauth.auth_url = "https://auth.example.com/authorize"
loop = asyncio.new_event_loop()
try:
with pytest.raises(Exception, match="OAuth error: access_denied"):
loop.run_until_complete(oauth.callback_handler())
finally:
loop.close()
def test_init_scopes_as_string(self):
from application.agents.tools.mcp_tool import DocsGPTOAuth
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
oauth = DocsGPTOAuth(
mcp_url="https://mcp.example.com/api",
scopes="read write",
redis_client=MagicMock(),
redirect_uri="https://example.com/callback",
db=mock_db,
user_id="user1",
)
assert oauth.server_base_url == "https://mcp.example.com"
# =====================================================================
# DBTokenStorage extended
# =====================================================================
@pytest.mark.skip(reason="DBTokenStorage signature changed post-PG migration; needs repo-based rewrite")
@pytest.mark.unit
class TestDBTokenStorageExtended:
def test_get_tokens_with_valid_data(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = {
"tokens": {
"access_token": "at_123",
"token_type": "bearer",
}
}
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_tokens())
assert result is not None
assert result.access_token == "at_123"
finally:
loop.close()
def test_get_tokens_with_invalid_data(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = {
"tokens": {"bad_field": "bad_value"}
}
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_tokens())
assert result is None
finally:
loop.close()
def test_set_tokens(self):
from application.agents.tools.mcp_tool import DBTokenStorage
from mcp.shared.auth import OAuthToken
mock_db = MagicMock()
mock_collection = MagicMock()
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
token = OAuthToken(access_token="new_token", token_type="bearer")
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(storage.set_tokens(token))
mock_collection.update_one.assert_called_once()
finally:
loop.close()
def test_get_client_info_none(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = None
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_client_info())
assert result is None
finally:
loop.close()
def test_get_client_info_no_client_info_key(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = {"tokens": {}}
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_client_info())
assert result is None
finally:
loop.close()
def test_get_client_info_with_valid_data(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = {
"client_info": {
"client_id": "cid123",
"redirect_uris": ["https://example.com/callback"],
}
}
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_client_info())
assert result is not None
assert result.client_id == "cid123"
finally:
loop.close()
def test_get_client_info_redirect_uri_mismatch(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = {
"client_info": {
"client_id": "cid123",
"redirect_uris": ["https://old.example.com/callback"],
}
}
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
expected_redirect_uri="https://new.example.com/callback",
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_client_info())
assert result is None
mock_collection.update_one.assert_called_once()
finally:
loop.close()
def test_get_client_info_invalid_data(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_collection.find_one.return_value = {
"client_info": {"invalid_key": "value"}
}
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
loop = asyncio.new_event_loop()
try:
result = loop.run_until_complete(storage.get_client_info())
assert result is None
finally:
loop.close()
def test_set_client_info(self):
from application.agents.tools.mcp_tool import DBTokenStorage
from mcp.shared.auth import OAuthClientInformationFull
mock_db = MagicMock()
mock_collection = MagicMock()
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
client_info = OAuthClientInformationFull(
client_id="cid123",
redirect_uris=["https://example.com/callback"],
)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(storage.set_client_info(client_info))
mock_collection.update_one.assert_called_once()
finally:
loop.close()
def test_clear_all(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
mock_collection = MagicMock()
mock_db.__getitem__ = MagicMock(return_value=mock_collection)
loop = asyncio.new_event_loop()
try:
loop.run_until_complete(DBTokenStorage.clear_all(mock_db))
mock_collection.delete_many.assert_called_once_with({})
finally:
loop.close()
def test_serialize_client_info_without_redirect_uris(self):
from application.agents.tools.mcp_tool import DBTokenStorage
mock_db = MagicMock()
storage = DBTokenStorage(
server_url="https://mcp.example.com",
user_id="user1",
db_client=mock_db,
)
info = {"client_name": "test"}
result = storage._serialize_client_info(info)
assert result == {"client_name": "test"}
# =====================================================================
# MCPOAuthManager extended
# =====================================================================
@pytest.mark.unit
class TestMCPOAuthManagerExtended:
def test_handle_callback_redis_setex_for_state(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
manager = MCPOAuthManager(mock_redis)
result = manager.handle_oauth_callback(state="s1", code="c1")
assert result is True
# Should call setex for code and state
assert mock_redis.setex.call_count == 2
def test_handle_callback_with_error_stores_error(self):
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
manager = MCPOAuthManager(mock_redis)
result = manager.handle_oauth_callback(
state="s1", code="", error="invalid_scope"
)
assert result is False
# Should store error in redis
mock_redis.setex.assert_called()
def test_get_oauth_status_returns_not_found_on_redis_error(self):
"""A failure inside ``xrevrange`` (Redis down, network blip) is
swallowed — the manager returns ``not_found`` so the caller
can present a clean "OAuth failed, try again" message rather
than a 500.
"""
from application.agents.tools.mcp_tool import MCPOAuthManager
mock_redis = MagicMock()
mock_redis.xrevrange.side_effect = Exception("Redis went away")
manager = MCPOAuthManager(mock_redis)
result = manager.get_oauth_status("task123", "alice")
assert result["status"] == "not_found"
# =====================================================================
# get_actions_metadata extended
# =====================================================================
@pytest.mark.unit
class TestGetActionsMetadataExtended:
def test_tools_with_schema_key(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [
{
"name": "schema_tool",
"description": "Uses schema key",
"schema": {
"type": "object",
"properties": {"q": {"type": "string"}},
"required": ["q"],
},
}
]
meta = tool.get_actions_metadata()
assert len(meta) == 1
assert "q" in meta[0]["parameters"]["properties"]
def test_tools_with_input_schema_key(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [
{
"name": "is_tool",
"description": "Uses input_schema key",
"input_schema": {
"type": "object",
"properties": {"x": {"type": "number"}},
},
}
]
meta = tool.get_actions_metadata()
assert "x" in meta[0]["parameters"]["properties"]
def test_multiple_tools(self, mcp_config):
tool = _make_tool(mcp_config)
tool.available_tools = [
{"name": "a", "description": "da"},
{"name": "b", "description": "db", "inputSchema": {
"type": "object",
"properties": {"p": {"type": "string"}},
}},
]
meta = tool.get_actions_metadata()
assert len(meta) == 2
assert meta[0]["name"] == "a"
assert meta[0]["parameters"]["properties"] == {}
assert "p" in meta[1]["parameters"]["properties"]
# =====================================================================
# Coverage gap tests (lines 207-210, 288-293, 346-347, 416-417, 620)
# =====================================================================
@pytest.mark.unit
class TestMCPToolGaps:
def test_create_transport_stdio_raises(self, mcp_config):
"""Cover line 199-200: stdio transport raises ValueError."""
mcp_config["transport_type"] = "stdio"
tool = _make_tool(mcp_config)
with pytest.raises(ValueError, match="STDIO transport is disabled"):
tool._create_transport()
def test_run_in_new_loop(self, mcp_config):
"""Cover lines 288-293: _run_in_new_loop creates a new event loop."""
tool = _make_tool(mcp_config)
async def dummy_operation(*args, **kwargs):
return "result"
tool._execute_with_client = dummy_operation
result = tool._run_in_new_loop("test_op")
assert result == "result"
def test_execute_action_auth_error_retry(self, mcp_config):
"""Cover lines 346-347: auth error detection in execute_action."""
tool = _make_tool(mcp_config)
tool.available_tools = [{"name": "test_action"}]
tool.auth_type = "bearer"
call_count = 0
def mock_run_async(operation, action_name, **kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
raise Exception("401 unauthorized")
return MagicMock(content=[MagicMock(text="success")])
tool._run_async_operation = mock_run_async
tool._setup_client = MagicMock()
result = tool.execute_action("test_action")
assert "success" in str(result)
def test_test_connection_invalid_url(self, mcp_config):
"""Cover lines 416-417: test_connection with invalid URL."""
tool = _make_tool(mcp_config)
tool.server_url = "not a url at all"
tool._client = None
result = tool.test_connection()
assert result["success"] is False
def test_get_config_requirements_has_username(self, mcp_config):
"""Cover line 620: config requirements include username field."""
tool = _make_tool(mcp_config)
config = tool.get_config_requirements()
assert "username" in config
assert config["username"]["description"] == "Username for basic authentication"
assert config["username"]["depends_on"] == {"auth_type": "basic"}
# ---------------------------------------------------------------------------
# Coverage — additional uncovered lines: 207-210, 288-293, 346-347, 416-417, 620
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMCPToolTransportCreation:
def test_unknown_transport_defaults_to_http(self, mcp_config):
"""Cover line 207-210 and 212: unknown transport type defaults to StreamableHttpTransport."""
mcp_config["transport_type"] = "unknown_protocol"
tool = _make_tool(mcp_config)
transport = tool._create_transport()
# Should be StreamableHttpTransport (the default fallback)
assert transport is not None
def test_sse_transport_creation(self, mcp_config):
"""Cover lines 201-203: SSE transport creation."""
mcp_config["transport_type"] = "sse"
tool = _make_tool(mcp_config)
transport = tool._create_transport()
assert transport is not None
def test_stdio_transport_raises(self, mcp_config):
"""Cover line 200: stdio transport is disabled."""
mcp_config["transport_type"] = "stdio"
tool = _make_tool(mcp_config)
with pytest.raises(ValueError, match="STDIO transport is disabled"):
tool._create_transport()
@pytest.mark.unit
class TestMCPToolRunAsyncOperation:
def test_run_async_operation_maps_error(self, mcp_config):
"""Cover lines 288-293: _run_async_operation exception mapped."""
tool = _make_tool(mcp_config)
async def bad_execute(op, *a, **kw):
raise ConnectionRefusedError("refused")
tool._execute_with_client = bad_execute
tool._client = MagicMock()
with pytest.raises(Exception, match="Connection refused"):
tool._run_async_operation("ping")
@pytest.mark.unit
class TestMCPToolExecuteActionAuth:
def test_execute_action_oauth_auth_error(self, mcp_config):
"""Cover lines 346-347: OAuth auth error raises specific message."""
mcp_config["auth_type"] = "oauth"
tool = _make_tool(mcp_config)
def bad_run(*a, **kw):
raise Exception("401 Unauthorized")
tool._run_async_operation = bad_run
tool._client = MagicMock()
with pytest.raises(Exception, match="OAuth session expired"):
tool.execute_action("test_action")
@pytest.mark.unit
class TestMCPToolTestConnectionInvalidScheme:
def test_test_connection_ftp_scheme_invalid(self, mcp_config):
"""Cover lines 416-417: test_connection with invalid URL scheme."""
tool = _make_tool(mcp_config)
tool.server_url = "ftp://invalid.example.com"
tool._client = None
result = tool.test_connection()
assert result["success"] is False
assert "scheme" in result["message"].lower() or "Invalid" in result["message"]
@pytest.mark.unit
class TestMCPToolConfigRequirements:
def test_get_config_requirements_has_password(self, mcp_config):
"""Cover line 620+: config requirements include password field."""
tool = _make_tool(mcp_config)
config = tool.get_config_requirements()
assert "password" in config
assert config["password"]["secret"] is True
assert config["password"]["depends_on"] == {"auth_type": "basic"}
# ---------------------------------------------------------------------------
# Additional coverage for mcp_tool.py
# Lines: 207-210 (stdio transport), 288-293 (_map_error + _run_in_new_loop),
# 346-347 (execute_action error handling), 620 (config requirements password)
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestMCPToolStdioTransport:
"""Cover line 199-200: stdio transport raises ValueError."""
def test_create_stdio_transport_raises(self, mcp_config):
mcp_config["transport_type"] = "stdio"
tool = _make_tool(mcp_config)
tool.transport_type = "stdio"
with pytest.raises(ValueError, match="STDIO transport is disabled"):
tool._create_transport()
def test_create_sse_transport(self, mcp_config):
"""Cover line 201-203: SSE transport."""
mcp_config["transport_type"] = "sse"
tool = _make_tool(mcp_config)
tool.transport_type = "sse"
transport = tool._create_transport()
# Should return an SSETransport
assert transport is not None
def test_create_unknown_transport_defaults_to_http(self, mcp_config):
"""Cover line 211-212: unknown transport defaults to StreamableHttpTransport."""
tool = _make_tool(mcp_config)
tool.transport_type = "unknown_transport"
transport = tool._create_transport()
assert transport is not None
@pytest.mark.unit
class TestMCPToolRunInNewLoop:
"""Cover lines 288-293: _run_in_new_loop and _map_error."""
def test_run_in_new_loop(self, mcp_config):
tool = _make_tool(mcp_config)
async def mock_execute(*args, **kwargs):
return "loop_result"
tool._execute_with_client = mock_execute
result = tool._run_in_new_loop("list_tools")
assert result == "loop_result"
def test_map_error_timeout(self, mcp_config):
tool = _make_tool(mcp_config)
from asyncio import TimeoutError as AsyncTimeout
err = tool._map_error("test_op", AsyncTimeout("timed out"))
assert isinstance(err, Exception)
assert "timeout" in str(err).lower() or "timed out" in str(err).lower()
def test_map_error_generic(self, mcp_config):
tool = _make_tool(mcp_config)
err = tool._map_error("test_op", RuntimeError("something broke"))
assert isinstance(err, Exception)
@pytest.mark.unit
class TestMCPToolExecuteActionErrorHandling:
"""Cover lines 346-347: execute_action non-auth error."""
def test_execute_action_generic_error(self, mcp_config):
tool = _make_tool(mcp_config)
tool._run_async_operation = MagicMock(
side_effect=RuntimeError("generic failure")
)
with pytest.raises(Exception, match="Failed to execute action"):
tool.execute_action("some_action", key="value")