mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-05-01 07:13:00 +00:00
Fixes #5116. MCPToolResolver._resolve_native() could raise UnboundLocalError when asyncio.run() raised a RuntimeError that didn't match the 'cancel scope' or 'task' conditions — the exception was silently swallowed leaving tools_list unbound. Changes: - Initialize tools_list to an empty list before the try block - Add early return with warning log when tools_list is empty (matching the existing behavior in _resolve_external) This covers three scenarios: 1. MCP server returns no tools 2. tool_filter removes all tools 3. RuntimeError swallowed without assigning tools_list Co-Authored-By: João <joao@crewai.com>
619 lines
21 KiB
Python
619 lines
21 KiB
Python
"""Tests for AMP MCP config fetching and tool resolution."""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from crewai.agent.core import Agent
|
|
from crewai.mcp.config import MCPServerHTTP, MCPServerSSE
|
|
from crewai.mcp.tool_resolver import MCPToolResolver
|
|
from crewai.tools.base_tool import BaseTool
|
|
|
|
|
|
@pytest.fixture
|
|
def agent():
|
|
return Agent(
|
|
role="Test Agent",
|
|
goal="Test goal",
|
|
backstory="Test backstory",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def resolver(agent):
|
|
return MCPToolResolver(agent=agent, logger=agent._logger)
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_tool_definitions():
|
|
return [
|
|
{
|
|
"name": "search",
|
|
"description": "Search tool",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "Search query"}
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
{
|
|
"name": "create_page",
|
|
"description": "Create a page",
|
|
"inputSchema": {},
|
|
},
|
|
]
|
|
|
|
|
|
class TestBuildMCPConfigFromDict:
|
|
def test_builds_http_config(self):
|
|
config_dict = {
|
|
"type": "http",
|
|
"url": "https://mcp.example.com/api",
|
|
"headers": {"Authorization": "Bearer token123"},
|
|
"streamable": True,
|
|
"cache_tools_list": False,
|
|
}
|
|
|
|
result = MCPToolResolver._build_mcp_config_from_dict(config_dict)
|
|
|
|
assert isinstance(result, MCPServerHTTP)
|
|
assert result.url == "https://mcp.example.com/api"
|
|
assert result.headers == {"Authorization": "Bearer token123"}
|
|
assert result.streamable is True
|
|
assert result.cache_tools_list is False
|
|
|
|
def test_builds_sse_config(self):
|
|
config_dict = {
|
|
"type": "sse",
|
|
"url": "https://mcp.example.com/sse",
|
|
"headers": {"Authorization": "Bearer token123"},
|
|
"cache_tools_list": True,
|
|
}
|
|
|
|
result = MCPToolResolver._build_mcp_config_from_dict(config_dict)
|
|
|
|
assert isinstance(result, MCPServerSSE)
|
|
assert result.url == "https://mcp.example.com/sse"
|
|
assert result.headers == {"Authorization": "Bearer token123"}
|
|
assert result.cache_tools_list is True
|
|
|
|
def test_defaults_to_http(self):
|
|
config_dict = {
|
|
"url": "https://mcp.example.com/api",
|
|
}
|
|
|
|
result = MCPToolResolver._build_mcp_config_from_dict(config_dict)
|
|
|
|
assert isinstance(result, MCPServerHTTP)
|
|
assert result.streamable is True
|
|
|
|
def test_http_defaults(self):
|
|
config_dict = {
|
|
"type": "http",
|
|
"url": "https://mcp.example.com/api",
|
|
}
|
|
|
|
result = MCPToolResolver._build_mcp_config_from_dict(config_dict)
|
|
|
|
assert result.headers is None
|
|
assert result.streamable is True
|
|
assert result.cache_tools_list is False
|
|
|
|
|
|
class TestFetchAmpMCPConfigs:
|
|
@patch("crewai.cli.plus_api.PlusAPI")
|
|
@patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key")
|
|
def test_fetches_configs_successfully(self, mock_get_token, mock_plus_api_class, resolver):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"configs": {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
"headers": {"Authorization": "Bearer notion-token"},
|
|
},
|
|
"github": {
|
|
"type": "http",
|
|
"url": "https://mcp.github.com/api",
|
|
"headers": {"Authorization": "Bearer gh-token"},
|
|
},
|
|
},
|
|
}
|
|
mock_plus_api = MagicMock()
|
|
mock_plus_api.get_mcp_configs.return_value = mock_response
|
|
mock_plus_api_class.return_value = mock_plus_api
|
|
|
|
result = resolver._fetch_amp_mcp_configs(["notion", "github"])
|
|
|
|
assert "notion" in result
|
|
assert "github" in result
|
|
assert result["notion"]["url"] == "https://mcp.notion.so/sse"
|
|
mock_plus_api_class.assert_called_once_with(api_key="test-api-key")
|
|
mock_plus_api.get_mcp_configs.assert_called_once_with(["notion", "github"])
|
|
|
|
@patch("crewai.cli.plus_api.PlusAPI")
|
|
@patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key")
|
|
def test_omits_missing_slugs(self, mock_get_token, mock_plus_api_class, resolver):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 200
|
|
mock_response.json.return_value = {
|
|
"configs": {"notion": {"type": "sse", "url": "https://mcp.notion.so/sse"}},
|
|
}
|
|
mock_plus_api = MagicMock()
|
|
mock_plus_api.get_mcp_configs.return_value = mock_response
|
|
mock_plus_api_class.return_value = mock_plus_api
|
|
|
|
result = resolver._fetch_amp_mcp_configs(["notion", "missing-server"])
|
|
|
|
assert "notion" in result
|
|
assert "missing-server" not in result
|
|
|
|
@patch("crewai.cli.plus_api.PlusAPI")
|
|
@patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key")
|
|
def test_returns_empty_on_http_error(self, mock_get_token, mock_plus_api_class, resolver):
|
|
mock_response = MagicMock()
|
|
mock_response.status_code = 500
|
|
mock_plus_api = MagicMock()
|
|
mock_plus_api.get_mcp_configs.return_value = mock_response
|
|
mock_plus_api_class.return_value = mock_plus_api
|
|
|
|
result = resolver._fetch_amp_mcp_configs(["notion"])
|
|
|
|
assert result == {}
|
|
|
|
@patch("crewai.cli.plus_api.PlusAPI")
|
|
@patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", return_value="test-api-key")
|
|
def test_returns_empty_on_network_error(self, mock_get_token, mock_plus_api_class, resolver):
|
|
import httpx
|
|
|
|
mock_plus_api = MagicMock()
|
|
mock_plus_api.get_mcp_configs.side_effect = httpx.ConnectError("Connection refused")
|
|
mock_plus_api_class.return_value = mock_plus_api
|
|
|
|
result = resolver._fetch_amp_mcp_configs(["notion"])
|
|
|
|
assert result == {}
|
|
|
|
@patch("crewai_tools.tools.crewai_platform_tools.misc.get_platform_integration_token", side_effect=Exception("No token"))
|
|
def test_returns_empty_when_no_token(self, mock_get_token, resolver):
|
|
result = resolver._fetch_amp_mcp_configs(["notion"])
|
|
|
|
assert result == {}
|
|
|
|
|
|
class TestParseAmpRef:
|
|
def test_bare_slug(self):
|
|
slug, tool = MCPToolResolver._parse_amp_ref("notion")
|
|
assert slug == "notion"
|
|
assert tool is None
|
|
|
|
def test_bare_slug_with_tool(self):
|
|
slug, tool = MCPToolResolver._parse_amp_ref("notion#search")
|
|
assert slug == "notion"
|
|
assert tool == "search"
|
|
|
|
def test_bare_slug_with_empty_tool(self):
|
|
slug, tool = MCPToolResolver._parse_amp_ref("notion#")
|
|
assert slug == "notion"
|
|
assert tool is None
|
|
|
|
def test_legacy_prefix_slug(self):
|
|
slug, tool = MCPToolResolver._parse_amp_ref("crewai-amp:notion")
|
|
assert slug == "notion"
|
|
assert tool is None
|
|
|
|
def test_legacy_prefix_with_tool(self):
|
|
slug, tool = MCPToolResolver._parse_amp_ref("crewai-amp:notion#search")
|
|
assert slug == "notion"
|
|
assert tool == "search"
|
|
|
|
|
|
class TestGetMCPToolsAmpIntegration:
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
def test_single_request_for_multiple_amp_refs(
|
|
self, mock_fetch, mock_client_class, agent, mock_tool_definitions
|
|
):
|
|
mock_fetch.return_value = {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
},
|
|
"github": {
|
|
"type": "http",
|
|
"url": "https://mcp.github.com/api",
|
|
"headers": {"Authorization": "Bearer gh-token"},
|
|
"streamable": True,
|
|
},
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
tools = agent.get_mcp_tools(["notion", "github"])
|
|
|
|
mock_fetch.assert_called_once_with(["notion", "github"])
|
|
assert len(tools) == 4 # 2 tools per server
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
def test_tool_filter_with_hash_syntax(
|
|
self, mock_fetch, mock_client_class, agent, mock_tool_definitions
|
|
):
|
|
mock_fetch.return_value = {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
},
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
tools = agent.get_mcp_tools(["notion#search"])
|
|
|
|
mock_fetch.assert_called_once_with(["notion"])
|
|
assert len(tools) == 1
|
|
assert tools[0].name == "mcp_notion_so_sse_search"
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
def test_tool_filter_with_hyphenated_hash_syntax(
|
|
self, mock_fetch, mock_client_class, agent
|
|
):
|
|
"""notion#get-page must match the tool whose sanitized name is get_page."""
|
|
mock_fetch.return_value = {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
},
|
|
}
|
|
|
|
hyphenated_tool_definitions = [
|
|
{
|
|
"name": "get_page",
|
|
"original_name": "get-page",
|
|
"description": "Get a page",
|
|
"inputSchema": {},
|
|
},
|
|
{
|
|
"name": "search",
|
|
"original_name": "search",
|
|
"description": "Search tool",
|
|
"inputSchema": {
|
|
"type": "object",
|
|
"properties": {
|
|
"query": {"type": "string", "description": "Search query"}
|
|
},
|
|
"required": ["query"],
|
|
},
|
|
},
|
|
]
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=hyphenated_tool_definitions)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
tools = agent.get_mcp_tools(["notion#get-page"])
|
|
|
|
mock_fetch.assert_called_once_with(["notion"])
|
|
assert len(tools) == 1
|
|
assert tools[0].name.endswith("_get_page")
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
def test_deduplicates_slugs(
|
|
self, mock_fetch, mock_client_class, agent, mock_tool_definitions
|
|
):
|
|
mock_fetch.return_value = {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
},
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
tools = agent.get_mcp_tools(["notion#search", "notion#create_page"])
|
|
|
|
mock_fetch.assert_called_once_with(["notion"])
|
|
assert len(tools) == 2
|
|
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
def test_skips_missing_configs_gracefully(self, mock_fetch, agent):
|
|
mock_fetch.return_value = {}
|
|
|
|
tools = agent.get_mcp_tools(["missing-server"])
|
|
|
|
assert tools == []
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
def test_legacy_crewai_amp_prefix_still_works(
|
|
self, mock_fetch, mock_client_class, agent, mock_tool_definitions
|
|
):
|
|
mock_fetch.return_value = {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
"headers": {"Authorization": "Bearer token"},
|
|
},
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
tools = agent.get_mcp_tools(["crewai-amp:notion"])
|
|
|
|
mock_fetch.assert_called_once_with(["notion"])
|
|
assert len(tools) == 2
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
@patch.object(MCPToolResolver, "_fetch_amp_mcp_configs")
|
|
@patch.object(MCPToolResolver, "_resolve_external")
|
|
def test_non_amp_items_unaffected(
|
|
self,
|
|
mock_external,
|
|
mock_fetch,
|
|
mock_client_class,
|
|
agent,
|
|
mock_tool_definitions,
|
|
):
|
|
mock_fetch.return_value = {
|
|
"notion": {
|
|
"type": "sse",
|
|
"url": "https://mcp.notion.so/sse",
|
|
},
|
|
}
|
|
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=mock_tool_definitions)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
mock_external_tool = MagicMock(spec=BaseTool)
|
|
mock_external.return_value = [mock_external_tool]
|
|
|
|
http_config = MCPServerHTTP(
|
|
url="https://other.mcp.com/api",
|
|
headers={"Authorization": "Bearer other"},
|
|
)
|
|
|
|
tools = agent.get_mcp_tools(
|
|
[
|
|
"notion",
|
|
"https://external.mcp.com/api",
|
|
http_config,
|
|
]
|
|
)
|
|
|
|
mock_fetch.assert_called_once_with(["notion"])
|
|
mock_external.assert_called_once_with("https://external.mcp.com/api")
|
|
# 2 from notion + 1 from external + 2 from http_config
|
|
assert len(tools) == 5
|
|
|
|
|
|
class TestResolveExternalToolFilter:
|
|
"""Tests for _resolve_external with #tool-name filtering."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
return Agent(
|
|
role="Test Agent",
|
|
goal="Test goal",
|
|
backstory="Test backstory",
|
|
)
|
|
|
|
@pytest.fixture
|
|
def resolver(self, agent):
|
|
return MCPToolResolver(agent=agent, logger=agent._logger)
|
|
|
|
@patch.object(MCPToolResolver, "_get_mcp_tool_schemas")
|
|
def test_filters_hyphenated_tool_name(self, mock_schemas, resolver):
|
|
"""https://...#get-page must match the sanitized key get_page in schemas."""
|
|
mock_schemas.return_value = {
|
|
"get_page": {
|
|
"description": "Get a page",
|
|
"args_schema": None,
|
|
},
|
|
"search": {
|
|
"description": "Search tool",
|
|
"args_schema": None,
|
|
},
|
|
}
|
|
|
|
tools = resolver._resolve_external("https://mcp.example.com/api#get-page")
|
|
|
|
assert len(tools) == 1
|
|
assert "get_page" in tools[0].name
|
|
|
|
@patch.object(MCPToolResolver, "_get_mcp_tool_schemas")
|
|
def test_filters_underscored_tool_name(self, mock_schemas, resolver):
|
|
"""https://...#get_page must also match the sanitized key get_page."""
|
|
mock_schemas.return_value = {
|
|
"get_page": {
|
|
"description": "Get a page",
|
|
"args_schema": None,
|
|
},
|
|
"search": {
|
|
"description": "Search tool",
|
|
"args_schema": None,
|
|
},
|
|
}
|
|
|
|
tools = resolver._resolve_external("https://mcp.example.com/api#get_page")
|
|
|
|
assert len(tools) == 1
|
|
assert "get_page" in tools[0].name
|
|
|
|
@patch.object(MCPToolResolver, "_get_mcp_tool_schemas")
|
|
def test_returns_all_tools_without_hash(self, mock_schemas, resolver):
|
|
mock_schemas.return_value = {
|
|
"get_page": {
|
|
"description": "Get a page",
|
|
"args_schema": None,
|
|
},
|
|
"search": {
|
|
"description": "Search tool",
|
|
"args_schema": None,
|
|
},
|
|
}
|
|
|
|
tools = resolver._resolve_external("https://mcp.example.com/api")
|
|
|
|
assert len(tools) == 2
|
|
|
|
@patch.object(MCPToolResolver, "_get_mcp_tool_schemas")
|
|
def test_returns_empty_for_nonexistent_tool(self, mock_schemas, resolver):
|
|
mock_schemas.return_value = {
|
|
"search": {
|
|
"description": "Search tool",
|
|
"args_schema": None,
|
|
},
|
|
}
|
|
|
|
tools = resolver._resolve_external("https://mcp.example.com/api#nonexistent")
|
|
|
|
assert len(tools) == 0
|
|
|
|
|
|
class TestResolveNativeEmptyTools:
|
|
"""Tests for _resolve_native when MCP server returns no tools or tools_list is unbound."""
|
|
|
|
@pytest.fixture
|
|
def agent(self):
|
|
return Agent(
|
|
role="Test Agent",
|
|
goal="Test goal",
|
|
backstory="Test backstory",
|
|
)
|
|
|
|
@pytest.fixture
|
|
def mock_logger(self):
|
|
logger = MagicMock()
|
|
logger.log = MagicMock()
|
|
return logger
|
|
|
|
@pytest.fixture
|
|
def resolver(self, agent, mock_logger):
|
|
return MCPToolResolver(agent=agent, logger=mock_logger)
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
def test_returns_empty_and_logs_warning_when_server_returns_no_tools(
|
|
self, mock_client_class, resolver
|
|
):
|
|
"""When the MCP server returns an empty tool list, _resolve_native should
|
|
return empty tools and log a warning instead of raising an error."""
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(return_value=[])
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
http_config = MCPServerHTTP(
|
|
url="https://empty-server.example.com/api",
|
|
headers={"Authorization": "Bearer token"},
|
|
)
|
|
|
|
tools, clients = resolver._resolve_native(http_config)
|
|
|
|
assert tools == []
|
|
assert clients == []
|
|
resolver._logger.log.assert_any_call(
|
|
"warning",
|
|
"No tools discovered from MCP server: empty-server_example_com_api",
|
|
)
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
def test_returns_empty_and_logs_warning_when_tool_filter_removes_all(
|
|
self, mock_client_class, resolver
|
|
):
|
|
"""When the tool_filter removes all tools, _resolve_native should
|
|
return empty tools and log a warning."""
|
|
mock_client = AsyncMock()
|
|
mock_client.list_tools = AsyncMock(
|
|
return_value=[
|
|
{"name": "search", "description": "Search tool", "inputSchema": {}},
|
|
]
|
|
)
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
mock_client_class.return_value = mock_client
|
|
|
|
http_config = MCPServerHTTP(
|
|
url="https://filtered-server.example.com/api",
|
|
headers={"Authorization": "Bearer token"},
|
|
tool_filter=lambda tool: False, # reject all tools
|
|
)
|
|
|
|
tools, clients = resolver._resolve_native(http_config)
|
|
|
|
assert tools == []
|
|
assert clients == []
|
|
resolver._logger.log.assert_any_call(
|
|
"warning",
|
|
"No tools discovered from MCP server: filtered-server_example_com_api",
|
|
)
|
|
|
|
@patch("crewai.mcp.tool_resolver.MCPClient")
|
|
def test_no_unbound_local_error_when_runtime_error_swallowed(
|
|
self, mock_client_class, resolver
|
|
):
|
|
"""When asyncio.run raises a RuntimeError that doesn't match the
|
|
'cancel scope' / 'task' conditions, tools_list must not be unbound.
|
|
Previously this caused UnboundLocalError on line 412."""
|
|
mock_client = AsyncMock()
|
|
mock_client.connected = False
|
|
mock_client.connect = AsyncMock()
|
|
mock_client.disconnect = AsyncMock()
|
|
# Make list_tools raise a RuntimeError that does NOT match the conditions
|
|
mock_client.list_tools = AsyncMock(
|
|
side_effect=RuntimeError("some unrelated runtime error")
|
|
)
|
|
mock_client_class.return_value = mock_client
|
|
|
|
http_config = MCPServerHTTP(
|
|
url="https://broken-server.example.com/api",
|
|
headers={"Authorization": "Bearer token"},
|
|
)
|
|
|
|
tools, clients = resolver._resolve_native(http_config)
|
|
|
|
assert tools == []
|
|
assert clients == []
|
|
resolver._logger.log.assert_any_call(
|
|
"warning",
|
|
"No tools discovered from MCP server: broken-server_example_com_api",
|
|
)
|