hud-python 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/__init__.py +22 -22
- hud/agents/__init__.py +13 -15
- hud/agents/base.py +599 -599
- hud/agents/claude.py +373 -373
- hud/agents/langchain.py +261 -250
- hud/agents/misc/__init__.py +7 -7
- hud/agents/misc/response_agent.py +82 -80
- hud/agents/openai.py +352 -352
- hud/agents/openai_chat_generic.py +154 -154
- hud/agents/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -742
- hud/agents/tests/test_claude.py +324 -324
- hud/agents/tests/test_client.py +363 -363
- hud/agents/tests/test_openai.py +237 -237
- hud/cli/__init__.py +617 -617
- hud/cli/__main__.py +8 -8
- hud/cli/analyze.py +371 -371
- hud/cli/analyze_metadata.py +230 -230
- hud/cli/build.py +498 -427
- hud/cli/clone.py +185 -185
- hud/cli/cursor.py +92 -92
- hud/cli/debug.py +392 -392
- hud/cli/docker_utils.py +83 -83
- hud/cli/init.py +280 -281
- hud/cli/interactive.py +353 -353
- hud/cli/mcp_server.py +764 -756
- hud/cli/pull.py +330 -336
- hud/cli/push.py +404 -370
- hud/cli/remote_runner.py +311 -311
- hud/cli/runner.py +160 -160
- hud/cli/tests/__init__.py +3 -3
- hud/cli/tests/test_analyze.py +284 -284
- hud/cli/tests/test_cli_init.py +265 -265
- hud/cli/tests/test_cli_main.py +27 -27
- hud/cli/tests/test_clone.py +142 -142
- hud/cli/tests/test_cursor.py +253 -253
- hud/cli/tests/test_debug.py +453 -453
- hud/cli/tests/test_mcp_server.py +139 -139
- hud/cli/tests/test_utils.py +388 -388
- hud/cli/utils.py +263 -263
- hud/clients/README.md +143 -143
- hud/clients/__init__.py +16 -16
- hud/clients/base.py +378 -379
- hud/clients/fastmcp.py +222 -222
- hud/clients/mcp_use.py +298 -278
- hud/clients/tests/__init__.py +1 -1
- hud/clients/tests/test_client_integration.py +111 -111
- hud/clients/tests/test_fastmcp.py +342 -342
- hud/clients/tests/test_protocol.py +188 -188
- hud/clients/utils/__init__.py +1 -1
- hud/clients/utils/retry_transport.py +160 -160
- hud/datasets.py +327 -322
- hud/misc/__init__.py +1 -1
- hud/misc/claude_plays_pokemon.py +292 -292
- hud/otel/__init__.py +35 -35
- hud/otel/collector.py +142 -142
- hud/otel/config.py +164 -164
- hud/otel/context.py +536 -536
- hud/otel/exporters.py +366 -366
- hud/otel/instrumentation.py +97 -97
- hud/otel/processors.py +118 -118
- hud/otel/tests/__init__.py +1 -1
- hud/otel/tests/test_processors.py +197 -197
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -114
- hud/server/helper/__init__.py +5 -5
- hud/server/low_level.py +132 -132
- hud/server/server.py +170 -166
- hud/server/tests/__init__.py +3 -3
- hud/settings.py +73 -73
- hud/shared/__init__.py +5 -5
- hud/shared/exceptions.py +180 -180
- hud/shared/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -157
- hud/shared/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -25
- hud/telemetry/instrument.py +379 -379
- hud/telemetry/job.py +309 -309
- hud/telemetry/replay.py +74 -74
- hud/telemetry/trace.py +83 -83
- hud/tools/__init__.py +33 -33
- hud/tools/base.py +365 -365
- hud/tools/bash.py +161 -161
- hud/tools/computer/__init__.py +15 -15
- hud/tools/computer/anthropic.py +437 -437
- hud/tools/computer/hud.py +376 -376
- hud/tools/computer/openai.py +295 -295
- hud/tools/computer/settings.py +82 -82
- hud/tools/edit.py +314 -314
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -539
- hud/tools/executors/pyautogui.py +621 -621
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -511
- hud/tools/playwright.py +412 -412
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -282
- hud/tools/tests/test_bash.py +158 -158
- hud/tools/tests/test_bash_extended.py +197 -197
- hud/tools/tests/test_computer.py +425 -425
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -259
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -145
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -72
- hud/tools/utils.py +50 -50
- hud/types.py +136 -136
- hud/utils/__init__.py +10 -10
- hud/utils/async_utils.py +65 -65
- hud/utils/design.py +236 -168
- hud/utils/mcp.py +55 -55
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -173
- hud/utils/tests/test_init.py +17 -17
- hud/utils/tests/test_progress.py +261 -261
- hud/utils/tests/test_telemetry.py +82 -82
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
- hud_python-0.4.3.dist-info/RECORD +131 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
- hud/agents/art.py +0 -101
- hud_python-0.4.1.dist-info/RECORD +0 -132
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/agents/tests/test_client.py
CHANGED
|
@@ -1,363 +1,363 @@
|
|
|
1
|
-
"""Tests for MCP Client implementation."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import logging
|
|
6
|
-
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
|
-
|
|
8
|
-
import pytest
|
|
9
|
-
from mcp import types
|
|
10
|
-
from pydantic import AnyUrl
|
|
11
|
-
|
|
12
|
-
from hud.clients.mcp_use import MCPUseHUDClient as MCPClient
|
|
13
|
-
from hud.types import MCPToolResult
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@patch("hud.clients.base.setup_hud_telemetry")
|
|
19
|
-
class TestMCPClient:
|
|
20
|
-
"""Test MCPClient class."""
|
|
21
|
-
|
|
22
|
-
@pytest.fixture
|
|
23
|
-
def mock_mcp_use_client(self):
|
|
24
|
-
"""Create a mock MCPUseClient (the internal mcp_use client)."""
|
|
25
|
-
# Create a mock instance that will be returned by from_dict
|
|
26
|
-
mock_instance = MagicMock()
|
|
27
|
-
mock_instance.create_session = AsyncMock()
|
|
28
|
-
mock_instance.create_all_sessions = AsyncMock(return_value={})
|
|
29
|
-
mock_instance.close_all_sessions = AsyncMock()
|
|
30
|
-
mock_instance.get_all_active_sessions = MagicMock(return_value={})
|
|
31
|
-
|
|
32
|
-
# Patch MCPUseClient.from_dict at the module level
|
|
33
|
-
with patch("mcp_use.client.MCPClient.from_dict", return_value=mock_instance):
|
|
34
|
-
yield mock_instance
|
|
35
|
-
|
|
36
|
-
@pytest.mark.asyncio
|
|
37
|
-
async def test_init_with_config(self, mock_telemetry):
|
|
38
|
-
"""Test client initialization with config dictionary."""
|
|
39
|
-
mcp_config = {
|
|
40
|
-
"test_server": {
|
|
41
|
-
"command": "python",
|
|
42
|
-
"args": ["-m", "test_server"],
|
|
43
|
-
"env": {"TEST": "true"},
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
with patch("mcp_use.client.MCPClient.from_dict") as mock_from_dict:
|
|
48
|
-
mock_instance = MagicMock()
|
|
49
|
-
mock_instance.create_all_sessions = AsyncMock(return_value={})
|
|
50
|
-
mock_from_dict.return_value = mock_instance
|
|
51
|
-
client = MCPClient(mcp_config=mcp_config, verbose=True)
|
|
52
|
-
# Initialize to trigger connection
|
|
53
|
-
await client.initialize()
|
|
54
|
-
|
|
55
|
-
assert client.verbose is True
|
|
56
|
-
# Verify MCPUseClient.from_dict was called with proper config
|
|
57
|
-
mock_from_dict.assert_called_once_with({"mcpServers": mcp_config})
|
|
58
|
-
|
|
59
|
-
@pytest.mark.asyncio
|
|
60
|
-
async def test_connect_single_server(self, mock_telemetry, mock_mcp_use_client):
|
|
61
|
-
"""Test connecting to a single server."""
|
|
62
|
-
config = {"test_server": {"command": "python", "args": ["-m", "test_server"]}}
|
|
63
|
-
|
|
64
|
-
# Create the MCPClient - the fixture already patches MCPUseClient
|
|
65
|
-
client = MCPClient(mcp_config=config, verbose=True)
|
|
66
|
-
|
|
67
|
-
# Mock session
|
|
68
|
-
mock_session = MagicMock()
|
|
69
|
-
mock_session.connector = MagicMock()
|
|
70
|
-
mock_session.connector.client_session = MagicMock()
|
|
71
|
-
|
|
72
|
-
# Mock list_tools response
|
|
73
|
-
async def mock_list_tools():
|
|
74
|
-
return types.ListToolsResult(
|
|
75
|
-
tools=[
|
|
76
|
-
types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"}),
|
|
77
|
-
types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"}),
|
|
78
|
-
]
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
82
|
-
|
|
83
|
-
# Mock create_all_sessions to return a dict with our session
|
|
84
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(
|
|
85
|
-
return_value={"test_server": mock_session}
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
# Initialize the client (creates sessions and discovers tools)
|
|
89
|
-
await client.initialize()
|
|
90
|
-
|
|
91
|
-
# Internal client created
|
|
92
|
-
assert client._client is not None
|
|
93
|
-
|
|
94
|
-
# Verify session was created
|
|
95
|
-
mock_mcp_use_client.create_all_sessions.assert_called_once()
|
|
96
|
-
|
|
97
|
-
# Check tools were discovered via public API
|
|
98
|
-
tools = await client.list_tools()
|
|
99
|
-
names = {t.name for t in tools}
|
|
100
|
-
assert names == {"tool1", "tool2"}
|
|
101
|
-
|
|
102
|
-
@pytest.mark.asyncio
|
|
103
|
-
async def test_connect_multiple_servers(self, mock_telemetry, mock_mcp_use_client):
|
|
104
|
-
"""Test connecting to multiple servers."""
|
|
105
|
-
config = {
|
|
106
|
-
"server1": {"command": "python", "args": ["-m", "server1"]},
|
|
107
|
-
"server2": {"command": "node", "args": ["server2.js"]},
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
client = MCPClient(mcp_config=config)
|
|
111
|
-
|
|
112
|
-
# Mock sessions
|
|
113
|
-
mock_session1 = MagicMock()
|
|
114
|
-
mock_session1.connector = MagicMock()
|
|
115
|
-
mock_session1.connector.client_session = MagicMock()
|
|
116
|
-
|
|
117
|
-
mock_session2 = MagicMock()
|
|
118
|
-
mock_session2.connector = MagicMock()
|
|
119
|
-
mock_session2.connector.client_session = MagicMock()
|
|
120
|
-
|
|
121
|
-
# Mock tools for each server
|
|
122
|
-
async def mock_list_tools1():
|
|
123
|
-
return types.ListToolsResult(
|
|
124
|
-
tools=[
|
|
125
|
-
types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"})
|
|
126
|
-
]
|
|
127
|
-
)
|
|
128
|
-
|
|
129
|
-
async def mock_list_tools2():
|
|
130
|
-
return types.ListToolsResult(
|
|
131
|
-
tools=[
|
|
132
|
-
types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"})
|
|
133
|
-
]
|
|
134
|
-
)
|
|
135
|
-
|
|
136
|
-
mock_session1.connector.client_session.list_tools = mock_list_tools1
|
|
137
|
-
mock_session2.connector.client_session.list_tools = mock_list_tools2
|
|
138
|
-
|
|
139
|
-
# Mock create_all_sessions to return both sessions
|
|
140
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(
|
|
141
|
-
return_value={"server1": mock_session1, "server2": mock_session2}
|
|
142
|
-
)
|
|
143
|
-
|
|
144
|
-
await client.initialize()
|
|
145
|
-
|
|
146
|
-
# Verify sessions were created
|
|
147
|
-
mock_mcp_use_client.create_all_sessions.assert_called_once()
|
|
148
|
-
|
|
149
|
-
# Check tools from both servers
|
|
150
|
-
tools = await client.list_tools()
|
|
151
|
-
names = {t.name for t in tools}
|
|
152
|
-
assert names == {"tool1", "tool2"}
|
|
153
|
-
|
|
154
|
-
@pytest.mark.asyncio
|
|
155
|
-
async def test_call_tool(self, mock_telemetry, mock_mcp_use_client):
|
|
156
|
-
"""Test calling a tool."""
|
|
157
|
-
config = {"test": {"command": "test"}}
|
|
158
|
-
client = MCPClient(mcp_config=config)
|
|
159
|
-
|
|
160
|
-
# Setup mock session
|
|
161
|
-
mock_session = MagicMock()
|
|
162
|
-
mock_session.connector = MagicMock()
|
|
163
|
-
mock_session.connector.client_session = MagicMock()
|
|
164
|
-
|
|
165
|
-
# Mock tool
|
|
166
|
-
tool = types.Tool(
|
|
167
|
-
name="calculator", description="Calculator", inputSchema={"type": "object"}
|
|
168
|
-
)
|
|
169
|
-
|
|
170
|
-
async def mock_list_tools():
|
|
171
|
-
return types.ListToolsResult(tools=[tool])
|
|
172
|
-
|
|
173
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
174
|
-
|
|
175
|
-
# The session returns CallToolResult, but the client should return MCPToolResult
|
|
176
|
-
mock_call_result = types.CallToolResult(
|
|
177
|
-
content=[types.TextContent(type="text", text="Result: 42")], isError=False
|
|
178
|
-
)
|
|
179
|
-
|
|
180
|
-
mock_session.connector.client_session.call_tool = AsyncMock(return_value=mock_call_result)
|
|
181
|
-
|
|
182
|
-
# Set up the mock to return the session both when creating and when getting sessions
|
|
183
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
184
|
-
mock_mcp_use_client.get_all_active_sessions = MagicMock(return_value={"test": mock_session})
|
|
185
|
-
|
|
186
|
-
await client.initialize()
|
|
187
|
-
|
|
188
|
-
# First discover tools by calling list_tools
|
|
189
|
-
tools = await client.list_tools()
|
|
190
|
-
assert len(tools) == 1
|
|
191
|
-
assert tools[0].name == "calculator"
|
|
192
|
-
|
|
193
|
-
# Call the tool
|
|
194
|
-
result = await client.call_tool(
|
|
195
|
-
name="calculator", arguments={"operation": "add", "a": 20, "b": 22}
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
assert isinstance(result, MCPToolResult)
|
|
199
|
-
assert result.content[0].text == "Result: 42" # type: ignore
|
|
200
|
-
assert result.isError is False
|
|
201
|
-
mock_session.connector.client_session.call_tool.assert_called_once_with(
|
|
202
|
-
name="calculator", arguments={"operation": "add", "a": 20, "b": 22}
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
@pytest.mark.asyncio
|
|
206
|
-
async def test_call_tool_not_found(self, mock_telemetry, mock_mcp_use_client):
|
|
207
|
-
"""Test calling a non-existent tool."""
|
|
208
|
-
config = {"test": {"command": "test"}}
|
|
209
|
-
client = MCPClient(mcp_config=config)
|
|
210
|
-
|
|
211
|
-
mock_session = MagicMock()
|
|
212
|
-
mock_session.connector = MagicMock()
|
|
213
|
-
mock_session.connector.client_session = MagicMock()
|
|
214
|
-
|
|
215
|
-
async def mock_list_tools():
|
|
216
|
-
return types.ListToolsResult(tools=[])
|
|
217
|
-
|
|
218
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
219
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
220
|
-
|
|
221
|
-
await client.initialize()
|
|
222
|
-
|
|
223
|
-
with pytest.raises(ValueError, match="Tool 'nonexistent' not found"):
|
|
224
|
-
await client.call_tool(name="nonexistent", arguments={})
|
|
225
|
-
|
|
226
|
-
@pytest.mark.asyncio
|
|
227
|
-
async def test_get_telemetry_data(self, mock_telemetry, mock_mcp_use_client):
|
|
228
|
-
"""Test getting telemetry data."""
|
|
229
|
-
config = {"test": {"command": "test"}}
|
|
230
|
-
client = MCPClient(mcp_config=config)
|
|
231
|
-
|
|
232
|
-
mock_session = MagicMock()
|
|
233
|
-
mock_session.connector = MagicMock()
|
|
234
|
-
mock_session.connector.client_session = MagicMock()
|
|
235
|
-
|
|
236
|
-
# Mock tools
|
|
237
|
-
async def mock_list_tools():
|
|
238
|
-
return types.ListToolsResult(tools=[])
|
|
239
|
-
|
|
240
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
241
|
-
|
|
242
|
-
# Mock telemetry resource
|
|
243
|
-
mock_telemetry = types.ReadResourceResult(
|
|
244
|
-
contents=[
|
|
245
|
-
types.TextResourceContents(
|
|
246
|
-
uri=AnyUrl("telemetry://live"),
|
|
247
|
-
mimeType="application/json",
|
|
248
|
-
text='{"events": [{"type": "test", "data": "value"}]}',
|
|
249
|
-
)
|
|
250
|
-
]
|
|
251
|
-
)
|
|
252
|
-
|
|
253
|
-
mock_session.connector.client_session.read_resource = AsyncMock(return_value=mock_telemetry)
|
|
254
|
-
|
|
255
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
256
|
-
|
|
257
|
-
await client.initialize()
|
|
258
|
-
|
|
259
|
-
telemetry_data = client._telemetry_data
|
|
260
|
-
# In the new client, telemetry is a flat dict of fields
|
|
261
|
-
assert isinstance(telemetry_data, dict)
|
|
262
|
-
|
|
263
|
-
@pytest.mark.asyncio
|
|
264
|
-
async def test_close(self, mock_telemetry, mock_mcp_use_client):
|
|
265
|
-
"""Test closing client connections."""
|
|
266
|
-
config = {"test": {"command": "test"}}
|
|
267
|
-
client = MCPClient(mcp_config=config)
|
|
268
|
-
|
|
269
|
-
mock_session = MagicMock()
|
|
270
|
-
mock_session.connector = MagicMock()
|
|
271
|
-
mock_session.connector.client_session = MagicMock()
|
|
272
|
-
|
|
273
|
-
async def mock_list_tools():
|
|
274
|
-
return types.ListToolsResult(tools=[])
|
|
275
|
-
|
|
276
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
277
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
278
|
-
mock_mcp_use_client.close_all_sessions = AsyncMock()
|
|
279
|
-
|
|
280
|
-
await client.initialize()
|
|
281
|
-
await client.shutdown()
|
|
282
|
-
|
|
283
|
-
mock_mcp_use_client.close_all_sessions.assert_called_once()
|
|
284
|
-
|
|
285
|
-
@pytest.mark.asyncio
|
|
286
|
-
async def test_context_manager(self, mock_telemetry, mock_mcp_use_client):
|
|
287
|
-
"""Test using client as context manager."""
|
|
288
|
-
mock_session = MagicMock()
|
|
289
|
-
mock_session.connector = MagicMock()
|
|
290
|
-
mock_session.connector.client_session = MagicMock()
|
|
291
|
-
|
|
292
|
-
async def mock_list_tools():
|
|
293
|
-
return types.ListToolsResult(tools=[])
|
|
294
|
-
|
|
295
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
296
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
297
|
-
mock_mcp_use_client.close_all_sessions = AsyncMock()
|
|
298
|
-
|
|
299
|
-
config = {"test": {"command": "test"}}
|
|
300
|
-
# The fixture already patches MCPUseClient
|
|
301
|
-
async with MCPClient(mcp_config=config) as client:
|
|
302
|
-
assert client._client is not None
|
|
303
|
-
# Verify that the client uses our mock
|
|
304
|
-
assert client._client == mock_mcp_use_client
|
|
305
|
-
|
|
306
|
-
# Verify cleanup was called
|
|
307
|
-
mock_mcp_use_client.close_all_sessions.assert_called_once()
|
|
308
|
-
|
|
309
|
-
@pytest.mark.asyncio
|
|
310
|
-
async def test_get_available_tools(self, mock_telemetry, mock_mcp_use_client):
|
|
311
|
-
"""Test getting available tools."""
|
|
312
|
-
config = {"test": {"command": "test"}}
|
|
313
|
-
client = MCPClient(mcp_config=config)
|
|
314
|
-
|
|
315
|
-
# Create tool objects
|
|
316
|
-
tool1 = types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"})
|
|
317
|
-
tool2 = types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"})
|
|
318
|
-
|
|
319
|
-
# Setup mock session with tools
|
|
320
|
-
mock_session = MagicMock()
|
|
321
|
-
mock_session.connector = MagicMock()
|
|
322
|
-
mock_session.connector.client_session = MagicMock()
|
|
323
|
-
|
|
324
|
-
async def mock_list_tools():
|
|
325
|
-
return types.ListToolsResult(tools=[tool1, tool2])
|
|
326
|
-
|
|
327
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
328
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
329
|
-
|
|
330
|
-
# Initialize to populate tools
|
|
331
|
-
await client.initialize()
|
|
332
|
-
|
|
333
|
-
tools = await client.list_tools()
|
|
334
|
-
names = {t.name for t in tools}
|
|
335
|
-
assert names == {"tool1", "tool2"}
|
|
336
|
-
|
|
337
|
-
@pytest.mark.asyncio
|
|
338
|
-
async def test_get_tool_map(self, mock_telemetry, mock_mcp_use_client):
|
|
339
|
-
"""Test getting tool map."""
|
|
340
|
-
config = {"test": {"command": "test"}}
|
|
341
|
-
client = MCPClient(mcp_config=config)
|
|
342
|
-
|
|
343
|
-
# Create tool objects
|
|
344
|
-
tool1 = types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"})
|
|
345
|
-
tool2 = types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"})
|
|
346
|
-
|
|
347
|
-
# Setup mock session with tools
|
|
348
|
-
mock_session = MagicMock()
|
|
349
|
-
mock_session.connector = MagicMock()
|
|
350
|
-
mock_session.connector.client_session = MagicMock()
|
|
351
|
-
|
|
352
|
-
async def mock_list_tools():
|
|
353
|
-
return types.ListToolsResult(tools=[tool1, tool2])
|
|
354
|
-
|
|
355
|
-
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
356
|
-
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
357
|
-
|
|
358
|
-
# Initialize to populate tools
|
|
359
|
-
await client.initialize()
|
|
360
|
-
|
|
361
|
-
tools = await client.list_tools()
|
|
362
|
-
names = {t.name for t in tools}
|
|
363
|
-
assert names == {"tool1", "tool2"}
|
|
1
|
+
"""Tests for MCP Client implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from mcp import types
|
|
10
|
+
from pydantic import AnyUrl
|
|
11
|
+
|
|
12
|
+
from hud.clients.mcp_use import MCPUseHUDClient as MCPClient
|
|
13
|
+
from hud.types import MCPToolResult
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@patch("hud.clients.base.setup_hud_telemetry")
|
|
19
|
+
class TestMCPClient:
|
|
20
|
+
"""Test MCPClient class."""
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def mock_mcp_use_client(self):
|
|
24
|
+
"""Create a mock MCPUseClient (the internal mcp_use client)."""
|
|
25
|
+
# Create a mock instance that will be returned by from_dict
|
|
26
|
+
mock_instance = MagicMock()
|
|
27
|
+
mock_instance.create_session = AsyncMock()
|
|
28
|
+
mock_instance.create_all_sessions = AsyncMock(return_value={})
|
|
29
|
+
mock_instance.close_all_sessions = AsyncMock()
|
|
30
|
+
mock_instance.get_all_active_sessions = MagicMock(return_value={})
|
|
31
|
+
|
|
32
|
+
# Patch MCPUseClient.from_dict at the module level
|
|
33
|
+
with patch("mcp_use.client.MCPClient.from_dict", return_value=mock_instance):
|
|
34
|
+
yield mock_instance
|
|
35
|
+
|
|
36
|
+
@pytest.mark.asyncio
|
|
37
|
+
async def test_init_with_config(self, mock_telemetry):
|
|
38
|
+
"""Test client initialization with config dictionary."""
|
|
39
|
+
mcp_config = {
|
|
40
|
+
"test_server": {
|
|
41
|
+
"command": "python",
|
|
42
|
+
"args": ["-m", "test_server"],
|
|
43
|
+
"env": {"TEST": "true"},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
with patch("mcp_use.client.MCPClient.from_dict") as mock_from_dict:
|
|
48
|
+
mock_instance = MagicMock()
|
|
49
|
+
mock_instance.create_all_sessions = AsyncMock(return_value={})
|
|
50
|
+
mock_from_dict.return_value = mock_instance
|
|
51
|
+
client = MCPClient(mcp_config=mcp_config, verbose=True)
|
|
52
|
+
# Initialize to trigger connection
|
|
53
|
+
await client.initialize()
|
|
54
|
+
|
|
55
|
+
assert client.verbose is True
|
|
56
|
+
# Verify MCPUseClient.from_dict was called with proper config
|
|
57
|
+
mock_from_dict.assert_called_once_with({"mcpServers": mcp_config})
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_connect_single_server(self, mock_telemetry, mock_mcp_use_client):
|
|
61
|
+
"""Test connecting to a single server."""
|
|
62
|
+
config = {"test_server": {"command": "python", "args": ["-m", "test_server"]}}
|
|
63
|
+
|
|
64
|
+
# Create the MCPClient - the fixture already patches MCPUseClient
|
|
65
|
+
client = MCPClient(mcp_config=config, verbose=True)
|
|
66
|
+
|
|
67
|
+
# Mock session
|
|
68
|
+
mock_session = MagicMock()
|
|
69
|
+
mock_session.connector = MagicMock()
|
|
70
|
+
mock_session.connector.client_session = MagicMock()
|
|
71
|
+
|
|
72
|
+
# Mock list_tools response
|
|
73
|
+
async def mock_list_tools():
|
|
74
|
+
return types.ListToolsResult(
|
|
75
|
+
tools=[
|
|
76
|
+
types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"}),
|
|
77
|
+
types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"}),
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
82
|
+
|
|
83
|
+
# Mock create_all_sessions to return a dict with our session
|
|
84
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(
|
|
85
|
+
return_value={"test_server": mock_session}
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Initialize the client (creates sessions and discovers tools)
|
|
89
|
+
await client.initialize()
|
|
90
|
+
|
|
91
|
+
# Internal client created
|
|
92
|
+
assert client._client is not None
|
|
93
|
+
|
|
94
|
+
# Verify session was created
|
|
95
|
+
mock_mcp_use_client.create_all_sessions.assert_called_once()
|
|
96
|
+
|
|
97
|
+
# Check tools were discovered via public API
|
|
98
|
+
tools = await client.list_tools()
|
|
99
|
+
names = {t.name for t in tools}
|
|
100
|
+
assert names == {"tool1", "tool2"}
|
|
101
|
+
|
|
102
|
+
@pytest.mark.asyncio
|
|
103
|
+
async def test_connect_multiple_servers(self, mock_telemetry, mock_mcp_use_client):
|
|
104
|
+
"""Test connecting to multiple servers."""
|
|
105
|
+
config = {
|
|
106
|
+
"server1": {"command": "python", "args": ["-m", "server1"]},
|
|
107
|
+
"server2": {"command": "node", "args": ["server2.js"]},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
client = MCPClient(mcp_config=config)
|
|
111
|
+
|
|
112
|
+
# Mock sessions
|
|
113
|
+
mock_session1 = MagicMock()
|
|
114
|
+
mock_session1.connector = MagicMock()
|
|
115
|
+
mock_session1.connector.client_session = MagicMock()
|
|
116
|
+
|
|
117
|
+
mock_session2 = MagicMock()
|
|
118
|
+
mock_session2.connector = MagicMock()
|
|
119
|
+
mock_session2.connector.client_session = MagicMock()
|
|
120
|
+
|
|
121
|
+
# Mock tools for each server
|
|
122
|
+
async def mock_list_tools1():
|
|
123
|
+
return types.ListToolsResult(
|
|
124
|
+
tools=[
|
|
125
|
+
types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"})
|
|
126
|
+
]
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
async def mock_list_tools2():
|
|
130
|
+
return types.ListToolsResult(
|
|
131
|
+
tools=[
|
|
132
|
+
types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"})
|
|
133
|
+
]
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
mock_session1.connector.client_session.list_tools = mock_list_tools1
|
|
137
|
+
mock_session2.connector.client_session.list_tools = mock_list_tools2
|
|
138
|
+
|
|
139
|
+
# Mock create_all_sessions to return both sessions
|
|
140
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(
|
|
141
|
+
return_value={"server1": mock_session1, "server2": mock_session2}
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await client.initialize()
|
|
145
|
+
|
|
146
|
+
# Verify sessions were created
|
|
147
|
+
mock_mcp_use_client.create_all_sessions.assert_called_once()
|
|
148
|
+
|
|
149
|
+
# Check tools from both servers
|
|
150
|
+
tools = await client.list_tools()
|
|
151
|
+
names = {t.name for t in tools}
|
|
152
|
+
assert names == {"tool1", "tool2"}
|
|
153
|
+
|
|
154
|
+
@pytest.mark.asyncio
|
|
155
|
+
async def test_call_tool(self, mock_telemetry, mock_mcp_use_client):
|
|
156
|
+
"""Test calling a tool."""
|
|
157
|
+
config = {"test": {"command": "test"}}
|
|
158
|
+
client = MCPClient(mcp_config=config)
|
|
159
|
+
|
|
160
|
+
# Setup mock session
|
|
161
|
+
mock_session = MagicMock()
|
|
162
|
+
mock_session.connector = MagicMock()
|
|
163
|
+
mock_session.connector.client_session = MagicMock()
|
|
164
|
+
|
|
165
|
+
# Mock tool
|
|
166
|
+
tool = types.Tool(
|
|
167
|
+
name="calculator", description="Calculator", inputSchema={"type": "object"}
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
async def mock_list_tools():
|
|
171
|
+
return types.ListToolsResult(tools=[tool])
|
|
172
|
+
|
|
173
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
174
|
+
|
|
175
|
+
# The session returns CallToolResult, but the client should return MCPToolResult
|
|
176
|
+
mock_call_result = types.CallToolResult(
|
|
177
|
+
content=[types.TextContent(type="text", text="Result: 42")], isError=False
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
mock_session.connector.client_session.call_tool = AsyncMock(return_value=mock_call_result)
|
|
181
|
+
|
|
182
|
+
# Set up the mock to return the session both when creating and when getting sessions
|
|
183
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
184
|
+
mock_mcp_use_client.get_all_active_sessions = MagicMock(return_value={"test": mock_session})
|
|
185
|
+
|
|
186
|
+
await client.initialize()
|
|
187
|
+
|
|
188
|
+
# First discover tools by calling list_tools
|
|
189
|
+
tools = await client.list_tools()
|
|
190
|
+
assert len(tools) == 1
|
|
191
|
+
assert tools[0].name == "calculator"
|
|
192
|
+
|
|
193
|
+
# Call the tool
|
|
194
|
+
result = await client.call_tool(
|
|
195
|
+
name="calculator", arguments={"operation": "add", "a": 20, "b": 22}
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
assert isinstance(result, MCPToolResult)
|
|
199
|
+
assert result.content[0].text == "Result: 42" # type: ignore
|
|
200
|
+
assert result.isError is False
|
|
201
|
+
mock_session.connector.client_session.call_tool.assert_called_once_with(
|
|
202
|
+
name="calculator", arguments={"operation": "add", "a": 20, "b": 22}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
@pytest.mark.asyncio
|
|
206
|
+
async def test_call_tool_not_found(self, mock_telemetry, mock_mcp_use_client):
|
|
207
|
+
"""Test calling a non-existent tool."""
|
|
208
|
+
config = {"test": {"command": "test"}}
|
|
209
|
+
client = MCPClient(mcp_config=config)
|
|
210
|
+
|
|
211
|
+
mock_session = MagicMock()
|
|
212
|
+
mock_session.connector = MagicMock()
|
|
213
|
+
mock_session.connector.client_session = MagicMock()
|
|
214
|
+
|
|
215
|
+
async def mock_list_tools():
|
|
216
|
+
return types.ListToolsResult(tools=[])
|
|
217
|
+
|
|
218
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
219
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
220
|
+
|
|
221
|
+
await client.initialize()
|
|
222
|
+
|
|
223
|
+
with pytest.raises(ValueError, match="Tool 'nonexistent' not found"):
|
|
224
|
+
await client.call_tool(name="nonexistent", arguments={})
|
|
225
|
+
|
|
226
|
+
@pytest.mark.asyncio
|
|
227
|
+
async def test_get_telemetry_data(self, mock_telemetry, mock_mcp_use_client):
|
|
228
|
+
"""Test getting telemetry data."""
|
|
229
|
+
config = {"test": {"command": "test"}}
|
|
230
|
+
client = MCPClient(mcp_config=config)
|
|
231
|
+
|
|
232
|
+
mock_session = MagicMock()
|
|
233
|
+
mock_session.connector = MagicMock()
|
|
234
|
+
mock_session.connector.client_session = MagicMock()
|
|
235
|
+
|
|
236
|
+
# Mock tools
|
|
237
|
+
async def mock_list_tools():
|
|
238
|
+
return types.ListToolsResult(tools=[])
|
|
239
|
+
|
|
240
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
241
|
+
|
|
242
|
+
# Mock telemetry resource
|
|
243
|
+
mock_telemetry = types.ReadResourceResult(
|
|
244
|
+
contents=[
|
|
245
|
+
types.TextResourceContents(
|
|
246
|
+
uri=AnyUrl("telemetry://live"),
|
|
247
|
+
mimeType="application/json",
|
|
248
|
+
text='{"events": [{"type": "test", "data": "value"}]}',
|
|
249
|
+
)
|
|
250
|
+
]
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
mock_session.connector.client_session.read_resource = AsyncMock(return_value=mock_telemetry)
|
|
254
|
+
|
|
255
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
256
|
+
|
|
257
|
+
await client.initialize()
|
|
258
|
+
|
|
259
|
+
telemetry_data = client._telemetry_data
|
|
260
|
+
# In the new client, telemetry is a flat dict of fields
|
|
261
|
+
assert isinstance(telemetry_data, dict)
|
|
262
|
+
|
|
263
|
+
@pytest.mark.asyncio
|
|
264
|
+
async def test_close(self, mock_telemetry, mock_mcp_use_client):
|
|
265
|
+
"""Test closing client connections."""
|
|
266
|
+
config = {"test": {"command": "test"}}
|
|
267
|
+
client = MCPClient(mcp_config=config)
|
|
268
|
+
|
|
269
|
+
mock_session = MagicMock()
|
|
270
|
+
mock_session.connector = MagicMock()
|
|
271
|
+
mock_session.connector.client_session = MagicMock()
|
|
272
|
+
|
|
273
|
+
async def mock_list_tools():
|
|
274
|
+
return types.ListToolsResult(tools=[])
|
|
275
|
+
|
|
276
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
277
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
278
|
+
mock_mcp_use_client.close_all_sessions = AsyncMock()
|
|
279
|
+
|
|
280
|
+
await client.initialize()
|
|
281
|
+
await client.shutdown()
|
|
282
|
+
|
|
283
|
+
mock_mcp_use_client.close_all_sessions.assert_called_once()
|
|
284
|
+
|
|
285
|
+
@pytest.mark.asyncio
|
|
286
|
+
async def test_context_manager(self, mock_telemetry, mock_mcp_use_client):
|
|
287
|
+
"""Test using client as context manager."""
|
|
288
|
+
mock_session = MagicMock()
|
|
289
|
+
mock_session.connector = MagicMock()
|
|
290
|
+
mock_session.connector.client_session = MagicMock()
|
|
291
|
+
|
|
292
|
+
async def mock_list_tools():
|
|
293
|
+
return types.ListToolsResult(tools=[])
|
|
294
|
+
|
|
295
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
296
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
297
|
+
mock_mcp_use_client.close_all_sessions = AsyncMock()
|
|
298
|
+
|
|
299
|
+
config = {"test": {"command": "test"}}
|
|
300
|
+
# The fixture already patches MCPUseClient
|
|
301
|
+
async with MCPClient(mcp_config=config) as client:
|
|
302
|
+
assert client._client is not None
|
|
303
|
+
# Verify that the client uses our mock
|
|
304
|
+
assert client._client == mock_mcp_use_client
|
|
305
|
+
|
|
306
|
+
# Verify cleanup was called
|
|
307
|
+
mock_mcp_use_client.close_all_sessions.assert_called_once()
|
|
308
|
+
|
|
309
|
+
@pytest.mark.asyncio
|
|
310
|
+
async def test_get_available_tools(self, mock_telemetry, mock_mcp_use_client):
|
|
311
|
+
"""Test getting available tools."""
|
|
312
|
+
config = {"test": {"command": "test"}}
|
|
313
|
+
client = MCPClient(mcp_config=config)
|
|
314
|
+
|
|
315
|
+
# Create tool objects
|
|
316
|
+
tool1 = types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"})
|
|
317
|
+
tool2 = types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"})
|
|
318
|
+
|
|
319
|
+
# Setup mock session with tools
|
|
320
|
+
mock_session = MagicMock()
|
|
321
|
+
mock_session.connector = MagicMock()
|
|
322
|
+
mock_session.connector.client_session = MagicMock()
|
|
323
|
+
|
|
324
|
+
async def mock_list_tools():
|
|
325
|
+
return types.ListToolsResult(tools=[tool1, tool2])
|
|
326
|
+
|
|
327
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
328
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
329
|
+
|
|
330
|
+
# Initialize to populate tools
|
|
331
|
+
await client.initialize()
|
|
332
|
+
|
|
333
|
+
tools = await client.list_tools()
|
|
334
|
+
names = {t.name for t in tools}
|
|
335
|
+
assert names == {"tool1", "tool2"}
|
|
336
|
+
|
|
337
|
+
@pytest.mark.asyncio
|
|
338
|
+
async def test_get_tool_map(self, mock_telemetry, mock_mcp_use_client):
|
|
339
|
+
"""Test getting tool map."""
|
|
340
|
+
config = {"test": {"command": "test"}}
|
|
341
|
+
client = MCPClient(mcp_config=config)
|
|
342
|
+
|
|
343
|
+
# Create tool objects
|
|
344
|
+
tool1 = types.Tool(name="tool1", description="Tool 1", inputSchema={"type": "object"})
|
|
345
|
+
tool2 = types.Tool(name="tool2", description="Tool 2", inputSchema={"type": "object"})
|
|
346
|
+
|
|
347
|
+
# Setup mock session with tools
|
|
348
|
+
mock_session = MagicMock()
|
|
349
|
+
mock_session.connector = MagicMock()
|
|
350
|
+
mock_session.connector.client_session = MagicMock()
|
|
351
|
+
|
|
352
|
+
async def mock_list_tools():
|
|
353
|
+
return types.ListToolsResult(tools=[tool1, tool2])
|
|
354
|
+
|
|
355
|
+
mock_session.connector.client_session.list_tools = mock_list_tools
|
|
356
|
+
mock_mcp_use_client.create_all_sessions = AsyncMock(return_value={"test": mock_session})
|
|
357
|
+
|
|
358
|
+
# Initialize to populate tools
|
|
359
|
+
await client.initialize()
|
|
360
|
+
|
|
361
|
+
tools = await client.list_tools()
|
|
362
|
+
names = {t.name for t in tools}
|
|
363
|
+
assert names == {"tool1", "tool2"}
|