hud-python 0.3.5__py3-none-any.whl → 0.4.1__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 -89
- hud/agents/__init__.py +15 -0
- hud/agents/art.py +101 -0
- hud/agents/base.py +599 -0
- hud/{mcp → agents}/claude.py +373 -321
- hud/{mcp → agents}/langchain.py +250 -250
- hud/agents/misc/__init__.py +7 -0
- hud/{agent → agents}/misc/response_agent.py +80 -80
- hud/{mcp → agents}/openai.py +352 -334
- hud/agents/openai_chat_generic.py +154 -0
- hud/{mcp → agents}/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -0
- hud/agents/tests/test_claude.py +324 -0
- hud/{mcp → agents}/tests/test_client.py +363 -324
- hud/{mcp → agents}/tests/test_openai.py +237 -238
- hud/cli/__init__.py +617 -0
- hud/cli/__main__.py +8 -0
- hud/cli/analyze.py +371 -0
- hud/cli/analyze_metadata.py +230 -0
- hud/cli/build.py +427 -0
- hud/cli/clone.py +185 -0
- hud/cli/cursor.py +92 -0
- hud/cli/debug.py +392 -0
- hud/cli/docker_utils.py +83 -0
- hud/cli/init.py +281 -0
- hud/cli/interactive.py +353 -0
- hud/cli/mcp_server.py +756 -0
- hud/cli/pull.py +336 -0
- hud/cli/push.py +370 -0
- hud/cli/remote_runner.py +311 -0
- hud/cli/runner.py +160 -0
- hud/cli/tests/__init__.py +3 -0
- hud/cli/tests/test_analyze.py +284 -0
- hud/cli/tests/test_cli_init.py +265 -0
- hud/cli/tests/test_cli_main.py +27 -0
- hud/cli/tests/test_clone.py +142 -0
- hud/cli/tests/test_cursor.py +253 -0
- hud/cli/tests/test_debug.py +453 -0
- hud/cli/tests/test_mcp_server.py +139 -0
- hud/cli/tests/test_utils.py +388 -0
- hud/cli/utils.py +263 -0
- hud/clients/README.md +143 -0
- hud/clients/__init__.py +16 -0
- hud/clients/base.py +379 -0
- hud/clients/fastmcp.py +222 -0
- hud/clients/mcp_use.py +278 -0
- hud/clients/tests/__init__.py +1 -0
- hud/clients/tests/test_client_integration.py +111 -0
- hud/clients/tests/test_fastmcp.py +342 -0
- hud/clients/tests/test_protocol.py +188 -0
- hud/clients/utils/__init__.py +1 -0
- hud/clients/utils/retry_transport.py +160 -0
- hud/datasets.py +322 -192
- hud/misc/__init__.py +1 -0
- hud/{agent → misc}/claude_plays_pokemon.py +292 -283
- hud/otel/__init__.py +35 -0
- hud/otel/collector.py +142 -0
- hud/otel/config.py +164 -0
- hud/otel/context.py +536 -0
- hud/otel/exporters.py +366 -0
- hud/otel/instrumentation.py +97 -0
- hud/otel/processors.py +118 -0
- hud/otel/tests/__init__.py +1 -0
- hud/otel/tests/test_processors.py +197 -0
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -0
- hud/server/helper/__init__.py +5 -0
- hud/server/low_level.py +132 -0
- hud/server/server.py +166 -0
- hud/server/tests/__init__.py +3 -0
- hud/settings.py +73 -79
- hud/shared/__init__.py +5 -0
- hud/{exceptions.py → shared/exceptions.py} +180 -180
- hud/{server → shared}/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -0
- hud/{server → shared}/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -30
- hud/telemetry/instrument.py +379 -0
- hud/telemetry/job.py +309 -141
- hud/telemetry/replay.py +74 -0
- hud/telemetry/trace.py +83 -0
- hud/tools/__init__.py +33 -34
- hud/tools/base.py +365 -65
- hud/tools/bash.py +161 -137
- hud/tools/computer/__init__.py +15 -13
- hud/tools/computer/anthropic.py +437 -420
- hud/tools/computer/hud.py +376 -334
- hud/tools/computer/openai.py +295 -292
- hud/tools/computer/settings.py +82 -0
- hud/tools/edit.py +314 -290
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -532
- hud/tools/executors/pyautogui.py +621 -619
- 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 -503
- hud/tools/{playwright_tool.py → playwright.py} +412 -379
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -0
- hud/tools/tests/test_bash.py +158 -152
- hud/tools/tests/test_bash_extended.py +197 -0
- hud/tools/tests/test_computer.py +425 -52
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -240
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -157
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -0
- hud/tools/utils.py +50 -50
- hud/types.py +136 -89
- hud/utils/__init__.py +10 -16
- hud/utils/async_utils.py +65 -0
- hud/utils/design.py +168 -0
- hud/utils/mcp.py +55 -0
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -0
- hud/utils/tests/test_init.py +17 -21
- hud/utils/tests/test_progress.py +261 -225
- hud/utils/tests/test_telemetry.py +82 -37
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- hud_python-0.4.1.dist-info/METADATA +476 -0
- hud_python-0.4.1.dist-info/RECORD +132 -0
- hud_python-0.4.1.dist-info/entry_points.txt +3 -0
- {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/licenses/LICENSE +21 -21
- hud/adapters/__init__.py +0 -8
- hud/adapters/claude/__init__.py +0 -5
- hud/adapters/claude/adapter.py +0 -180
- hud/adapters/claude/tests/__init__.py +0 -1
- hud/adapters/claude/tests/test_adapter.py +0 -519
- hud/adapters/common/__init__.py +0 -6
- hud/adapters/common/adapter.py +0 -178
- hud/adapters/common/tests/test_adapter.py +0 -289
- hud/adapters/common/types.py +0 -446
- hud/adapters/operator/__init__.py +0 -5
- hud/adapters/operator/adapter.py +0 -108
- hud/adapters/operator/tests/__init__.py +0 -1
- hud/adapters/operator/tests/test_adapter.py +0 -370
- hud/agent/__init__.py +0 -19
- hud/agent/base.py +0 -126
- hud/agent/claude.py +0 -271
- hud/agent/langchain.py +0 -215
- hud/agent/misc/__init__.py +0 -3
- hud/agent/operator.py +0 -268
- hud/agent/tests/__init__.py +0 -1
- hud/agent/tests/test_base.py +0 -202
- hud/env/__init__.py +0 -11
- hud/env/client.py +0 -35
- hud/env/docker_client.py +0 -349
- hud/env/environment.py +0 -446
- hud/env/local_docker_client.py +0 -358
- hud/env/remote_client.py +0 -212
- hud/env/remote_docker_client.py +0 -292
- hud/gym.py +0 -130
- hud/job.py +0 -773
- hud/mcp/__init__.py +0 -17
- hud/mcp/base.py +0 -631
- hud/mcp/client.py +0 -312
- hud/mcp/tests/test_base.py +0 -512
- hud/mcp/tests/test_claude.py +0 -294
- hud/task.py +0 -149
- hud/taskset.py +0 -237
- hud/telemetry/_trace.py +0 -347
- hud/telemetry/context.py +0 -230
- hud/telemetry/exporter.py +0 -575
- hud/telemetry/instrumentation/__init__.py +0 -3
- hud/telemetry/instrumentation/mcp.py +0 -259
- hud/telemetry/instrumentation/registry.py +0 -59
- hud/telemetry/mcp_models.py +0 -270
- hud/telemetry/tests/__init__.py +0 -1
- hud/telemetry/tests/test_context.py +0 -210
- hud/telemetry/tests/test_trace.py +0 -312
- hud/tools/helper/README.md +0 -56
- hud/tools/helper/__init__.py +0 -9
- hud/tools/helper/mcp_server.py +0 -78
- hud/tools/helper/server_initialization.py +0 -115
- hud/tools/helper/utils.py +0 -58
- hud/trajectory.py +0 -94
- hud/utils/agent.py +0 -37
- hud/utils/common.py +0 -256
- hud/utils/config.py +0 -120
- hud/utils/deprecation.py +0 -115
- hud/utils/misc.py +0 -53
- hud/utils/tests/test_common.py +0 -277
- hud/utils/tests/test_config.py +0 -129
- hud_python-0.3.5.dist-info/METADATA +0 -284
- hud_python-0.3.5.dist-info/RECORD +0 -120
- /hud/{adapters/common → shared}/tests/__init__.py +0 -0
- {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
"""Tests for FastMCP client implementation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from unittest.mock import ANY, AsyncMock, MagicMock, patch
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from mcp import types
|
|
9
|
+
from pydantic.networks import AnyUrl
|
|
10
|
+
|
|
11
|
+
from hud.clients.fastmcp import FastMCPHUDClient
|
|
12
|
+
from hud.types import MCPToolCall, MCPToolResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TestFastMCPHUDClient:
|
|
16
|
+
"""Test FastMCP HUD client."""
|
|
17
|
+
|
|
18
|
+
def test_initialization(self):
|
|
19
|
+
"""Test client initialization."""
|
|
20
|
+
config = {"server1": {"command": "python", "args": ["server.py"]}}
|
|
21
|
+
|
|
22
|
+
# Client is just instantiated, not connected yet
|
|
23
|
+
client = FastMCPHUDClient(config)
|
|
24
|
+
|
|
25
|
+
# Check that the client has the config stored
|
|
26
|
+
assert client._mcp_config == config
|
|
27
|
+
assert client._client is None # Not connected yet
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_connect_creates_client(self):
|
|
31
|
+
"""Test that _connect creates the FastMCP client."""
|
|
32
|
+
config = {"server1": {"command": "test"}}
|
|
33
|
+
|
|
34
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
35
|
+
mock_fastmcp = AsyncMock()
|
|
36
|
+
mock_client_class.return_value = mock_fastmcp
|
|
37
|
+
|
|
38
|
+
client = FastMCPHUDClient(config)
|
|
39
|
+
await client._connect(config)
|
|
40
|
+
|
|
41
|
+
# Check FastMCP client was created
|
|
42
|
+
mock_client_class.assert_called_once()
|
|
43
|
+
|
|
44
|
+
# Check it was created with correct transport and client info
|
|
45
|
+
call_args = mock_client_class.call_args
|
|
46
|
+
assert call_args[0][0] == {"mcpServers": config}
|
|
47
|
+
assert call_args[1]["client_info"].name == "hud-fastmcp"
|
|
48
|
+
|
|
49
|
+
@pytest.mark.asyncio
|
|
50
|
+
async def test_connect_logs_info(self):
|
|
51
|
+
"""Test that connect logs info message."""
|
|
52
|
+
config = {"server1": {"command": "test"}}
|
|
53
|
+
|
|
54
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
55
|
+
mock_fastmcp = AsyncMock()
|
|
56
|
+
mock_client_class.return_value = mock_fastmcp
|
|
57
|
+
|
|
58
|
+
client = FastMCPHUDClient(config)
|
|
59
|
+
|
|
60
|
+
with patch("hud.clients.fastmcp.logger") as mock_logger:
|
|
61
|
+
await client._connect(config)
|
|
62
|
+
|
|
63
|
+
# Check info was logged
|
|
64
|
+
mock_logger.info.assert_called_with("FastMCP client connected")
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_list_tools(self):
|
|
68
|
+
"""Test listing tools."""
|
|
69
|
+
config = {"server1": {"command": "test"}}
|
|
70
|
+
|
|
71
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
72
|
+
mock_fastmcp = AsyncMock()
|
|
73
|
+
mock_tools = [
|
|
74
|
+
MagicMock(spec=types.Tool, name="tool1"),
|
|
75
|
+
MagicMock(spec=types.Tool, name="tool2"),
|
|
76
|
+
]
|
|
77
|
+
mock_fastmcp.list_tools.return_value = mock_tools
|
|
78
|
+
mock_client_class.return_value = mock_fastmcp
|
|
79
|
+
|
|
80
|
+
client = FastMCPHUDClient(config)
|
|
81
|
+
client._initialized = True # Skip initialization
|
|
82
|
+
client._client = mock_fastmcp # Set the mock client
|
|
83
|
+
|
|
84
|
+
tools = await client.list_tools()
|
|
85
|
+
|
|
86
|
+
assert tools == mock_tools
|
|
87
|
+
mock_fastmcp.list_tools.assert_called_once()
|
|
88
|
+
|
|
89
|
+
@pytest.mark.asyncio
|
|
90
|
+
async def test_call_tool(self):
|
|
91
|
+
"""Test calling a tool."""
|
|
92
|
+
config = {"server1": {"command": "test"}}
|
|
93
|
+
|
|
94
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
95
|
+
mock_fastmcp = AsyncMock()
|
|
96
|
+
|
|
97
|
+
# Mock FastMCP result
|
|
98
|
+
mock_result = MagicMock()
|
|
99
|
+
mock_result.content = [types.TextContent(type="text", text="result")]
|
|
100
|
+
mock_result.is_error = False
|
|
101
|
+
mock_result.structured_content = {"key": "value"}
|
|
102
|
+
|
|
103
|
+
mock_fastmcp.call_tool.return_value = mock_result
|
|
104
|
+
mock_client_class.return_value = mock_fastmcp
|
|
105
|
+
|
|
106
|
+
client = FastMCPHUDClient(config)
|
|
107
|
+
client._initialized = True
|
|
108
|
+
client._client = mock_fastmcp # Set the mock client
|
|
109
|
+
|
|
110
|
+
result = await client.call_tool(name="test_tool", arguments={"arg": "value"})
|
|
111
|
+
|
|
112
|
+
assert isinstance(result, MCPToolResult)
|
|
113
|
+
assert result.content == mock_result.content
|
|
114
|
+
assert result.isError is False
|
|
115
|
+
assert result.structuredContent == {"key": "value"}
|
|
116
|
+
|
|
117
|
+
mock_fastmcp.call_tool.assert_called_once_with(
|
|
118
|
+
name="test_tool",
|
|
119
|
+
arguments={"arg": "value"},
|
|
120
|
+
raise_on_error=False,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
@pytest.mark.asyncio
|
|
124
|
+
async def test_call_tool_with_mcp_tool_call(self):
|
|
125
|
+
"""Test calling a tool with MCPToolCall object."""
|
|
126
|
+
config = {"server1": {"command": "test"}}
|
|
127
|
+
|
|
128
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
129
|
+
mock_fastmcp = AsyncMock()
|
|
130
|
+
|
|
131
|
+
# Mock FastMCP result
|
|
132
|
+
mock_result = MagicMock()
|
|
133
|
+
mock_result.content = [types.TextContent(type="text", text="result")]
|
|
134
|
+
mock_result.is_error = False
|
|
135
|
+
mock_result.structured_content = {"key": "value"}
|
|
136
|
+
|
|
137
|
+
mock_fastmcp.call_tool.return_value = mock_result
|
|
138
|
+
mock_client_class.return_value = mock_fastmcp
|
|
139
|
+
|
|
140
|
+
client = FastMCPHUDClient(config)
|
|
141
|
+
client._initialized = True
|
|
142
|
+
client._client = mock_fastmcp # Set the mock client
|
|
143
|
+
|
|
144
|
+
# Test with MCPToolCall object
|
|
145
|
+
tool_call = MCPToolCall(name="test_tool", arguments={"arg": "value"})
|
|
146
|
+
result = await client.call_tool(tool_call)
|
|
147
|
+
|
|
148
|
+
assert isinstance(result, MCPToolResult)
|
|
149
|
+
assert result.content == mock_result.content
|
|
150
|
+
assert result.isError is False
|
|
151
|
+
assert result.structuredContent == {"key": "value"}
|
|
152
|
+
|
|
153
|
+
mock_fastmcp.call_tool.assert_called_once_with(
|
|
154
|
+
name="test_tool",
|
|
155
|
+
arguments={"arg": "value"},
|
|
156
|
+
raise_on_error=False,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@pytest.mark.asyncio
|
|
160
|
+
async def test_call_tool_no_arguments(self):
|
|
161
|
+
"""Test calling a tool without arguments."""
|
|
162
|
+
config = {"server1": {"command": "test"}}
|
|
163
|
+
|
|
164
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
165
|
+
mock_fastmcp = AsyncMock()
|
|
166
|
+
mock_result = MagicMock()
|
|
167
|
+
mock_result.content = []
|
|
168
|
+
mock_result.is_error = True
|
|
169
|
+
mock_result.structured_content = None
|
|
170
|
+
|
|
171
|
+
mock_fastmcp.call_tool.return_value = mock_result
|
|
172
|
+
mock_client_class.return_value = mock_fastmcp
|
|
173
|
+
|
|
174
|
+
client = FastMCPHUDClient(config)
|
|
175
|
+
client._initialized = True
|
|
176
|
+
client._client = mock_fastmcp # Set the mock client
|
|
177
|
+
|
|
178
|
+
await client.call_tool(name="test_tool", arguments={})
|
|
179
|
+
|
|
180
|
+
# Should pass empty dict for arguments
|
|
181
|
+
mock_fastmcp.call_tool.assert_called_once_with(
|
|
182
|
+
name="test_tool",
|
|
183
|
+
arguments={},
|
|
184
|
+
raise_on_error=False,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
@pytest.mark.asyncio
|
|
188
|
+
async def test_list_resources(self):
|
|
189
|
+
"""Test listing resources."""
|
|
190
|
+
config = {"server1": {"command": "test"}}
|
|
191
|
+
|
|
192
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
193
|
+
mock_fastmcp = AsyncMock()
|
|
194
|
+
mock_resources = [
|
|
195
|
+
MagicMock(spec=types.Resource, uri="file:///test1"),
|
|
196
|
+
MagicMock(spec=types.Resource, uri="file:///test2"),
|
|
197
|
+
]
|
|
198
|
+
mock_fastmcp.list_resources.return_value = mock_resources
|
|
199
|
+
mock_client_class.return_value = mock_fastmcp
|
|
200
|
+
|
|
201
|
+
client = FastMCPHUDClient(config)
|
|
202
|
+
client._initialized = True
|
|
203
|
+
client._client = mock_fastmcp # Set the mock client
|
|
204
|
+
|
|
205
|
+
resources = await client.list_resources()
|
|
206
|
+
|
|
207
|
+
assert resources == mock_resources
|
|
208
|
+
mock_fastmcp.list_resources.assert_called_once()
|
|
209
|
+
|
|
210
|
+
@pytest.mark.asyncio
|
|
211
|
+
async def test_read_resource_internal_success(self):
|
|
212
|
+
"""Test reading a resource successfully."""
|
|
213
|
+
config = {"server1": {"command": "test"}}
|
|
214
|
+
|
|
215
|
+
# Create proper resource contents that ReadResourceResult expects
|
|
216
|
+
mock_contents = [
|
|
217
|
+
types.TextResourceContents(
|
|
218
|
+
uri=AnyUrl("file:///test"),
|
|
219
|
+
mimeType="text/plain",
|
|
220
|
+
text="resource content",
|
|
221
|
+
)
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
225
|
+
# Create a mock FastMCP client
|
|
226
|
+
mock_fastmcp = AsyncMock()
|
|
227
|
+
mock_fastmcp.read_resource.return_value = mock_contents
|
|
228
|
+
mock_client_class.return_value = mock_fastmcp
|
|
229
|
+
|
|
230
|
+
# Now create the HUD client - it will use our mocked FastMCP client
|
|
231
|
+
client = FastMCPHUDClient(config)
|
|
232
|
+
client._initialized = True
|
|
233
|
+
client._client = mock_fastmcp # Set the mock client
|
|
234
|
+
|
|
235
|
+
result = await client.read_resource("file:///test")
|
|
236
|
+
|
|
237
|
+
assert isinstance(result, types.ReadResourceResult)
|
|
238
|
+
assert result.contents == mock_contents
|
|
239
|
+
mock_fastmcp.read_resource.assert_called_once_with("file:///test")
|
|
240
|
+
|
|
241
|
+
@pytest.mark.asyncio
|
|
242
|
+
async def test_read_resource_internal_error_verbose(self):
|
|
243
|
+
"""Test reading a resource with error in verbose mode."""
|
|
244
|
+
config = {"server1": {"command": "test"}}
|
|
245
|
+
|
|
246
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
247
|
+
mock_fastmcp = AsyncMock()
|
|
248
|
+
mock_fastmcp.read_resource.side_effect = Exception("Read failed")
|
|
249
|
+
mock_client_class.return_value = mock_fastmcp
|
|
250
|
+
|
|
251
|
+
client = FastMCPHUDClient(config, verbose=True)
|
|
252
|
+
client._initialized = True
|
|
253
|
+
client._client = mock_fastmcp # Set the mock client
|
|
254
|
+
|
|
255
|
+
with patch("hud.clients.fastmcp.logger") as mock_logger:
|
|
256
|
+
result = await client.read_resource("file:///bad")
|
|
257
|
+
|
|
258
|
+
assert result is None
|
|
259
|
+
mock_logger.warning.assert_called_with(
|
|
260
|
+
"Unexpected error reading resource '%s': %s", "file:///bad", ANY
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
@pytest.mark.asyncio
|
|
264
|
+
async def test_read_resource_internal_error_not_verbose(self):
|
|
265
|
+
"""Test reading a resource with error in non-verbose mode."""
|
|
266
|
+
config = {"server1": {"command": "test"}}
|
|
267
|
+
|
|
268
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
269
|
+
mock_fastmcp = AsyncMock()
|
|
270
|
+
mock_fastmcp.read_resource.side_effect = Exception("Read failed")
|
|
271
|
+
mock_client_class.return_value = mock_fastmcp
|
|
272
|
+
|
|
273
|
+
client = FastMCPHUDClient(config, verbose=False)
|
|
274
|
+
client._initialized = True
|
|
275
|
+
client._client = mock_fastmcp # Set the mock client
|
|
276
|
+
|
|
277
|
+
with patch("hud.clients.fastmcp.logger") as mock_logger:
|
|
278
|
+
result = await client.read_resource("file:///bad")
|
|
279
|
+
|
|
280
|
+
assert result is None
|
|
281
|
+
# Should not log in non-verbose mode
|
|
282
|
+
mock_logger.debug.assert_not_called()
|
|
283
|
+
|
|
284
|
+
@pytest.mark.asyncio
|
|
285
|
+
async def test_shutdown(self):
|
|
286
|
+
"""Test shutting down the client."""
|
|
287
|
+
config = {"server1": {"command": "test"}}
|
|
288
|
+
|
|
289
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
290
|
+
mock_fastmcp = AsyncMock()
|
|
291
|
+
mock_client_class.return_value = mock_fastmcp
|
|
292
|
+
|
|
293
|
+
client = FastMCPHUDClient(config)
|
|
294
|
+
|
|
295
|
+
# Set up stack and client
|
|
296
|
+
mock_stack = AsyncMock()
|
|
297
|
+
client._stack = mock_stack
|
|
298
|
+
client._initialized = True
|
|
299
|
+
client._client = mock_fastmcp # Set the mock client
|
|
300
|
+
|
|
301
|
+
with patch("hud.clients.fastmcp.logger") as mock_logger:
|
|
302
|
+
await client.shutdown()
|
|
303
|
+
|
|
304
|
+
mock_stack.aclose.assert_called_once()
|
|
305
|
+
assert client._stack is None
|
|
306
|
+
assert client._initialized is False
|
|
307
|
+
mock_logger.debug.assert_called_with("FastMCP client closed")
|
|
308
|
+
|
|
309
|
+
@pytest.mark.asyncio
|
|
310
|
+
async def test_shutdown_no_stack(self):
|
|
311
|
+
"""Test shutting down when no stack exists."""
|
|
312
|
+
config = {"server1": {"command": "test"}}
|
|
313
|
+
|
|
314
|
+
with patch("hud.clients.fastmcp.FastMCPClient"):
|
|
315
|
+
client = FastMCPHUDClient(config)
|
|
316
|
+
client._stack = None
|
|
317
|
+
|
|
318
|
+
# Should not raise error
|
|
319
|
+
await client.shutdown()
|
|
320
|
+
|
|
321
|
+
assert client._stack is None
|
|
322
|
+
|
|
323
|
+
@pytest.mark.asyncio
|
|
324
|
+
async def test_context_manager(self):
|
|
325
|
+
"""Test using client as async context manager."""
|
|
326
|
+
config = {"server1": {"command": "test"}}
|
|
327
|
+
|
|
328
|
+
with patch("hud.clients.fastmcp.FastMCPClient") as mock_client_class:
|
|
329
|
+
mock_fastmcp = AsyncMock()
|
|
330
|
+
mock_client_class.return_value = mock_fastmcp
|
|
331
|
+
|
|
332
|
+
client = FastMCPHUDClient(config)
|
|
333
|
+
|
|
334
|
+
with (
|
|
335
|
+
patch.object(client, "initialize", new_callable=AsyncMock) as mock_init,
|
|
336
|
+
patch.object(client, "shutdown", new_callable=AsyncMock) as mock_close,
|
|
337
|
+
):
|
|
338
|
+
async with client as ctx:
|
|
339
|
+
assert ctx is client
|
|
340
|
+
mock_init.assert_called_once()
|
|
341
|
+
|
|
342
|
+
mock_close.assert_called_once()
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Tests for the MCP client protocol and implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
from mcp import types
|
|
9
|
+
|
|
10
|
+
from hud.clients.base import AgentMCPClient, BaseHUDClient
|
|
11
|
+
from hud.clients.fastmcp import FastMCPHUDClient
|
|
12
|
+
from hud.clients.mcp_use import MCPUseHUDClient
|
|
13
|
+
from hud.types import MCPToolCall, MCPToolResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class MockClient(BaseHUDClient):
|
|
17
|
+
"""Mock client for testing the base class."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, **kwargs):
|
|
20
|
+
super().__init__(mcp_config={"test": {"url": "mock://test"}}, **kwargs)
|
|
21
|
+
self._connected = False
|
|
22
|
+
self._mock_tools = [
|
|
23
|
+
types.Tool(
|
|
24
|
+
name="test_tool",
|
|
25
|
+
description="A test tool",
|
|
26
|
+
inputSchema={"type": "object", "properties": {}},
|
|
27
|
+
)
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
async def _connect(self, mcp_config: dict[str, dict[str, Any]]) -> None:
|
|
31
|
+
self._connected = True
|
|
32
|
+
|
|
33
|
+
async def list_tools(self) -> list[types.Tool]:
|
|
34
|
+
if not self._connected:
|
|
35
|
+
raise RuntimeError("Not connected")
|
|
36
|
+
return self._mock_tools
|
|
37
|
+
|
|
38
|
+
async def list_resources(self) -> list[types.Resource]:
|
|
39
|
+
"""Minimal list_resources for protocol satisfaction in tests."""
|
|
40
|
+
return []
|
|
41
|
+
|
|
42
|
+
async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
|
|
43
|
+
if tool_call.name == "test_tool":
|
|
44
|
+
return MCPToolResult(
|
|
45
|
+
content=[types.TextContent(type="text", text="Success")], isError=False
|
|
46
|
+
)
|
|
47
|
+
raise ValueError(f"Tool {tool_call.name} not found")
|
|
48
|
+
|
|
49
|
+
async def read_resource(self, uri: str) -> types.ReadResourceResult | None:
|
|
50
|
+
if uri == "telemetry://live":
|
|
51
|
+
from pydantic import AnyUrl
|
|
52
|
+
|
|
53
|
+
return types.ReadResourceResult(
|
|
54
|
+
contents=[
|
|
55
|
+
types.TextResourceContents(
|
|
56
|
+
uri=AnyUrl(uri),
|
|
57
|
+
mimeType="application/json",
|
|
58
|
+
text='{"status": "healthy", "services": {"api": "running"}}',
|
|
59
|
+
)
|
|
60
|
+
]
|
|
61
|
+
)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
async def _disconnect(self) -> None:
|
|
65
|
+
"""Disconnect from the MCP server."""
|
|
66
|
+
self._connected = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestProtocol:
|
|
70
|
+
"""Test that all clients implement the protocol correctly."""
|
|
71
|
+
|
|
72
|
+
def test_mock_client_implements_protocol(self):
|
|
73
|
+
"""Test that our mock client implements the protocol."""
|
|
74
|
+
client = MockClient()
|
|
75
|
+
assert isinstance(client, AgentMCPClient)
|
|
76
|
+
|
|
77
|
+
def test_fastmcp_client_implements_protocol(self):
|
|
78
|
+
"""Test that FastMCPHUDClient implements the protocol."""
|
|
79
|
+
client = FastMCPHUDClient({"test": {"url": "http://localhost"}})
|
|
80
|
+
assert isinstance(client, AgentMCPClient)
|
|
81
|
+
|
|
82
|
+
def test_mcp_use_client_implements_protocol(self):
|
|
83
|
+
"""Test that MCPUseHUDClient implements the protocol."""
|
|
84
|
+
client = MCPUseHUDClient({"test": {"url": "http://localhost"}})
|
|
85
|
+
assert isinstance(client, AgentMCPClient)
|
|
86
|
+
|
|
87
|
+
@pytest.mark.asyncio
|
|
88
|
+
async def test_base_client_initialization(self):
|
|
89
|
+
"""Test that base client initialization works correctly."""
|
|
90
|
+
client = MockClient()
|
|
91
|
+
|
|
92
|
+
# Not initialized yet
|
|
93
|
+
assert not client._initialized
|
|
94
|
+
# Can't call list_tools before initialization, it would raise an error
|
|
95
|
+
|
|
96
|
+
# Initialize
|
|
97
|
+
await client.initialize()
|
|
98
|
+
|
|
99
|
+
# Should be initialized with tools discovered
|
|
100
|
+
assert client._initialized
|
|
101
|
+
tools = await client.list_tools()
|
|
102
|
+
assert len(tools) == 1
|
|
103
|
+
assert tools[0].name == "test_tool"
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_telemetry_fetching(self):
|
|
107
|
+
"""Test that telemetry is fetched during initialization."""
|
|
108
|
+
client = MockClient()
|
|
109
|
+
|
|
110
|
+
# No telemetry before initialization
|
|
111
|
+
assert not hasattr(client, "_telemetry_data") or client._telemetry_data == {}
|
|
112
|
+
|
|
113
|
+
# Initialize
|
|
114
|
+
await client.initialize()
|
|
115
|
+
|
|
116
|
+
# Should have telemetry
|
|
117
|
+
assert hasattr(client, "_telemetry_data")
|
|
118
|
+
assert client._telemetry_data["status"] == "healthy"
|
|
119
|
+
assert client._telemetry_data["services"]["api"] == "running"
|
|
120
|
+
|
|
121
|
+
@pytest.mark.asyncio
|
|
122
|
+
async def test_context_manager(self):
|
|
123
|
+
"""Test that clients work as context managers."""
|
|
124
|
+
client = MockClient()
|
|
125
|
+
|
|
126
|
+
async with client:
|
|
127
|
+
assert client._initialized
|
|
128
|
+
tools = await client.list_tools()
|
|
129
|
+
assert len(tools) == 1
|
|
130
|
+
|
|
131
|
+
# Should be closed after exiting context
|
|
132
|
+
assert not client._initialized
|
|
133
|
+
|
|
134
|
+
@pytest.mark.asyncio
|
|
135
|
+
async def test_tool_execution(self):
|
|
136
|
+
"""Test tool execution through the protocol."""
|
|
137
|
+
client = MockClient()
|
|
138
|
+
|
|
139
|
+
await client.initialize()
|
|
140
|
+
|
|
141
|
+
# Execute a tool - test both call signatures
|
|
142
|
+
# Test with MCPToolCall
|
|
143
|
+
tool_call = MCPToolCall(name="test_tool", arguments={"arg": "value"})
|
|
144
|
+
result = await client.call_tool(tool_call)
|
|
145
|
+
|
|
146
|
+
assert isinstance(result, MCPToolResult)
|
|
147
|
+
assert not result.isError
|
|
148
|
+
from mcp.types import TextContent
|
|
149
|
+
|
|
150
|
+
assert isinstance(result.content[0], TextContent) and result.content[0].text == "Success"
|
|
151
|
+
|
|
152
|
+
# Test with name/arguments
|
|
153
|
+
result2 = await client.call_tool(name="test_tool", arguments={"arg": "value"})
|
|
154
|
+
assert isinstance(result2, MCPToolResult)
|
|
155
|
+
assert not result2.isError
|
|
156
|
+
assert isinstance(result2.content[0], TextContent) and result2.content[0].text == "Success"
|
|
157
|
+
|
|
158
|
+
@pytest.mark.asyncio
|
|
159
|
+
async def test_tool_not_found(self):
|
|
160
|
+
"""Test error handling for missing tools."""
|
|
161
|
+
client = MockClient()
|
|
162
|
+
|
|
163
|
+
await client.initialize()
|
|
164
|
+
|
|
165
|
+
# Try to execute non-existent tool
|
|
166
|
+
with pytest.raises(ValueError, match="Tool unknown_tool not found"):
|
|
167
|
+
await client.call_tool(name="unknown_tool", arguments={})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class TestClientCompatibility:
|
|
171
|
+
"""Test that clients are compatible with agents."""
|
|
172
|
+
|
|
173
|
+
def test_protocol_satisfied(self):
|
|
174
|
+
"""Test that all clients satisfy the protocol."""
|
|
175
|
+
# Test mock client
|
|
176
|
+
mock_client = MockClient()
|
|
177
|
+
assert isinstance(mock_client, AgentMCPClient)
|
|
178
|
+
assert hasattr(mock_client, "initialize")
|
|
179
|
+
assert hasattr(mock_client, "list_tools")
|
|
180
|
+
assert hasattr(mock_client, "call_tool")
|
|
181
|
+
|
|
182
|
+
# Test FastMCP client
|
|
183
|
+
fastmcp_client = FastMCPHUDClient({"test": {"url": "http://localhost"}})
|
|
184
|
+
assert isinstance(fastmcp_client, AgentMCPClient)
|
|
185
|
+
|
|
186
|
+
# Test MCP-use client
|
|
187
|
+
mcp_use_client = MCPUseHUDClient({"test": {"url": "http://localhost"}})
|
|
188
|
+
assert isinstance(mcp_use_client, AgentMCPClient)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""HUD MCP client utilities."""
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Custom HTTPX transport with retry logic for HTTP errors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
from httpx._transports.default import AsyncHTTPTransport
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from httpx._models import Request, Response
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RetryTransport(AsyncHTTPTransport):
|
|
19
|
+
"""
|
|
20
|
+
Custom HTTPX transport that retries on specific HTTP status codes.
|
|
21
|
+
|
|
22
|
+
This transport wraps the standard AsyncHTTPTransport and adds
|
|
23
|
+
retry logic with exponential backoff for gateway errors (502, 503, 504).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
*args: Any,
|
|
29
|
+
max_retries: int = 3,
|
|
30
|
+
retry_status_codes: set[int] | None = None,
|
|
31
|
+
retry_delay: float = 1.0,
|
|
32
|
+
backoff_factor: float = 2.0,
|
|
33
|
+
**kwargs: Any,
|
|
34
|
+
) -> None:
|
|
35
|
+
"""
|
|
36
|
+
Initialize retry transport.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
max_retries: Maximum number of retry attempts
|
|
40
|
+
retry_status_codes: HTTP status codes to retry (default: 502, 503, 504)
|
|
41
|
+
retry_delay: Initial delay between retries in seconds
|
|
42
|
+
backoff_factor: Multiplier for exponential backoff
|
|
43
|
+
*args, **kwargs: Passed to AsyncHTTPTransport
|
|
44
|
+
"""
|
|
45
|
+
super().__init__(*args, **kwargs)
|
|
46
|
+
self.max_retries = max_retries
|
|
47
|
+
self.retry_status_codes = retry_status_codes or {502, 503, 504}
|
|
48
|
+
self.retry_delay = retry_delay
|
|
49
|
+
self.backoff_factor = backoff_factor
|
|
50
|
+
|
|
51
|
+
async def handle_async_request(self, request: Request) -> Response:
|
|
52
|
+
"""
|
|
53
|
+
Handle request with retry logic.
|
|
54
|
+
|
|
55
|
+
Retries the request if it fails with a retryable status code,
|
|
56
|
+
using exponential backoff between attempts.
|
|
57
|
+
"""
|
|
58
|
+
last_exception = None
|
|
59
|
+
|
|
60
|
+
for attempt in range(self.max_retries + 1):
|
|
61
|
+
try:
|
|
62
|
+
response = await super().handle_async_request(request)
|
|
63
|
+
|
|
64
|
+
# Check if we should retry based on status code
|
|
65
|
+
if response.status_code in self.retry_status_codes and attempt < self.max_retries:
|
|
66
|
+
delay = self.retry_delay * (self.backoff_factor**attempt)
|
|
67
|
+
logger.warning(
|
|
68
|
+
"Got %d from %s, retrying in %.1fs (attempt %d/%d)",
|
|
69
|
+
response.status_code,
|
|
70
|
+
request.url,
|
|
71
|
+
delay,
|
|
72
|
+
attempt + 1,
|
|
73
|
+
self.max_retries,
|
|
74
|
+
)
|
|
75
|
+
# Important: Close the response to free resources
|
|
76
|
+
await response.aclose()
|
|
77
|
+
await asyncio.sleep(delay)
|
|
78
|
+
continue
|
|
79
|
+
|
|
80
|
+
return response
|
|
81
|
+
|
|
82
|
+
except (httpx.ConnectError, httpx.TimeoutException) as e:
|
|
83
|
+
last_exception = e
|
|
84
|
+
if attempt < self.max_retries:
|
|
85
|
+
delay = self.retry_delay * (self.backoff_factor**attempt)
|
|
86
|
+
logger.warning(
|
|
87
|
+
"%s for %s, retrying in %.1fs (attempt %d/%d)",
|
|
88
|
+
type(e).__name__,
|
|
89
|
+
request.url,
|
|
90
|
+
delay,
|
|
91
|
+
attempt + 1,
|
|
92
|
+
self.max_retries,
|
|
93
|
+
)
|
|
94
|
+
await asyncio.sleep(delay)
|
|
95
|
+
continue
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
# If we get here, we've exhausted retries
|
|
99
|
+
if last_exception:
|
|
100
|
+
raise last_exception
|
|
101
|
+
else:
|
|
102
|
+
# This shouldn't happen, but just in case
|
|
103
|
+
raise httpx.HTTPStatusError(
|
|
104
|
+
"Max retries exceeded",
|
|
105
|
+
request=request,
|
|
106
|
+
response=response,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def create_retry_httpx_client(
|
|
111
|
+
headers: dict[str, str] | None = None,
|
|
112
|
+
timeout: httpx.Timeout | None = None,
|
|
113
|
+
auth: httpx.Auth | None = None,
|
|
114
|
+
max_retries: int = 3,
|
|
115
|
+
retry_status_codes: set[int] | None = None,
|
|
116
|
+
) -> httpx.AsyncClient:
|
|
117
|
+
"""
|
|
118
|
+
Create an HTTPX AsyncClient with HTTP error retry support.
|
|
119
|
+
|
|
120
|
+
This factory creates an HTTPX client with a custom transport that
|
|
121
|
+
retries on specific HTTP status codes (502, 503, 504 by default).
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
headers: Optional headers to include with all requests
|
|
125
|
+
timeout: Request timeout (defaults to 600s)
|
|
126
|
+
auth: Optional authentication handler
|
|
127
|
+
max_retries: Maximum retry attempts (default: 3)
|
|
128
|
+
retry_status_codes: Status codes to retry (default: {502, 503, 504})
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
Configured httpx.AsyncClient with retry transport
|
|
132
|
+
"""
|
|
133
|
+
if timeout is None:
|
|
134
|
+
timeout = httpx.Timeout(600.0) # 10 minutes
|
|
135
|
+
|
|
136
|
+
# Use higher connection limits for concurrent operations
|
|
137
|
+
# These match HUD server's configuration for consistency
|
|
138
|
+
limits = httpx.Limits(
|
|
139
|
+
max_connections=1000,
|
|
140
|
+
max_keepalive_connections=1000,
|
|
141
|
+
keepalive_expiry=20.0,
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# Create our custom retry transport
|
|
145
|
+
transport = RetryTransport(
|
|
146
|
+
max_retries=max_retries,
|
|
147
|
+
retry_status_codes=retry_status_codes,
|
|
148
|
+
# Connection-level retries (in addition to HTTP retries)
|
|
149
|
+
retries=3,
|
|
150
|
+
limits=limits,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
return httpx.AsyncClient(
|
|
154
|
+
transport=transport,
|
|
155
|
+
headers=headers,
|
|
156
|
+
timeout=timeout,
|
|
157
|
+
auth=auth,
|
|
158
|
+
follow_redirects=True,
|
|
159
|
+
limits=limits,
|
|
160
|
+
)
|