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.

Files changed (192) hide show
  1. hud/__init__.py +22 -89
  2. hud/agents/__init__.py +15 -0
  3. hud/agents/art.py +101 -0
  4. hud/agents/base.py +599 -0
  5. hud/{mcp → agents}/claude.py +373 -321
  6. hud/{mcp → agents}/langchain.py +250 -250
  7. hud/agents/misc/__init__.py +7 -0
  8. hud/{agent → agents}/misc/response_agent.py +80 -80
  9. hud/{mcp → agents}/openai.py +352 -334
  10. hud/agents/openai_chat_generic.py +154 -0
  11. hud/{mcp → agents}/tests/__init__.py +1 -1
  12. hud/agents/tests/test_base.py +742 -0
  13. hud/agents/tests/test_claude.py +324 -0
  14. hud/{mcp → agents}/tests/test_client.py +363 -324
  15. hud/{mcp → agents}/tests/test_openai.py +237 -238
  16. hud/cli/__init__.py +617 -0
  17. hud/cli/__main__.py +8 -0
  18. hud/cli/analyze.py +371 -0
  19. hud/cli/analyze_metadata.py +230 -0
  20. hud/cli/build.py +427 -0
  21. hud/cli/clone.py +185 -0
  22. hud/cli/cursor.py +92 -0
  23. hud/cli/debug.py +392 -0
  24. hud/cli/docker_utils.py +83 -0
  25. hud/cli/init.py +281 -0
  26. hud/cli/interactive.py +353 -0
  27. hud/cli/mcp_server.py +756 -0
  28. hud/cli/pull.py +336 -0
  29. hud/cli/push.py +370 -0
  30. hud/cli/remote_runner.py +311 -0
  31. hud/cli/runner.py +160 -0
  32. hud/cli/tests/__init__.py +3 -0
  33. hud/cli/tests/test_analyze.py +284 -0
  34. hud/cli/tests/test_cli_init.py +265 -0
  35. hud/cli/tests/test_cli_main.py +27 -0
  36. hud/cli/tests/test_clone.py +142 -0
  37. hud/cli/tests/test_cursor.py +253 -0
  38. hud/cli/tests/test_debug.py +453 -0
  39. hud/cli/tests/test_mcp_server.py +139 -0
  40. hud/cli/tests/test_utils.py +388 -0
  41. hud/cli/utils.py +263 -0
  42. hud/clients/README.md +143 -0
  43. hud/clients/__init__.py +16 -0
  44. hud/clients/base.py +379 -0
  45. hud/clients/fastmcp.py +222 -0
  46. hud/clients/mcp_use.py +278 -0
  47. hud/clients/tests/__init__.py +1 -0
  48. hud/clients/tests/test_client_integration.py +111 -0
  49. hud/clients/tests/test_fastmcp.py +342 -0
  50. hud/clients/tests/test_protocol.py +188 -0
  51. hud/clients/utils/__init__.py +1 -0
  52. hud/clients/utils/retry_transport.py +160 -0
  53. hud/datasets.py +322 -192
  54. hud/misc/__init__.py +1 -0
  55. hud/{agent → misc}/claude_plays_pokemon.py +292 -283
  56. hud/otel/__init__.py +35 -0
  57. hud/otel/collector.py +142 -0
  58. hud/otel/config.py +164 -0
  59. hud/otel/context.py +536 -0
  60. hud/otel/exporters.py +366 -0
  61. hud/otel/instrumentation.py +97 -0
  62. hud/otel/processors.py +118 -0
  63. hud/otel/tests/__init__.py +1 -0
  64. hud/otel/tests/test_processors.py +197 -0
  65. hud/server/__init__.py +5 -5
  66. hud/server/context.py +114 -0
  67. hud/server/helper/__init__.py +5 -0
  68. hud/server/low_level.py +132 -0
  69. hud/server/server.py +166 -0
  70. hud/server/tests/__init__.py +3 -0
  71. hud/settings.py +73 -79
  72. hud/shared/__init__.py +5 -0
  73. hud/{exceptions.py → shared/exceptions.py} +180 -180
  74. hud/{server → shared}/requests.py +264 -264
  75. hud/shared/tests/test_exceptions.py +157 -0
  76. hud/{server → shared}/tests/test_requests.py +275 -275
  77. hud/telemetry/__init__.py +25 -30
  78. hud/telemetry/instrument.py +379 -0
  79. hud/telemetry/job.py +309 -141
  80. hud/telemetry/replay.py +74 -0
  81. hud/telemetry/trace.py +83 -0
  82. hud/tools/__init__.py +33 -34
  83. hud/tools/base.py +365 -65
  84. hud/tools/bash.py +161 -137
  85. hud/tools/computer/__init__.py +15 -13
  86. hud/tools/computer/anthropic.py +437 -420
  87. hud/tools/computer/hud.py +376 -334
  88. hud/tools/computer/openai.py +295 -292
  89. hud/tools/computer/settings.py +82 -0
  90. hud/tools/edit.py +314 -290
  91. hud/tools/executors/__init__.py +30 -30
  92. hud/tools/executors/base.py +539 -532
  93. hud/tools/executors/pyautogui.py +621 -619
  94. hud/tools/executors/tests/__init__.py +1 -1
  95. hud/tools/executors/tests/test_base_executor.py +338 -338
  96. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  97. hud/tools/executors/xdo.py +511 -503
  98. hud/tools/{playwright_tool.py → playwright.py} +412 -379
  99. hud/tools/tests/__init__.py +3 -3
  100. hud/tools/tests/test_base.py +282 -0
  101. hud/tools/tests/test_bash.py +158 -152
  102. hud/tools/tests/test_bash_extended.py +197 -0
  103. hud/tools/tests/test_computer.py +425 -52
  104. hud/tools/tests/test_computer_actions.py +34 -34
  105. hud/tools/tests/test_edit.py +259 -240
  106. hud/tools/tests/test_init.py +27 -27
  107. hud/tools/tests/test_playwright_tool.py +183 -183
  108. hud/tools/tests/test_tools.py +145 -157
  109. hud/tools/tests/test_utils.py +156 -156
  110. hud/tools/types.py +72 -0
  111. hud/tools/utils.py +50 -50
  112. hud/types.py +136 -89
  113. hud/utils/__init__.py +10 -16
  114. hud/utils/async_utils.py +65 -0
  115. hud/utils/design.py +168 -0
  116. hud/utils/mcp.py +55 -0
  117. hud/utils/progress.py +149 -149
  118. hud/utils/telemetry.py +66 -66
  119. hud/utils/tests/test_async_utils.py +173 -0
  120. hud/utils/tests/test_init.py +17 -21
  121. hud/utils/tests/test_progress.py +261 -225
  122. hud/utils/tests/test_telemetry.py +82 -37
  123. hud/utils/tests/test_version.py +8 -8
  124. hud/version.py +7 -7
  125. hud_python-0.4.1.dist-info/METADATA +476 -0
  126. hud_python-0.4.1.dist-info/RECORD +132 -0
  127. hud_python-0.4.1.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.5.dist-info → hud_python-0.4.1.dist-info}/licenses/LICENSE +21 -21
  129. hud/adapters/__init__.py +0 -8
  130. hud/adapters/claude/__init__.py +0 -5
  131. hud/adapters/claude/adapter.py +0 -180
  132. hud/adapters/claude/tests/__init__.py +0 -1
  133. hud/adapters/claude/tests/test_adapter.py +0 -519
  134. hud/adapters/common/__init__.py +0 -6
  135. hud/adapters/common/adapter.py +0 -178
  136. hud/adapters/common/tests/test_adapter.py +0 -289
  137. hud/adapters/common/types.py +0 -446
  138. hud/adapters/operator/__init__.py +0 -5
  139. hud/adapters/operator/adapter.py +0 -108
  140. hud/adapters/operator/tests/__init__.py +0 -1
  141. hud/adapters/operator/tests/test_adapter.py +0 -370
  142. hud/agent/__init__.py +0 -19
  143. hud/agent/base.py +0 -126
  144. hud/agent/claude.py +0 -271
  145. hud/agent/langchain.py +0 -215
  146. hud/agent/misc/__init__.py +0 -3
  147. hud/agent/operator.py +0 -268
  148. hud/agent/tests/__init__.py +0 -1
  149. hud/agent/tests/test_base.py +0 -202
  150. hud/env/__init__.py +0 -11
  151. hud/env/client.py +0 -35
  152. hud/env/docker_client.py +0 -349
  153. hud/env/environment.py +0 -446
  154. hud/env/local_docker_client.py +0 -358
  155. hud/env/remote_client.py +0 -212
  156. hud/env/remote_docker_client.py +0 -292
  157. hud/gym.py +0 -130
  158. hud/job.py +0 -773
  159. hud/mcp/__init__.py +0 -17
  160. hud/mcp/base.py +0 -631
  161. hud/mcp/client.py +0 -312
  162. hud/mcp/tests/test_base.py +0 -512
  163. hud/mcp/tests/test_claude.py +0 -294
  164. hud/task.py +0 -149
  165. hud/taskset.py +0 -237
  166. hud/telemetry/_trace.py +0 -347
  167. hud/telemetry/context.py +0 -230
  168. hud/telemetry/exporter.py +0 -575
  169. hud/telemetry/instrumentation/__init__.py +0 -3
  170. hud/telemetry/instrumentation/mcp.py +0 -259
  171. hud/telemetry/instrumentation/registry.py +0 -59
  172. hud/telemetry/mcp_models.py +0 -270
  173. hud/telemetry/tests/__init__.py +0 -1
  174. hud/telemetry/tests/test_context.py +0 -210
  175. hud/telemetry/tests/test_trace.py +0 -312
  176. hud/tools/helper/README.md +0 -56
  177. hud/tools/helper/__init__.py +0 -9
  178. hud/tools/helper/mcp_server.py +0 -78
  179. hud/tools/helper/server_initialization.py +0 -115
  180. hud/tools/helper/utils.py +0 -58
  181. hud/trajectory.py +0 -94
  182. hud/utils/agent.py +0 -37
  183. hud/utils/common.py +0 -256
  184. hud/utils/config.py +0 -120
  185. hud/utils/deprecation.py +0 -115
  186. hud/utils/misc.py +0 -53
  187. hud/utils/tests/test_common.py +0 -277
  188. hud/utils/tests/test_config.py +0 -129
  189. hud_python-0.3.5.dist-info/METADATA +0 -284
  190. hud_python-0.3.5.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {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
+ )