hud-python 0.5.1__py3-none-any.whl → 0.5.13__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.
Files changed (67) hide show
  1. hud/__init__.py +1 -1
  2. hud/agents/__init__.py +65 -6
  3. hud/agents/base.py +33 -15
  4. hud/agents/claude.py +60 -31
  5. hud/agents/gateway.py +42 -0
  6. hud/agents/gemini.py +15 -26
  7. hud/agents/gemini_cua.py +6 -17
  8. hud/agents/misc/response_agent.py +7 -0
  9. hud/agents/openai.py +16 -29
  10. hud/agents/openai_chat.py +3 -19
  11. hud/agents/operator.py +5 -17
  12. hud/agents/resolver.py +70 -0
  13. hud/agents/tests/test_claude.py +2 -4
  14. hud/agents/tests/test_openai.py +2 -1
  15. hud/agents/tests/test_resolver.py +192 -0
  16. hud/agents/types.py +148 -0
  17. hud/cli/__init__.py +34 -3
  18. hud/cli/build.py +37 -5
  19. hud/cli/dev.py +11 -2
  20. hud/cli/eval.py +51 -39
  21. hud/cli/flows/init.py +1 -1
  22. hud/cli/pull.py +1 -1
  23. hud/cli/push.py +9 -2
  24. hud/cli/tests/test_build.py +2 -2
  25. hud/cli/tests/test_push.py +1 -1
  26. hud/cli/utils/metadata.py +1 -1
  27. hud/cli/utils/tests/test_metadata.py +1 -1
  28. hud/clients/mcp_use.py +6 -1
  29. hud/datasets/loader.py +17 -18
  30. hud/datasets/runner.py +16 -10
  31. hud/datasets/tests/test_loader.py +15 -15
  32. hud/environment/__init__.py +5 -3
  33. hud/environment/connection.py +58 -6
  34. hud/environment/connectors/mcp_config.py +29 -1
  35. hud/environment/environment.py +218 -77
  36. hud/environment/router.py +175 -24
  37. hud/environment/scenarios.py +313 -186
  38. hud/environment/tests/test_connectors.py +10 -23
  39. hud/environment/tests/test_environment.py +432 -0
  40. hud/environment/tests/test_local_connectors.py +81 -40
  41. hud/environment/tests/test_scenarios.py +820 -14
  42. hud/eval/context.py +63 -10
  43. hud/eval/instrument.py +4 -2
  44. hud/eval/manager.py +79 -12
  45. hud/eval/task.py +36 -4
  46. hud/eval/tests/test_eval.py +1 -1
  47. hud/eval/tests/test_task.py +147 -1
  48. hud/eval/types.py +2 -0
  49. hud/eval/utils.py +14 -3
  50. hud/patches/mcp_patches.py +178 -21
  51. hud/telemetry/instrument.py +8 -1
  52. hud/telemetry/tests/test_eval_telemetry.py +8 -8
  53. hud/tools/__init__.py +2 -0
  54. hud/tools/agent.py +223 -0
  55. hud/tools/computer/__init__.py +34 -5
  56. hud/tools/shell.py +3 -3
  57. hud/tools/tests/test_agent_tool.py +355 -0
  58. hud/types.py +62 -34
  59. hud/utils/hud_console.py +30 -17
  60. hud/utils/strict_schema.py +1 -1
  61. hud/utils/tests/test_version.py +1 -1
  62. hud/version.py +1 -1
  63. {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/METADATA +2 -2
  64. {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/RECORD +67 -61
  65. {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/WHEEL +0 -0
  66. {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/entry_points.txt +0 -0
  67. {hud_python-0.5.1.dist-info → hud_python-0.5.13.dist-info}/licenses/LICENSE +0 -0
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
- from unittest.mock import MagicMock, patch
6
+ from unittest.mock import patch
7
7
 
8
8
  from hud.environment.connection import ConnectionType, Connector
9
9
 
@@ -180,39 +180,26 @@ class TestRemoteConnectorMixin:
180
180
  conn = env._connections["example"]
181
181
  assert conn._auth == "Bearer my-token"
182
182
 
183
- @patch("httpx.Client")
184
- def test_connect_hub_fetches_config(self, mock_httpx_cls: MagicMock) -> None:
185
- """connect_hub fetches mcp_config from API."""
183
+ def test_connect_hub_creates_connection(self) -> None:
184
+ """connect_hub creates connection with correct config."""
186
185
  from hud.environment.connectors.remote import RemoteConnectorMixin
187
186
 
188
187
  class TestEnv(RemoteConnectorMixin):
189
188
  def __init__(self) -> None:
190
189
  self._connections: dict[str, Connector] = {}
190
+ self._hub_config: dict[str, Any] | None = None
191
191
 
192
192
  def mount(self, server: Any, *, prefix: str | None = None) -> None:
193
193
  pass
194
194
 
195
- # Mock httpx response
196
- mock_response = MagicMock()
197
- mock_response.json.return_value = {
198
- "mcp_config": {
199
- "browser": {"url": "https://mcp.hud.ai/browser"},
200
- }
201
- }
202
- mock_response.raise_for_status = MagicMock()
203
-
204
- mock_client = MagicMock()
205
- mock_client.get.return_value = mock_response
206
- mock_client.__enter__ = MagicMock(return_value=mock_client)
207
- mock_client.__exit__ = MagicMock(return_value=None)
208
- mock_httpx_cls.return_value = mock_client
209
-
210
195
  env = TestEnv()
211
196
  with patch("hud.settings.settings") as mock_settings:
212
- mock_settings.hud_api_url = "https://api.hud.so"
213
- mock_settings.api_key = "test-key"
197
+ mock_settings.hud_mcp_url = "https://mcp.hud.ai"
198
+ mock_settings.client_timeout = 300 # Used in connect_mcp for sse_read_timeout
214
199
 
215
- env.connect_hub("hud/browser")
200
+ env.connect_hub("browser")
216
201
 
217
- # connect_hub creates a connection named "hud" (the server name)
202
+ # connect_hub creates a connection named "hud" (from mcp_config key)
218
203
  assert "hud" in env._connections
204
+ # Verify hub config is stored for serialization
205
+ assert env._hub_config == {"name": "browser"}
@@ -159,3 +159,435 @@ class TestEnvironmentSetupEvaluate:
159
159
  )
160
160
 
161
161
  assert len(env._setup_calls) == 2
162
+
163
+
164
+ class TestEnvironmentMCPProtocol:
165
+ """Tests for MCP protocol overrides - Environment._env_list_tools and _env_call_tool.
166
+
167
+ These test that Environment properly exposes connector tools via MCP handlers.
168
+ """
169
+
170
+ @pytest.mark.asyncio
171
+ async def test_env_list_tools_includes_local_tools(self) -> None:
172
+ """_env_list_tools returns local tools after routing is built."""
173
+ from hud.environment import Environment
174
+
175
+ env = Environment("test")
176
+
177
+ @env.tool()
178
+ def my_tool(x: int) -> int:
179
+ """A test tool."""
180
+ return x * 2
181
+
182
+ # Build routing (simulates what __aenter__ does)
183
+ await env._build_routing()
184
+
185
+ # Call the handler that MCP will call
186
+ tools = await env._env_list_tools()
187
+
188
+ assert len(tools) == 1
189
+ assert tools[0].name == "my_tool"
190
+
191
+ @pytest.mark.asyncio
192
+ async def test_env_list_tools_includes_connector_tools(self) -> None:
193
+ """_env_list_tools returns tools from connectors (the key feature)."""
194
+ import mcp.types as mcp_types
195
+
196
+ from hud.environment import Environment
197
+
198
+ env = Environment("test")
199
+
200
+ # Create a mock connector with cached tools
201
+ mock_tools = [
202
+ mcp_types.Tool(
203
+ name="remote_tool",
204
+ description="A remote tool",
205
+ inputSchema={"type": "object"},
206
+ )
207
+ ]
208
+
209
+ class MockConnector:
210
+ is_connected = True
211
+ _tools_cache = mock_tools
212
+
213
+ @property
214
+ def cached_tools(self) -> list[mcp_types.Tool]:
215
+ return self._tools_cache
216
+
217
+ @property
218
+ def cached_prompts(self) -> list[mcp_types.Prompt]:
219
+ return []
220
+
221
+ @property
222
+ def cached_resources(self) -> list[mcp_types.Resource]:
223
+ return []
224
+
225
+ async def connect(self) -> None:
226
+ pass
227
+
228
+ async def disconnect(self) -> None:
229
+ pass
230
+
231
+ async def list_tools(self) -> list[mcp_types.Tool]:
232
+ return self._tools_cache
233
+
234
+ # Add the mock connector
235
+ env._connections["mock"] = MockConnector() # type: ignore
236
+
237
+ # Build routing
238
+ await env._build_routing()
239
+
240
+ # Call the handler that MCP will call
241
+ tools = await env._env_list_tools()
242
+
243
+ # Should include the remote tool
244
+ tool_names = [t.name for t in tools]
245
+ assert "remote_tool" in tool_names
246
+
247
+ @pytest.mark.asyncio
248
+ async def test_env_call_tool_routes_to_local(self) -> None:
249
+ """_env_call_tool routes local tool calls correctly."""
250
+ from hud.environment import Environment
251
+
252
+ env = Environment("test")
253
+ called_with: list[int] = []
254
+
255
+ @env.tool()
256
+ def my_tool(x: int) -> str:
257
+ """A test tool."""
258
+ called_with.append(x)
259
+ return f"result: {x}"
260
+
261
+ # Build routing
262
+ await env._build_routing()
263
+
264
+ # Call the handler that MCP will call
265
+ result = await env._env_call_tool("my_tool", {"x": 42})
266
+
267
+ assert called_with == [42]
268
+ assert len(result) == 1
269
+
270
+ @pytest.mark.asyncio
271
+ async def test_env_call_tool_routes_to_connector(self) -> None:
272
+ """_env_call_tool routes connector tool calls correctly."""
273
+ from unittest.mock import AsyncMock
274
+
275
+ import mcp.types as mcp_types
276
+
277
+ from hud.environment import Environment
278
+ from hud.types import MCPToolResult
279
+
280
+ env = Environment("test")
281
+
282
+ # Create a mock connector
283
+ mock_tools = [
284
+ mcp_types.Tool(
285
+ name="remote_tool",
286
+ description="A remote tool",
287
+ inputSchema={"type": "object"},
288
+ )
289
+ ]
290
+
291
+ class MockConnector:
292
+ is_connected = True
293
+ _tools_cache = mock_tools
294
+ call_tool = AsyncMock(
295
+ return_value=MCPToolResult(
296
+ content=[mcp_types.TextContent(type="text", text="remote result")],
297
+ isError=False,
298
+ )
299
+ )
300
+
301
+ @property
302
+ def cached_tools(self) -> list[mcp_types.Tool]:
303
+ return self._tools_cache
304
+
305
+ @property
306
+ def cached_prompts(self) -> list[mcp_types.Prompt]:
307
+ return []
308
+
309
+ @property
310
+ def cached_resources(self) -> list[mcp_types.Resource]:
311
+ return []
312
+
313
+ async def connect(self) -> None:
314
+ pass
315
+
316
+ async def disconnect(self) -> None:
317
+ pass
318
+
319
+ async def list_tools(self) -> list[mcp_types.Tool]:
320
+ return self._tools_cache
321
+
322
+ mock_conn = MockConnector()
323
+ env._connections["mock"] = mock_conn # type: ignore
324
+
325
+ # Build routing
326
+ await env._build_routing()
327
+
328
+ # Call the handler that MCP will call
329
+ result = await env._env_call_tool("remote_tool", {"arg": "value"})
330
+
331
+ # Verify the connector was called
332
+ mock_conn.call_tool.assert_called_once_with("remote_tool", {"arg": "value"})
333
+ assert len(result) == 1
334
+
335
+ def test_setup_handlers_registers_custom_handlers(self) -> None:
336
+ """Verify _setup_handlers registers our _env_list_tools and _env_call_tool."""
337
+ from hud.environment import Environment
338
+
339
+ env = Environment("test")
340
+
341
+ # Verify the custom handlers exist
342
+ assert hasattr(env, "_env_list_tools")
343
+ assert hasattr(env, "_env_call_tool")
344
+ assert callable(env._env_list_tools)
345
+ assert callable(env._env_call_tool)
346
+
347
+
348
+ class TestEnvironmentToolFiltering:
349
+ """Tests for agent-level tool filtering with wildcard support (v4 backwards compat)."""
350
+
351
+ @pytest.mark.asyncio
352
+ async def test_as_tools_no_filter(self) -> None:
353
+ """as_tools returns all tools when no filter is set."""
354
+ from hud.environment import Environment
355
+
356
+ env = Environment("test")
357
+
358
+ @env.tool()
359
+ def tool_a() -> str:
360
+ """Tool A."""
361
+ return "a"
362
+
363
+ @env.tool()
364
+ def tool_b() -> str:
365
+ """Tool B."""
366
+ return "b"
367
+
368
+ await env._build_routing()
369
+
370
+ tools = env.as_tools()
371
+ tool_names = [t.name for t in tools]
372
+
373
+ assert "tool_a" in tool_names
374
+ assert "tool_b" in tool_names
375
+
376
+ @pytest.mark.asyncio
377
+ async def test_as_tools_exact_include(self) -> None:
378
+ """as_tools filters with exact include list."""
379
+ from hud.environment import Environment
380
+
381
+ env = Environment("test")
382
+
383
+ @env.tool()
384
+ def tool_a() -> str:
385
+ """Tool A."""
386
+ return "a"
387
+
388
+ @env.tool()
389
+ def tool_b() -> str:
390
+ """Tool B."""
391
+ return "b"
392
+
393
+ env._agent_include = ["tool_a"]
394
+ await env._build_routing()
395
+
396
+ tools = env.as_tools()
397
+ tool_names = [t.name for t in tools]
398
+
399
+ assert "tool_a" in tool_names
400
+ assert "tool_b" not in tool_names
401
+
402
+ @pytest.mark.asyncio
403
+ async def test_as_tools_exact_exclude(self) -> None:
404
+ """as_tools filters with exact exclude list."""
405
+ from hud.environment import Environment
406
+
407
+ env = Environment("test")
408
+
409
+ @env.tool()
410
+ def tool_a() -> str:
411
+ """Tool A."""
412
+ return "a"
413
+
414
+ @env.tool()
415
+ def tool_b() -> str:
416
+ """Tool B."""
417
+ return "b"
418
+
419
+ env._agent_exclude = ["tool_a"]
420
+ await env._build_routing()
421
+
422
+ tools = env.as_tools()
423
+ tool_names = [t.name for t in tools]
424
+
425
+ assert "tool_a" not in tool_names
426
+ assert "tool_b" in tool_names
427
+
428
+ @pytest.mark.asyncio
429
+ async def test_as_tools_wildcard_exclude_prefix(self) -> None:
430
+ """as_tools filters with wildcard prefix pattern (e.g., 'setup_*')."""
431
+ from hud.environment import Environment
432
+
433
+ env = Environment("test")
434
+
435
+ @env.tool()
436
+ def setup_database() -> str:
437
+ """Setup tool."""
438
+ return "setup"
439
+
440
+ @env.tool()
441
+ def setup_user() -> str:
442
+ """Another setup tool."""
443
+ return "setup"
444
+
445
+ @env.tool()
446
+ def run_query() -> str:
447
+ """Regular tool."""
448
+ return "query"
449
+
450
+ env._agent_exclude = ["setup_*"]
451
+ await env._build_routing()
452
+
453
+ tools = env.as_tools()
454
+ tool_names = [t.name for t in tools]
455
+
456
+ assert "setup_database" not in tool_names
457
+ assert "setup_user" not in tool_names
458
+ assert "run_query" in tool_names
459
+
460
+ @pytest.mark.asyncio
461
+ async def test_as_tools_wildcard_exclude_contains(self) -> None:
462
+ """as_tools filters with wildcard contains pattern (e.g., '*setup*')."""
463
+ from hud.environment import Environment
464
+
465
+ env = Environment("test")
466
+
467
+ @env.tool()
468
+ def hud_setup() -> str:
469
+ """Contains setup."""
470
+ return "setup"
471
+
472
+ @env.tool()
473
+ def setup_env() -> str:
474
+ """Starts with setup."""
475
+ return "setup"
476
+
477
+ @env.tool()
478
+ def my_setup_tool() -> str:
479
+ """Contains setup in middle."""
480
+ return "setup"
481
+
482
+ @env.tool()
483
+ def run_query() -> str:
484
+ """No setup in name."""
485
+ return "query"
486
+
487
+ env._agent_exclude = ["*setup*"]
488
+ await env._build_routing()
489
+
490
+ tools = env.as_tools()
491
+ tool_names = [t.name for t in tools]
492
+
493
+ assert "hud_setup" not in tool_names
494
+ assert "setup_env" not in tool_names
495
+ assert "my_setup_tool" not in tool_names
496
+ assert "run_query" in tool_names
497
+
498
+ @pytest.mark.asyncio
499
+ async def test_as_tools_multiple_wildcard_patterns(self) -> None:
500
+ """as_tools filters with multiple wildcard patterns."""
501
+ from hud.environment import Environment
502
+
503
+ env = Environment("test")
504
+
505
+ @env.tool()
506
+ def setup_db() -> str:
507
+ """Setup tool."""
508
+ return "setup"
509
+
510
+ @env.tool()
511
+ def evaluate_result() -> str:
512
+ """Evaluate tool."""
513
+ return "evaluate"
514
+
515
+ @env.tool()
516
+ def checkout_branch() -> str:
517
+ """Checkout tool."""
518
+ return "checkout"
519
+
520
+ @env.tool()
521
+ def run_query() -> str:
522
+ """Regular tool."""
523
+ return "query"
524
+
525
+ env._agent_exclude = ["*setup*", "*evaluate*", "checkout_branch"]
526
+ await env._build_routing()
527
+
528
+ tools = env.as_tools()
529
+ tool_names = [t.name for t in tools]
530
+
531
+ assert "setup_db" not in tool_names
532
+ assert "evaluate_result" not in tool_names
533
+ assert "checkout_branch" not in tool_names
534
+ assert "run_query" in tool_names
535
+
536
+ @pytest.mark.asyncio
537
+ async def test_as_tools_wildcard_include_all(self) -> None:
538
+ """as_tools with ['*'] include pattern matches all tools."""
539
+ from hud.environment import Environment
540
+
541
+ env = Environment("test")
542
+
543
+ @env.tool()
544
+ def tool_a() -> str:
545
+ """Tool A."""
546
+ return "a"
547
+
548
+ @env.tool()
549
+ def tool_b() -> str:
550
+ """Tool B."""
551
+ return "b"
552
+
553
+ env._agent_include = ["*"]
554
+ await env._build_routing()
555
+
556
+ tools = env.as_tools()
557
+ tool_names = [t.name for t in tools]
558
+
559
+ assert "tool_a" in tool_names
560
+ assert "tool_b" in tool_names
561
+
562
+ @pytest.mark.asyncio
563
+ async def test_as_tools_include_and_exclude_combined(self) -> None:
564
+ """as_tools applies both include and exclude filters."""
565
+ from hud.environment import Environment
566
+
567
+ env = Environment("test")
568
+
569
+ @env.tool()
570
+ def browser_navigate() -> str:
571
+ """Browser tool."""
572
+ return "nav"
573
+
574
+ @env.tool()
575
+ def browser_setup() -> str:
576
+ """Browser setup - should be excluded."""
577
+ return "setup"
578
+
579
+ @env.tool()
580
+ def file_read() -> str:
581
+ """File tool - not included."""
582
+ return "read"
583
+
584
+ env._agent_include = ["browser_*"]
585
+ env._agent_exclude = ["*setup*"]
586
+ await env._build_routing()
587
+
588
+ tools = env.as_tools()
589
+ tool_names = [t.name for t in tools]
590
+
591
+ assert "browser_navigate" in tool_names
592
+ assert "browser_setup" not in tool_names # Excluded by *setup*
593
+ assert "file_read" not in tool_names # Not included by browser_*
@@ -11,13 +11,10 @@ from hud.environment.connection import ConnectionType, Connector
11
11
  class TestConnectImage:
12
12
  """Tests for LocalConnectorMixin.connect_image."""
13
13
 
14
- @patch("hud.cli.utils.docker.create_docker_run_command")
15
- def test_connect_image_creates_local_connection(self, mock_docker_cmd: MagicMock) -> None:
14
+ def test_connect_image_creates_local_connection(self) -> None:
16
15
  """connect_image creates LOCAL connection with docker command."""
17
16
  from hud.environment.connectors.local import LocalConnectorMixin
18
17
 
19
- mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
20
-
21
18
  class TestEnv(LocalConnectorMixin):
22
19
  def __init__(self) -> None:
23
20
  self._connections: dict[str, Connector] = {}
@@ -25,21 +22,32 @@ class TestConnectImage:
25
22
  def mount(self, server: Any, *, prefix: str | None = None) -> None:
26
23
  pass
27
24
 
28
- env = TestEnv()
29
- env.connect_image("mcp/fetch")
30
-
31
- assert "mcp/fetch" in env._connections
32
- conn = env._connections["mcp/fetch"]
33
- assert conn.connection_type == ConnectionType.LOCAL
34
- mock_docker_cmd.assert_called_once()
35
-
36
- @patch("hud.cli.utils.docker.create_docker_run_command")
37
- def test_connect_image_with_alias(self, mock_docker_cmd: MagicMock) -> None:
25
+ # Mock the import that happens inside connect_image
26
+ mock_docker_utils = MagicMock()
27
+ mock_docker_utils.create_docker_run_command.return_value = [
28
+ "docker",
29
+ "run",
30
+ "-i",
31
+ "--rm",
32
+ "mcp/fetch",
33
+ ]
34
+
35
+ with patch.dict(
36
+ "sys.modules",
37
+ {"hud.cli.utils.docker": mock_docker_utils},
38
+ ):
39
+ env = TestEnv()
40
+ env.connect_image("mcp/fetch")
41
+
42
+ assert "mcp/fetch" in env._connections
43
+ conn = env._connections["mcp/fetch"]
44
+ assert conn.connection_type == ConnectionType.LOCAL
45
+ mock_docker_utils.create_docker_run_command.assert_called_once()
46
+
47
+ def test_connect_image_with_alias(self) -> None:
38
48
  """connect_image uses alias for connection name."""
39
49
  from hud.environment.connectors.local import LocalConnectorMixin
40
50
 
41
- mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
42
-
43
51
  class TestEnv(LocalConnectorMixin):
44
52
  def __init__(self) -> None:
45
53
  self._connections: dict[str, Connector] = {}
@@ -47,19 +55,29 @@ class TestConnectImage:
47
55
  def mount(self, server: Any, *, prefix: str | None = None) -> None:
48
56
  pass
49
57
 
50
- env = TestEnv()
51
- env.connect_image("mcp/fetch", alias="fetcher")
52
-
53
- assert "fetcher" in env._connections
54
- assert "mcp/fetch" not in env._connections
55
-
56
- @patch("hud.cli.utils.docker.create_docker_run_command")
57
- def test_connect_image_with_prefix(self, mock_docker_cmd: MagicMock) -> None:
58
+ mock_docker_utils = MagicMock()
59
+ mock_docker_utils.create_docker_run_command.return_value = [
60
+ "docker",
61
+ "run",
62
+ "-i",
63
+ "--rm",
64
+ "mcp/fetch",
65
+ ]
66
+
67
+ with patch.dict(
68
+ "sys.modules",
69
+ {"hud.cli.utils.docker": mock_docker_utils},
70
+ ):
71
+ env = TestEnv()
72
+ env.connect_image("mcp/fetch", alias="fetcher")
73
+
74
+ assert "fetcher" in env._connections
75
+ assert "mcp/fetch" not in env._connections
76
+
77
+ def test_connect_image_with_prefix(self) -> None:
58
78
  """connect_image passes prefix to config."""
59
79
  from hud.environment.connectors.local import LocalConnectorMixin
60
80
 
61
- mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
62
-
63
81
  class TestEnv(LocalConnectorMixin):
64
82
  def __init__(self) -> None:
65
83
  self._connections: dict[str, Connector] = {}
@@ -67,19 +85,29 @@ class TestConnectImage:
67
85
  def mount(self, server: Any, *, prefix: str | None = None) -> None:
68
86
  pass
69
87
 
70
- env = TestEnv()
71
- env.connect_image("mcp/fetch", prefix="fetch")
72
-
73
- conn = env._connections["mcp/fetch"]
74
- assert conn.config.prefix == "fetch"
75
-
76
- @patch("hud.cli.utils.docker.create_docker_run_command")
77
- def test_connect_image_returns_self(self, mock_docker_cmd: MagicMock) -> None:
88
+ mock_docker_utils = MagicMock()
89
+ mock_docker_utils.create_docker_run_command.return_value = [
90
+ "docker",
91
+ "run",
92
+ "-i",
93
+ "--rm",
94
+ "mcp/fetch",
95
+ ]
96
+
97
+ with patch.dict(
98
+ "sys.modules",
99
+ {"hud.cli.utils.docker": mock_docker_utils},
100
+ ):
101
+ env = TestEnv()
102
+ env.connect_image("mcp/fetch", prefix="fetch")
103
+
104
+ conn = env._connections["mcp/fetch"]
105
+ assert conn.config.prefix == "fetch"
106
+
107
+ def test_connect_image_returns_self(self) -> None:
78
108
  """connect_image returns self for chaining."""
79
109
  from hud.environment.connectors.local import LocalConnectorMixin
80
110
 
81
- mock_docker_cmd.return_value = ["docker", "run", "-i", "--rm", "mcp/fetch"]
82
-
83
111
  class TestEnv(LocalConnectorMixin):
84
112
  def __init__(self) -> None:
85
113
  self._connections: dict[str, Connector] = {}
@@ -87,10 +115,23 @@ class TestConnectImage:
87
115
  def mount(self, server: Any, *, prefix: str | None = None) -> None:
88
116
  pass
89
117
 
90
- env = TestEnv()
91
- result = env.connect_image("mcp/fetch")
92
-
93
- assert result is env
118
+ mock_docker_utils = MagicMock()
119
+ mock_docker_utils.create_docker_run_command.return_value = [
120
+ "docker",
121
+ "run",
122
+ "-i",
123
+ "--rm",
124
+ "mcp/fetch",
125
+ ]
126
+
127
+ with patch.dict(
128
+ "sys.modules",
129
+ {"hud.cli.utils.docker": mock_docker_utils},
130
+ ):
131
+ env = TestEnv()
132
+ result = env.connect_image("mcp/fetch")
133
+
134
+ assert result is env
94
135
 
95
136
 
96
137
  class TestConnectServer: