hud-python 0.3.4__py3-none-any.whl → 0.4.0__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 +17 -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 +379 -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 +354 -0
  45. hud/clients/fastmcp.py +202 -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 -414
  87. hud/tools/computer/hud.py +376 -328
  88. hud/tools/computer/openai.py +295 -286
  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.0.dist-info/METADATA +474 -0
  126. hud_python-0.4.0.dist-info/RECORD +132 -0
  127. hud_python-0.4.0.dist-info/entry_points.txt +3 -0
  128. {hud_python-0.3.4.dist-info → hud_python-0.4.0.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.4.dist-info/METADATA +0 -284
  190. hud_python-0.3.4.dist-info/RECORD +0 -120
  191. /hud/{adapters/common → shared}/tests/__init__.py +0 -0
  192. {hud_python-0.3.4.dist-info → hud_python-0.4.0.dist-info}/WHEEL +0 -0
hud/clients/fastmcp.py ADDED
@@ -0,0 +1,202 @@
1
+ """FastMCP-based client implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ import os
8
+ from contextlib import AsyncExitStack
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from fastmcp import Client as FastMCPClient
12
+ from mcp import Implementation, types
13
+ from mcp.shared.exceptions import McpError
14
+
15
+ from hud.types import MCPToolCall, MCPToolResult
16
+ from hud.version import __version__ as hud_version
17
+
18
+ from .base import BaseHUDClient
19
+ from .utils.retry_transport import create_retry_httpx_client
20
+
21
+ if TYPE_CHECKING:
22
+ from pydantic import AnyUrl
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class FastMCPHUDClient(BaseHUDClient):
28
+ """FastMCP-based implementation of HUD MCP client."""
29
+
30
+ client_info = Implementation(
31
+ name="hud-fastmcp", title="hud FastMCP Client", version=hud_version
32
+ )
33
+
34
+ def __init__(self, mcp_config: dict[str, dict[str, Any]] | None = None, **kwargs: Any) -> None:
35
+ """
36
+ Initialize FastMCP client with retry support for HTTP transports.
37
+
38
+ Args:
39
+ mcp_config: MCP server configuration dict
40
+ **kwargs: Additional arguments passed to base class
41
+ """
42
+ super().__init__(mcp_config=mcp_config, **kwargs)
43
+
44
+ self._stack: AsyncExitStack | None = None
45
+ self._client: FastMCPClient | None = None
46
+
47
+ def _create_transport_with_retry(self, mcp_config: dict[str, dict[str, Any]]) -> Any:
48
+ """Create transport with retry support for HTTP-based servers."""
49
+ from fastmcp.client.transports import StreamableHttpTransport
50
+
51
+ # If single server with HTTP URL, create transport directly with retry
52
+ if len(mcp_config) == 1:
53
+ _, server_config = next(iter(mcp_config.items()))
54
+ url = server_config.get("url", "")
55
+
56
+ if url.startswith("http") and not url.endswith("/sse"):
57
+ headers = server_config.get("headers", {})
58
+
59
+ logger.debug("Enabling retry mechanism for HTTP transport to %s", url)
60
+ return StreamableHttpTransport(
61
+ url=url,
62
+ headers=headers,
63
+ httpx_client_factory=create_retry_httpx_client,
64
+ )
65
+
66
+ # For all other cases, use standard config (no retry)
67
+ return {"mcpServers": mcp_config}
68
+
69
+ async def _connect(self, mcp_config: dict[str, dict[str, Any]]) -> None:
70
+ """Enter FastMCP context to establish connection."""
71
+ if self._client is not None:
72
+ logger.warning("Client is already connected, cannot connect again")
73
+ return
74
+
75
+ # Create FastMCP client with the custom transport
76
+ timeout = 5 * 60 # 5 minutes
77
+ os.environ["FASTMCP_CLIENT_INIT_TIMEOUT"] = str(timeout)
78
+
79
+ # Create custom transport with retry support for HTTP servers
80
+ transport = self._create_transport_with_retry(mcp_config)
81
+ self._client = FastMCPClient(transport, client_info=self.client_info, timeout=timeout)
82
+
83
+ if self._stack is None:
84
+ self._stack = AsyncExitStack()
85
+ await self._stack.enter_async_context(self._client)
86
+
87
+ # Configure validation for output schemas based on client setting
88
+ from mcp.client.session import ValidationOptions
89
+
90
+ if (
91
+ hasattr(self._client, "_session_state")
92
+ and self._client._session_state.session is not None
93
+ ):
94
+ self._client._session_state.session._validation_options = ValidationOptions(
95
+ strict_output_validation=self._strict_validation
96
+ )
97
+
98
+ logger.info("FastMCP client connected")
99
+
100
+ async def list_tools(self) -> list[types.Tool]:
101
+ """List all available tools."""
102
+ if self._client is None:
103
+ raise ValueError("Client is not connected, call initialize() first")
104
+ return await self._client.list_tools()
105
+
106
+ async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
107
+ """Execute a tool by name."""
108
+ if self._client is None:
109
+ raise ValueError("Client is not connected, call initialize() first")
110
+
111
+ # FastMCP returns a different result type, convert it
112
+ result = await self._client.call_tool(
113
+ name=tool_call.name,
114
+ arguments=tool_call.arguments or {},
115
+ raise_on_error=False, # Don't raise, return error in result
116
+ )
117
+
118
+ # Convert FastMCP result to MCPToolResult
119
+ return MCPToolResult(
120
+ content=result.content,
121
+ isError=result.is_error,
122
+ structuredContent=result.structured_content,
123
+ )
124
+
125
+ async def list_resources(self) -> list[types.Resource]:
126
+ """List all available resources."""
127
+ if self._client is None:
128
+ raise ValueError("Client is not connected, call initialize() first")
129
+ return await self._client.list_resources()
130
+
131
+ async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult | None:
132
+ """Read a resource by URI."""
133
+ if self._client is None:
134
+ raise ValueError("Client is not connected, call initialize() first")
135
+ try:
136
+ contents = await self._client.read_resource(uri)
137
+ return types.ReadResourceResult(contents=contents)
138
+ except McpError as e:
139
+ if "telemetry://" in str(uri):
140
+ logger.debug("Telemetry resource not supported by server: %s", e)
141
+ elif self.verbose:
142
+ logger.debug("MCP resource error for '%s': %s", uri, e)
143
+ return None
144
+ except Exception as e:
145
+ if "telemetry://" in str(uri):
146
+ logger.debug("Failed to fetch telemetry: %s", e)
147
+ else:
148
+ logger.warning("Unexpected error reading resource '%s': %s", uri, e)
149
+ return None
150
+
151
+ async def _disconnect(self) -> None:
152
+ """Close the client connection, ensuring the underlying transport is terminated."""
153
+ if self._client is None:
154
+ logger.warning("Client is not connected, cannot disconnect")
155
+ return
156
+
157
+ # First close any active async context stack (this triggers client.__aexit__()).
158
+ if self._stack:
159
+ await self._stack.aclose()
160
+ self._stack = None
161
+
162
+ try:
163
+ # Close the FastMCP client - this calls transport.close()
164
+ await self._client.close()
165
+
166
+ # CRITICAL: Cancel any lingering transport tasks to ensure subprocess termination
167
+ # FastMCP's StdioTransport creates asyncio tasks that can outlive the client
168
+ # We need to handle nested transport structures (MCPConfigTransport -> StdioTransport)
169
+ transport = getattr(self._client, "transport", None)
170
+ if transport:
171
+ # If it's an MCPConfigTransport with a nested transport
172
+ if hasattr(transport, "transport"):
173
+ transport = transport.transport
174
+
175
+ # Now check if it's a StdioTransport with a _connect_task
176
+ if (
177
+ hasattr(transport, "_connect_task")
178
+ and transport._connect_task
179
+ and not transport._connect_task.done()
180
+ ):
181
+ logger.debug("Canceling lingering StdioTransport connect task")
182
+ transport._connect_task.cancel()
183
+ try:
184
+ await transport._connect_task
185
+ except asyncio.CancelledError:
186
+ logger.debug("Transport task cancelled successfully")
187
+ except Exception as e:
188
+ logger.debug("Error canceling transport task: %s", e)
189
+
190
+ except Exception as e:
191
+ logger.debug("Error while closing FastMCP client transport: %s", e)
192
+
193
+ logger.debug("FastMCP client closed")
194
+
195
+ async def __aenter__(self: Any) -> Any:
196
+ """Async context manager entry."""
197
+ await self.initialize()
198
+ return self
199
+
200
+ async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
201
+ """Async context manager exit."""
202
+ await self.shutdown()
hud/clients/mcp_use.py ADDED
@@ -0,0 +1,278 @@
1
+ """MCP-use based client implementation (legacy)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING, Any
7
+
8
+ from mcp import Implementation
9
+ from mcp.shared.exceptions import McpError
10
+ from mcp_use.client import MCPClient as MCPUseClient
11
+ from pydantic import AnyUrl
12
+
13
+ from hud.types import MCPToolCall, MCPToolResult
14
+ from hud.version import __version__ as hud_version
15
+
16
+ from .base import BaseHUDClient
17
+
18
+ if TYPE_CHECKING:
19
+ from mcp import types
20
+ from mcp_use.session import MCPSession as MCPUseSession
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class MCPUseHUDClient(BaseHUDClient):
26
+ """MCP-use based implementation of HUD MCP client."""
27
+
28
+ client_info = Implementation(
29
+ name="hud-mcp-use", title="hud MCP-use Client", version=hud_version
30
+ )
31
+
32
+ def __init__(self, mcp_config: dict[str, dict[str, Any]] | None = None, **kwargs: Any) -> None:
33
+ """
34
+ Initialize MCP-use client.
35
+
36
+ Args:
37
+ mcp_config: MCP server configuration dict
38
+ **kwargs: Additional arguments passed to base class
39
+ """
40
+ super().__init__(mcp_config=mcp_config, **kwargs)
41
+
42
+ self._sessions: dict[str, MCPUseSession] = {}
43
+ self._tool_map: dict[str, tuple[str, types.Tool]] = {}
44
+ self._client: MCPUseClient | None = None
45
+
46
+ async def _connect(self, mcp_config: dict[str, dict[str, Any]]) -> None:
47
+ """Create all sessions for MCP-use client."""
48
+ if self._client is not None:
49
+ logger.warning("Client is already connected, cannot connect again")
50
+ return
51
+
52
+ config = {"mcpServers": mcp_config}
53
+ self._client = MCPUseClient.from_dict(config)
54
+ try:
55
+ self._sessions = await self._client.create_all_sessions()
56
+ logger.info("Created %d MCP sessions", len(self._sessions))
57
+
58
+ # Configure validation for all sessions based on client setting
59
+ from mcp.client.session import ValidationOptions
60
+
61
+ for session in self._sessions.values():
62
+ if (
63
+ hasattr(session, "connector")
64
+ and hasattr(session.connector, "client_session")
65
+ and session.connector.client_session is not None
66
+ ):
67
+ session.connector.client_session._validation_options = ValidationOptions(
68
+ strict_output_validation=self._strict_validation
69
+ )
70
+
71
+ # Log session details in verbose mode
72
+ if self.verbose and self._sessions:
73
+ for name, session in self._sessions.items():
74
+ logger.debug(" - %s: %s", name, type(session).__name__)
75
+
76
+ except McpError as e:
77
+ # Protocol error - the server is reachable but rejecting our request
78
+ logger.error("MCP protocol error: %s", e)
79
+ logger.error("This typically means:")
80
+ logger.error("- Invalid or missing initialization parameters")
81
+ logger.error("- Incompatible protocol version")
82
+ logger.error("- Server-side configuration issues")
83
+ raise
84
+ except Exception as e:
85
+ # Transport or other errors
86
+ logger.error("Failed to create sessions: %s", e)
87
+ if self.verbose:
88
+ logger.info("Check that the MCP server is running and accessible")
89
+ raise
90
+
91
+ async def list_tools(self) -> list[types.Tool]:
92
+ """List all available tools from all sessions."""
93
+ if self._client is None or not self._sessions:
94
+ raise ValueError("Client is not connected, call initialize() first")
95
+
96
+ all_tools = []
97
+ self._tool_map = {}
98
+
99
+ for server_name, session in self._sessions.items():
100
+ try:
101
+ # Ensure session is initialized
102
+ if not hasattr(session, "connector") or not hasattr(
103
+ session.connector, "client_session"
104
+ ):
105
+ await session.initialize()
106
+
107
+ if session.connector.client_session is None:
108
+ logger.warning("Client session not initialized for %s", server_name)
109
+ continue
110
+
111
+ # List tools
112
+ tools_result = await session.connector.client_session.list_tools()
113
+
114
+ logger.info(
115
+ "Discovered %d tools from '%s': %s",
116
+ len(tools_result.tools),
117
+ server_name,
118
+ [tool.name for tool in tools_result.tools],
119
+ )
120
+
121
+ # Add to collections
122
+ for tool in tools_result.tools:
123
+ all_tools.append(tool)
124
+ self._tool_map[tool.name] = (server_name, tool)
125
+
126
+ # Log detailed tool info in verbose mode
127
+ if self.verbose:
128
+ for tool in tools_result.tools:
129
+ description = tool.description or ""
130
+ logger.debug(
131
+ " Tool '%s': %s",
132
+ tool.name,
133
+ description[:100] + "..." if len(description) > 100 else description,
134
+ )
135
+
136
+ except Exception as e:
137
+ logger.error("Error discovering tools from '%s': %s", server_name, e)
138
+ if self.verbose:
139
+ logger.exception("Full error details:")
140
+
141
+ return all_tools
142
+
143
+ async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
144
+ """Execute a tool by name."""
145
+ if self._client is None or not self._initialized:
146
+ raise ValueError("Client is not connected, call initialize() first")
147
+
148
+ if tool_call.name not in self._tool_map:
149
+ raise ValueError(f"Tool '{tool_call.name}' not found")
150
+
151
+ server_name, _ = self._tool_map[tool_call.name]
152
+ session = self._sessions[server_name]
153
+
154
+ if self.verbose:
155
+ logger.debug(
156
+ "Calling tool '%s' on server '%s' with arguments: %s",
157
+ tool_call.name,
158
+ server_name,
159
+ tool_call.arguments,
160
+ )
161
+
162
+ if session.connector.client_session is None:
163
+ raise ValueError(f"Client session not initialized for {server_name}")
164
+
165
+ result = await session.connector.client_session.call_tool(
166
+ name=tool_call.name,
167
+ arguments=tool_call.arguments or {},
168
+ )
169
+
170
+ if self.verbose:
171
+ logger.debug("Tool '%s' result: %s", tool_call.name, result)
172
+
173
+ # MCP-use already returns the correct type, but we need to ensure it's MCPToolResult
174
+ return MCPToolResult(
175
+ content=result.content,
176
+ isError=result.isError,
177
+ structuredContent=result.structuredContent,
178
+ )
179
+
180
+ async def list_resources(self) -> list[types.Resource]:
181
+ """List all available resources."""
182
+ if self._client is None or not self._sessions:
183
+ raise ValueError("Client is not connected, call initialize() first")
184
+
185
+ for server_name, session in self._sessions.items():
186
+ try:
187
+ if not hasattr(session, "connector") or not hasattr(
188
+ session.connector, "client_session"
189
+ ):
190
+ continue
191
+ if session.connector.client_session is None:
192
+ continue
193
+ # Prefer standard method name if available
194
+ if hasattr(session.connector.client_session, "list_resources"):
195
+ resources = await session.connector.client_session.list_resources()
196
+ else:
197
+ # If the client doesn't support resource listing, skip
198
+ continue
199
+ return resources.resources
200
+ except Exception as e:
201
+ if self.verbose:
202
+ logger.debug("Could not list resources from server '%s': %s", server_name, e)
203
+ continue
204
+ return []
205
+
206
+ async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult | None:
207
+ """Read a resource by URI from any server that provides it."""
208
+ if self._client is None or not self._sessions:
209
+ raise ValueError("Client is not connected, call initialize() first")
210
+
211
+ for server_name, session in self._sessions.items():
212
+ try:
213
+ if not hasattr(session, "connector") or not hasattr(
214
+ session.connector, "client_session"
215
+ ):
216
+ continue
217
+
218
+ if session.connector.client_session is None:
219
+ continue
220
+
221
+ # Convert str to AnyUrl if needed
222
+ resource_uri = AnyUrl(uri) if isinstance(uri, str) else uri
223
+ # Prefer read_resource; fall back to list_resources if needed
224
+ if hasattr(session.connector.client_session, "read_resource"):
225
+ result = await session.connector.client_session.read_resource(resource_uri)
226
+ else:
227
+ # Fallback path for older clients: not supported in strict typing
228
+ raise AttributeError("read_resource not available")
229
+
230
+ if self.verbose:
231
+ logger.debug(
232
+ "Successfully read resource '%s' from server '%s'", uri, server_name
233
+ )
234
+
235
+ return result
236
+
237
+ except McpError as e:
238
+ # McpError is expected for unsupported resources
239
+ if "telemetry://" in str(uri):
240
+ logger.debug(
241
+ "Telemetry resource not supported by server '%s': %s", server_name, e
242
+ )
243
+ elif self.verbose:
244
+ logger.debug(
245
+ "MCP resource error for '%s' from server '%s': %s", uri, server_name, e
246
+ )
247
+ continue
248
+ except Exception as e:
249
+ # Other errors might be more serious
250
+ if "telemetry://" in str(uri):
251
+ logger.debug("Failed to fetch telemetry from server '%s': %s", server_name, e)
252
+ else:
253
+ logger.warning(
254
+ "Unexpected error reading resource '%s' from server '%s': %s",
255
+ uri,
256
+ server_name,
257
+ e,
258
+ )
259
+ continue
260
+
261
+ return None
262
+
263
+ async def _disconnect(self) -> None:
264
+ """Close all active sessions."""
265
+ if self._client is None:
266
+ logger.warning("Client is not connected, cannot close")
267
+ return
268
+
269
+ await self._client.close_all_sessions()
270
+ self._sessions = {}
271
+ self._tool_map = {}
272
+ self._initialized = False
273
+ logger.debug("MCP-use client disconnected")
274
+
275
+ # Legacy compatibility methods (limited; tests should not rely on these)
276
+ def get_sessions(self) -> dict[str, MCPUseSession]:
277
+ """Get active MCP sessions."""
278
+ return self._sessions
@@ -0,0 +1 @@
1
+ """Tests for HUD MCP clients."""
@@ -0,0 +1,111 @@
1
+ """Integration tests for MCP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from hud.clients import FastMCPHUDClient
8
+ from hud.clients.base import AgentMCPClient
9
+ from hud.clients.mcp_use import MCPUseHUDClient
10
+
11
+
12
+ class TestClientIntegration:
13
+ """Test that clients work with real configurations."""
14
+
15
+ def test_fastmcp_client_creation(self):
16
+ """Test that FastMCP client can be created with various configs."""
17
+ # HTTP config
18
+ config = {"server": {"url": "http://localhost:8080"}}
19
+ client = FastMCPHUDClient(config)
20
+ assert isinstance(client, AgentMCPClient)
21
+ assert client.is_connected is False
22
+
23
+ # Stdio config
24
+ config = {"server": {"command": "python", "args": ["server.py"]}}
25
+ client = FastMCPHUDClient(config)
26
+ assert isinstance(client, AgentMCPClient)
27
+
28
+ # Multi-server config
29
+ config = {
30
+ "server1": {"url": "http://localhost:8080"},
31
+ "server2": {"command": "python", "args": ["server.py"]},
32
+ }
33
+ client = FastMCPHUDClient(config)
34
+ assert isinstance(client, AgentMCPClient)
35
+
36
+ def test_mcp_use_client_creation(self):
37
+ """Test that MCP-use client can be created with various configs."""
38
+ # HTTP config
39
+ config = {"server": {"url": "http://localhost:8080"}}
40
+ client = MCPUseHUDClient(config)
41
+ assert isinstance(client, AgentMCPClient)
42
+ assert client.is_connected is False
43
+
44
+ # Stdio config
45
+ config = {"server": {"command": "python", "args": ["server.py"]}}
46
+ client = MCPUseHUDClient(config)
47
+ assert isinstance(client, AgentMCPClient)
48
+
49
+ # Multi-server config
50
+ config = {
51
+ "server1": {"url": "http://localhost:8080"},
52
+ "server2": {"command": "python", "args": ["server.py"]},
53
+ }
54
+ client = MCPUseHUDClient(config)
55
+ assert isinstance(client, AgentMCPClient)
56
+
57
+ def test_client_switching(self):
58
+ """Test that clients can be switched without changing agent code."""
59
+ config = {"server": {"url": "http://localhost:8080"}}
60
+
61
+ # Both clients should satisfy the protocol
62
+ fastmcp_client = FastMCPHUDClient(config)
63
+ mcp_use_client = MCPUseHUDClient(config)
64
+
65
+ # Both implement the same protocol
66
+ assert isinstance(fastmcp_client, AgentMCPClient)
67
+ assert isinstance(mcp_use_client, AgentMCPClient)
68
+
69
+ # Both have the same essential methods
70
+ for method in ["initialize", "list_tools", "call_tool"]:
71
+ assert hasattr(fastmcp_client, method)
72
+ assert hasattr(mcp_use_client, method)
73
+ assert callable(getattr(fastmcp_client, method))
74
+ assert callable(getattr(mcp_use_client, method))
75
+
76
+ @pytest.mark.asyncio
77
+ async def test_context_manager_usage(self):
78
+ """Test that both clients work as context managers."""
79
+ from unittest.mock import AsyncMock, patch
80
+
81
+ config = {"server": {"url": "http://localhost:8080"}}
82
+
83
+ # Test FastMCP client with mocked initialization
84
+ fastmcp_client = FastMCPHUDClient(config)
85
+ assert not fastmcp_client.is_connected
86
+
87
+ with (
88
+ patch.object(fastmcp_client, "initialize", new_callable=AsyncMock) as mock_init,
89
+ patch.object(fastmcp_client, "shutdown", new_callable=AsyncMock) as mock_shutdown,
90
+ ):
91
+ async with fastmcp_client:
92
+ # Verify initialization was called
93
+ mock_init.assert_called_once()
94
+
95
+ # Verify shutdown was called
96
+ mock_shutdown.assert_called_once()
97
+
98
+ # Test MCP-use client with mocked initialization
99
+ mcp_use_client = MCPUseHUDClient(config)
100
+ assert not mcp_use_client.is_connected
101
+
102
+ with (
103
+ patch.object(mcp_use_client, "initialize", new_callable=AsyncMock) as mock_init,
104
+ patch.object(mcp_use_client, "shutdown", new_callable=AsyncMock) as mock_shutdown,
105
+ ):
106
+ async with mcp_use_client:
107
+ # Verify initialization was called
108
+ mock_init.assert_called_once()
109
+
110
+ # Verify shutdown was called
111
+ mock_shutdown.assert_called_once()