hud-python 0.4.1__py3-none-any.whl → 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/clients/base.py CHANGED
@@ -1,379 +1,378 @@
1
- """Base protocol and implementation for HUD MCP clients."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import logging
7
- from abc import abstractmethod
8
- from typing import TYPE_CHECKING, Any, Protocol, overload, runtime_checkable
9
-
10
- from mcp.types import Implementation
11
-
12
- from hud.types import MCPToolCall, MCPToolResult
13
- from hud.utils.mcp import setup_hud_telemetry
14
- from hud.version import __version__ as hud_version
15
-
16
- if TYPE_CHECKING:
17
- import mcp.types as types
18
-
19
- else:
20
- pass
21
-
22
-
23
- logger = logging.getLogger(__name__)
24
-
25
-
26
- @runtime_checkable
27
- class AgentMCPClient(Protocol):
28
- """Minimal interface for MCP clients used by agents.
29
-
30
- Any custom client must implement this interface.
31
-
32
- Any custom agent can assume that this will be the interaction protocol.
33
- """
34
-
35
- _initialized: bool
36
-
37
- @property
38
- def mcp_config(self) -> dict[str, dict[str, Any]]:
39
- """Get the MCP config."""
40
- ...
41
-
42
- @property
43
- def is_connected(self) -> bool:
44
- """Check if client is connected and initialized."""
45
- ...
46
-
47
- async def initialize(self, mcp_config: dict[str, dict[str, Any]] | None = None) -> None:
48
- """Initialize the client."""
49
- ...
50
-
51
- async def list_tools(self) -> list[types.Tool]:
52
- """List all available tools."""
53
- ...
54
-
55
- async def call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
56
- """Execute a tool by name."""
57
- ...
58
-
59
- async def shutdown(self) -> None:
60
- """Shutdown the client."""
61
- ...
62
-
63
-
64
- class BaseHUDClient(AgentMCPClient):
65
- """Base class with common HUD functionality that adds:
66
- - Connection management
67
- - Tool discovery
68
- - Telemetry fetching (hud environment-specific)
69
- - Logging
70
- - Strict tool output validation (optional)
71
- - Environment analysis (optional)
72
-
73
- Any custom client should inherit from this class, and implement:
74
- - _connect: Connect to the MCP server
75
- - list_tools: List all available tools
76
- - list_resources: List all available resources
77
- - call_tool: Execute a tool by name
78
- - read_resource: Read a resource by URI
79
- - _disconnect: Disconnect from the MCP server
80
- - any other MCP client methods
81
- """
82
-
83
- client_info = Implementation(name="hud-mcp", title="hud MCP Client", version=hud_version)
84
-
85
- def __init__(
86
- self,
87
- mcp_config: dict[str, dict[str, Any]] | None = None,
88
- verbose: bool = False,
89
- strict_validation: bool = False,
90
- auto_trace: bool = True,
91
- ) -> None:
92
- """
93
- Initialize base client.
94
-
95
- Args:
96
- mcp_config: MCP server configuration dict
97
- verbose: Enable verbose logging
98
- strict_validation: Enable strict tool output validation
99
- """
100
- self.verbose = verbose
101
- self._mcp_config = mcp_config
102
- self._strict_validation = strict_validation
103
- self._auto_trace = auto_trace
104
-
105
- self._initialized = False
106
- self._telemetry_data = {} # Initialize telemetry data
107
-
108
- if self.verbose:
109
- self._setup_verbose_logging()
110
-
111
- async def initialize(self, mcp_config: dict[str, dict[str, Any]] | None = None) -> None:
112
- """Initialize connection and fetch tools."""
113
- if self._initialized:
114
- logger.warning(
115
- "Client already connected, if you want to reconnect or change the configuration, "
116
- "call shutdown() first. This is especially important if you are using an agent."
117
- )
118
- return
119
-
120
- self._mcp_config = mcp_config or self._mcp_config
121
- if self._mcp_config is None:
122
- raise ValueError(
123
- "An MCP server configuration is required"
124
- "Either pass it to the constructor or call initialize with a configuration"
125
- )
126
-
127
- setup_hud_telemetry(self._mcp_config, auto_trace=self._auto_trace)
128
-
129
- logger.debug("Initializing MCP client...")
130
-
131
- try:
132
- # Subclasses implement connection
133
- await self._connect(self._mcp_config)
134
- except RuntimeError as e:
135
- # Re-raise authentication errors with clear message
136
- if "Authentication failed" in str(e):
137
- raise
138
- raise
139
- except Exception as e:
140
- # Check for authentication errors in the exception chain
141
- error_msg = str(e)
142
- if "401" in error_msg or "Unauthorized" in error_msg:
143
- # Check if connecting to HUD API
144
- for server_config in self._mcp_config.values():
145
- url = server_config.get("url", "")
146
- if "mcp.hud.so" in url:
147
- raise RuntimeError(
148
- "Authentication failed for HUD API. "
149
- "Please ensure your HUD_API_KEY environment variable is set correctly. "
150
- "You can get an API key at https://app.hud.so"
151
- ) from e
152
- # Generic 401 error
153
- raise RuntimeError(
154
- f"Authentication failed (401 Unauthorized). "
155
- f"Please check your credentials or API key."
156
- ) from e
157
- raise
158
-
159
- # Common hud behavior - fetch telemetry
160
- await self._fetch_telemetry()
161
-
162
- self._initialized = True
163
- logger.info("Client initialized")
164
-
165
- async def shutdown(self) -> None:
166
- """Disconnect from the MCP server."""
167
- if self._initialized:
168
- await self._disconnect()
169
- self._initialized = False
170
- logger.info("Client disconnected")
171
- else:
172
- logger.warning("Client is not running, cannot disconnect")
173
-
174
- @overload
175
- async def call_tool(self, tool_call: MCPToolCall, /) -> MCPToolResult: ...
176
- @overload
177
- async def call_tool(
178
- self,
179
- *,
180
- name: str,
181
- arguments: dict[str, Any] | None = None,
182
- ) -> MCPToolResult: ...
183
-
184
- async def call_tool(
185
- self,
186
- tool_call: MCPToolCall | None = None,
187
- *,
188
- name: str | None = None,
189
- arguments: dict[str, Any] | None = None,
190
- ) -> MCPToolResult:
191
- if tool_call is not None:
192
- return await self._call_tool(tool_call)
193
- elif name is not None:
194
- return await self._call_tool(MCPToolCall(name=name, arguments=arguments))
195
- else:
196
- raise TypeError(
197
- "call_tool() requires either an MCPToolCall positional arg "
198
- "or keyword 'name' (and optional 'arguments')."
199
- )
200
-
201
- @abstractmethod
202
- async def _connect(self, mcp_config: dict[str, dict[str, Any]]) -> None:
203
- """Subclasses implement their connection logic."""
204
- raise NotImplementedError
205
-
206
- @abstractmethod
207
- async def list_tools(self) -> list[types.Tool]:
208
- """List all available tools."""
209
- raise NotImplementedError
210
-
211
- @abstractmethod
212
- async def list_resources(self) -> list[types.Resource]:
213
- """List all available resources."""
214
- raise NotImplementedError
215
-
216
- @abstractmethod
217
- async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
218
- """Execute a tool by name."""
219
- raise NotImplementedError
220
-
221
- @abstractmethod
222
- async def read_resource(self, uri: str) -> types.ReadResourceResult | None:
223
- """Read a resource by URI."""
224
- raise NotImplementedError
225
-
226
- @abstractmethod
227
- async def _disconnect(self) -> None:
228
- """Subclasses implement their disconnection logic."""
229
- raise NotImplementedError
230
-
231
- @property
232
- def is_connected(self) -> bool:
233
- """Check if client is connected and initialized."""
234
- return self._initialized
235
-
236
- @property
237
- def mcp_config(self) -> dict[str, dict[str, Any]]:
238
- """Get the MCP config."""
239
- if self._mcp_config is None:
240
- raise ValueError("Please initialize the client with a valid MCP config")
241
- return self._mcp_config
242
-
243
- async def __aenter__(self: Any) -> Any:
244
- """Async context manager entry."""
245
- await self.initialize()
246
- return self
247
-
248
- async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
249
- """Async context manager exit."""
250
- await self.shutdown()
251
-
252
- def _setup_verbose_logging(self) -> None:
253
- """Configure verbose logging for debugging."""
254
- logging.getLogger("mcp").setLevel(logging.DEBUG)
255
- logging.getLogger("fastmcp").setLevel(logging.DEBUG)
256
-
257
- if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
258
- handler = logging.StreamHandler()
259
- handler.setFormatter(
260
- logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s - %(message)s")
261
- )
262
- logger.addHandler(handler)
263
- logger.setLevel(logging.DEBUG)
264
-
265
- async def _fetch_telemetry(self) -> None:
266
- """Common telemetry fetching for all hud clients."""
267
- try:
268
- # Try to read telemetry resource directly
269
- result = await self.read_resource("telemetry://live")
270
- if result and result.contents:
271
- # Parse telemetry data
272
- telemetry_data = json.loads(result.contents[0].text) # type: ignore
273
- self._telemetry_data = telemetry_data
274
-
275
- logger.info("📡 Telemetry data fetched:")
276
- if "live_url" in telemetry_data:
277
- logger.info(" 🖥️ Live URL: %s", telemetry_data["live_url"])
278
- if "cdp_url" in telemetry_data:
279
- logger.info(" 🦾 CDP URL: %s", telemetry_data["cdp_url"])
280
- if "status" in telemetry_data:
281
- logger.info(" 📊 Status: %s", telemetry_data["status"])
282
- if "services" in telemetry_data:
283
- logger.debug(" 📋 Services:")
284
- for service, status in telemetry_data["services"].items():
285
- status_icon = "✅" if status == "running" else "❌"
286
- logger.debug(" %s %s: %s", status_icon, service, status)
287
-
288
- if self.verbose:
289
- logger.debug("Full telemetry data:\n%s", json.dumps(telemetry_data, indent=2))
290
- except Exception as e:
291
- # Telemetry is optional
292
- if self.verbose:
293
- logger.debug("No telemetry available: %s", e)
294
-
295
- async def analyze_environment(self) -> dict[str, Any]:
296
- """Complete analysis of the MCP environment.
297
-
298
- Returns:
299
- Dictionary containing:
300
- - tools: All tools with full schemas
301
- - hub_tools: Hub structures with subtools
302
- - telemetry: Telemetry resources and data
303
- - resources: All available resources
304
- - metadata: Environment metadata
305
- """
306
- if not self._initialized:
307
- raise ValueError("Client must be initialized before analyzing the environment")
308
-
309
- analysis: dict[str, Any] = {
310
- "tools": [],
311
- "hub_tools": {},
312
- "telemetry": self._telemetry_data,
313
- "resources": [],
314
- "metadata": {
315
- "servers": list(self._mcp_config.keys()), # type: ignore
316
- "initialized": self._initialized,
317
- },
318
- }
319
-
320
- # Get all tools with schemas
321
- tools = await self.list_tools()
322
- for tool in tools:
323
- tool_info = {
324
- "name": tool.name,
325
- "description": tool.description,
326
- "input_schema": tool.inputSchema,
327
- }
328
- analysis["tools"].append(tool_info)
329
-
330
- # Check if this is a hub tool (like setup, evaluate)
331
- if (
332
- tool.description
333
- and "internal" in tool.description.lower()
334
- and "functions" in tool.description.lower()
335
- ):
336
- # This is likely a hub dispatcher tool
337
- hub_functions = await self.get_hub_tools(tool.name)
338
- if hub_functions:
339
- analysis["hub_tools"][tool.name] = hub_functions
340
-
341
- # Get all resources
342
- try:
343
- resources = await self.list_resources()
344
- for resource in resources:
345
- resource_info = {
346
- "uri": str(resource.uri),
347
- "name": resource.name,
348
- "description": resource.description,
349
- "mime_type": getattr(resource, "mimeType", None),
350
- }
351
- analysis["resources"].append(resource_info)
352
- except Exception as e:
353
- if self.verbose:
354
- logger.debug("Could not list resources: %s", e)
355
-
356
- return analysis
357
-
358
- async def get_hub_tools(self, hub_name: str) -> list[str]:
359
- """Get all subtools for a specific hub (setup/evaluate).
360
-
361
- Args:
362
- hub_name: Name of the hub (e.g., "setup", "evaluate")
363
-
364
- Returns:
365
- List of available function names for the hub
366
- """
367
- try:
368
- # Read the hub's functions catalogue resource
369
- result = await self.read_resource(f"file:///{hub_name}/functions")
370
- if result and result.contents:
371
- # Parse the JSON list of function names
372
- import json
373
-
374
- functions = json.loads(result.contents[0].text) # type: ignore
375
- return functions
376
- except Exception as e:
377
- if self.verbose:
378
- logger.debug("Could not read hub functions for '%s': %s", hub_name, e)
379
- return []
1
+ """Base protocol and implementation for HUD MCP clients."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from abc import abstractmethod
8
+ from typing import TYPE_CHECKING, Any, Protocol, overload, runtime_checkable
9
+
10
+ from mcp.types import Implementation
11
+
12
+ from hud.types import MCPToolCall, MCPToolResult
13
+ from hud.utils.mcp import setup_hud_telemetry
14
+ from hud.version import __version__ as hud_version
15
+
16
+ if TYPE_CHECKING:
17
+ import mcp.types as types
18
+
19
+ else:
20
+ pass
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+
25
+
26
+ @runtime_checkable
27
+ class AgentMCPClient(Protocol):
28
+ """Minimal interface for MCP clients used by agents.
29
+
30
+ Any custom client must implement this interface.
31
+
32
+ Any custom agent can assume that this will be the interaction protocol.
33
+ """
34
+
35
+ _initialized: bool
36
+
37
+ @property
38
+ def mcp_config(self) -> dict[str, dict[str, Any]]:
39
+ """Get the MCP config."""
40
+ ...
41
+
42
+ @property
43
+ def is_connected(self) -> bool:
44
+ """Check if client is connected and initialized."""
45
+ ...
46
+
47
+ async def initialize(self, mcp_config: dict[str, dict[str, Any]] | None = None) -> None:
48
+ """Initialize the client."""
49
+ ...
50
+
51
+ async def list_tools(self) -> list[types.Tool]:
52
+ """List all available tools."""
53
+ ...
54
+
55
+ async def call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
56
+ """Execute a tool by name."""
57
+ ...
58
+
59
+ async def shutdown(self) -> None:
60
+ """Shutdown the client."""
61
+ ...
62
+
63
+
64
+ class BaseHUDClient(AgentMCPClient):
65
+ """Base class with common HUD functionality that adds:
66
+ - Connection management
67
+ - Tool discovery
68
+ - Telemetry fetching (hud environment-specific)
69
+ - Logging
70
+ - Strict tool output validation (optional)
71
+ - Environment analysis (optional)
72
+
73
+ Any custom client should inherit from this class, and implement:
74
+ - _connect: Connect to the MCP server
75
+ - list_tools: List all available tools
76
+ - list_resources: List all available resources
77
+ - call_tool: Execute a tool by name
78
+ - read_resource: Read a resource by URI
79
+ - _disconnect: Disconnect from the MCP server
80
+ - any other MCP client methods
81
+ """
82
+
83
+ client_info = Implementation(name="hud-mcp", title="hud MCP Client", version=hud_version)
84
+
85
+ def __init__(
86
+ self,
87
+ mcp_config: dict[str, dict[str, Any]] | None = None,
88
+ verbose: bool = False,
89
+ strict_validation: bool = False,
90
+ auto_trace: bool = True,
91
+ ) -> None:
92
+ """
93
+ Initialize base client.
94
+
95
+ Args:
96
+ mcp_config: MCP server configuration dict
97
+ verbose: Enable verbose logging
98
+ strict_validation: Enable strict tool output validation
99
+ """
100
+ self.verbose = verbose
101
+ self._mcp_config = mcp_config
102
+ self._strict_validation = strict_validation
103
+ self._auto_trace = auto_trace
104
+
105
+ self._initialized = False
106
+ self._telemetry_data = {} # Initialize telemetry data
107
+
108
+ if self.verbose:
109
+ self._setup_verbose_logging()
110
+
111
+ async def initialize(self, mcp_config: dict[str, dict[str, Any]] | None = None) -> None:
112
+ """Initialize connection and fetch tools."""
113
+ if self._initialized:
114
+ logger.warning(
115
+ "Client already connected, if you want to reconnect or change the configuration, "
116
+ "call shutdown() first. This is especially important if you are using an agent."
117
+ )
118
+ return
119
+
120
+ self._mcp_config = mcp_config or self._mcp_config
121
+ if self._mcp_config is None:
122
+ raise ValueError(
123
+ "An MCP server configuration is required"
124
+ "Either pass it to the constructor or call initialize with a configuration"
125
+ )
126
+
127
+ setup_hud_telemetry(self._mcp_config, auto_trace=self._auto_trace)
128
+
129
+ logger.debug("Initializing MCP client...")
130
+
131
+ try:
132
+ # Subclasses implement connection
133
+ await self._connect(self._mcp_config)
134
+ except RuntimeError as e:
135
+ # Re-raise authentication errors with clear message
136
+ if "Authentication failed" in str(e):
137
+ raise
138
+ raise
139
+ except Exception as e:
140
+ # Check for authentication errors in the exception chain
141
+ error_msg = str(e)
142
+ if "401" in error_msg or "Unauthorized" in error_msg:
143
+ # Check if connecting to HUD API
144
+ for server_config in self._mcp_config.values():
145
+ url = server_config.get("url", "")
146
+ if "mcp.hud.so" in url:
147
+ raise RuntimeError(
148
+ "Authentication failed for HUD API. "
149
+ "Please ensure your HUD_API_KEY environment variable is set correctly. "
150
+ "You can get an API key at https://app.hud.so"
151
+ ) from e
152
+ raise RuntimeError(
153
+ "Authentication failed (401 Unauthorized). "
154
+ "Please check your credentials or API key."
155
+ ) from e
156
+ raise
157
+
158
+ # Common hud behavior - fetch telemetry
159
+ await self._fetch_telemetry()
160
+
161
+ self._initialized = True
162
+ logger.info("Client initialized")
163
+
164
+ async def shutdown(self) -> None:
165
+ """Disconnect from the MCP server."""
166
+ if self._initialized:
167
+ await self._disconnect()
168
+ self._initialized = False
169
+ logger.info("Client disconnected")
170
+ else:
171
+ logger.warning("Client is not running, cannot disconnect")
172
+
173
+ @overload
174
+ async def call_tool(self, tool_call: MCPToolCall, /) -> MCPToolResult: ...
175
+ @overload
176
+ async def call_tool(
177
+ self,
178
+ *,
179
+ name: str,
180
+ arguments: dict[str, Any] | None = None,
181
+ ) -> MCPToolResult: ...
182
+
183
+ async def call_tool(
184
+ self,
185
+ tool_call: MCPToolCall | None = None,
186
+ *,
187
+ name: str | None = None,
188
+ arguments: dict[str, Any] | None = None,
189
+ ) -> MCPToolResult:
190
+ if tool_call is not None:
191
+ return await self._call_tool(tool_call)
192
+ elif name is not None:
193
+ return await self._call_tool(MCPToolCall(name=name, arguments=arguments))
194
+ else:
195
+ raise TypeError(
196
+ "call_tool() requires either an MCPToolCall positional arg "
197
+ "or keyword 'name' (and optional 'arguments')."
198
+ )
199
+
200
+ @abstractmethod
201
+ async def _connect(self, mcp_config: dict[str, dict[str, Any]]) -> None:
202
+ """Subclasses implement their connection logic."""
203
+ raise NotImplementedError
204
+
205
+ @abstractmethod
206
+ async def list_tools(self) -> list[types.Tool]:
207
+ """List all available tools."""
208
+ raise NotImplementedError
209
+
210
+ @abstractmethod
211
+ async def list_resources(self) -> list[types.Resource]:
212
+ """List all available resources."""
213
+ raise NotImplementedError
214
+
215
+ @abstractmethod
216
+ async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
217
+ """Execute a tool by name."""
218
+ raise NotImplementedError
219
+
220
+ @abstractmethod
221
+ async def read_resource(self, uri: str) -> types.ReadResourceResult | None:
222
+ """Read a resource by URI."""
223
+ raise NotImplementedError
224
+
225
+ @abstractmethod
226
+ async def _disconnect(self) -> None:
227
+ """Subclasses implement their disconnection logic."""
228
+ raise NotImplementedError
229
+
230
+ @property
231
+ def is_connected(self) -> bool:
232
+ """Check if client is connected and initialized."""
233
+ return self._initialized
234
+
235
+ @property
236
+ def mcp_config(self) -> dict[str, dict[str, Any]]:
237
+ """Get the MCP config."""
238
+ if self._mcp_config is None:
239
+ raise ValueError("Please initialize the client with a valid MCP config")
240
+ return self._mcp_config
241
+
242
+ async def __aenter__(self: Any) -> Any:
243
+ """Async context manager entry."""
244
+ await self.initialize()
245
+ return self
246
+
247
+ async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
248
+ """Async context manager exit."""
249
+ await self.shutdown()
250
+
251
+ def _setup_verbose_logging(self) -> None:
252
+ """Configure verbose logging for debugging."""
253
+ logging.getLogger("mcp").setLevel(logging.DEBUG)
254
+ logging.getLogger("fastmcp").setLevel(logging.DEBUG)
255
+
256
+ if not any(isinstance(h, logging.StreamHandler) for h in logger.handlers):
257
+ handler = logging.StreamHandler()
258
+ handler.setFormatter(
259
+ logging.Formatter("[%(levelname)s] %(asctime)s - %(name)s - %(message)s")
260
+ )
261
+ logger.addHandler(handler)
262
+ logger.setLevel(logging.DEBUG)
263
+
264
+ async def _fetch_telemetry(self) -> None:
265
+ """Common telemetry fetching for all hud clients."""
266
+ try:
267
+ # Try to read telemetry resource directly
268
+ result = await self.read_resource("telemetry://live")
269
+ if result and result.contents:
270
+ # Parse telemetry data
271
+ telemetry_data = json.loads(result.contents[0].text) # type: ignore
272
+ self._telemetry_data = telemetry_data
273
+
274
+ logger.info("📡 Telemetry data fetched:")
275
+ if "live_url" in telemetry_data:
276
+ logger.info(" 🖥️ Live URL: %s", telemetry_data["live_url"])
277
+ if "cdp_url" in telemetry_data:
278
+ logger.info(" 🦾 CDP URL: %s", telemetry_data["cdp_url"])
279
+ if "status" in telemetry_data:
280
+ logger.info(" 📊 Status: %s", telemetry_data["status"])
281
+ if "services" in telemetry_data:
282
+ logger.debug(" 📋 Services:")
283
+ for service, status in telemetry_data["services"].items():
284
+ status_icon = "✅" if status == "running" else "❌"
285
+ logger.debug(" %s %s: %s", status_icon, service, status)
286
+
287
+ if self.verbose:
288
+ logger.debug("Full telemetry data:\n%s", json.dumps(telemetry_data, indent=2))
289
+ except Exception as e:
290
+ # Telemetry is optional
291
+ if self.verbose:
292
+ logger.debug("No telemetry available: %s", e)
293
+
294
+ async def analyze_environment(self) -> dict[str, Any]:
295
+ """Complete analysis of the MCP environment.
296
+
297
+ Returns:
298
+ Dictionary containing:
299
+ - tools: All tools with full schemas
300
+ - hub_tools: Hub structures with subtools
301
+ - telemetry: Telemetry resources and data
302
+ - resources: All available resources
303
+ - metadata: Environment metadata
304
+ """
305
+ if not self._initialized:
306
+ raise ValueError("Client must be initialized before analyzing the environment")
307
+
308
+ analysis: dict[str, Any] = {
309
+ "tools": [],
310
+ "hub_tools": {},
311
+ "telemetry": self._telemetry_data,
312
+ "resources": [],
313
+ "metadata": {
314
+ "servers": list(self._mcp_config.keys()), # type: ignore
315
+ "initialized": self._initialized,
316
+ },
317
+ }
318
+
319
+ # Get all tools with schemas
320
+ tools = await self.list_tools()
321
+ for tool in tools:
322
+ tool_info = {
323
+ "name": tool.name,
324
+ "description": tool.description,
325
+ "input_schema": tool.inputSchema,
326
+ }
327
+ analysis["tools"].append(tool_info)
328
+
329
+ # Check if this is a hub tool (like setup, evaluate)
330
+ if (
331
+ tool.description
332
+ and "internal" in tool.description.lower()
333
+ and "functions" in tool.description.lower()
334
+ ):
335
+ # This is likely a hub dispatcher tool
336
+ hub_functions = await self.get_hub_tools(tool.name)
337
+ if hub_functions:
338
+ analysis["hub_tools"][tool.name] = hub_functions
339
+
340
+ # Get all resources
341
+ try:
342
+ resources = await self.list_resources()
343
+ for resource in resources:
344
+ resource_info = {
345
+ "uri": str(resource.uri),
346
+ "name": resource.name,
347
+ "description": resource.description,
348
+ "mime_type": getattr(resource, "mimeType", None),
349
+ }
350
+ analysis["resources"].append(resource_info)
351
+ except Exception as e:
352
+ if self.verbose:
353
+ logger.debug("Could not list resources: %s", e)
354
+
355
+ return analysis
356
+
357
+ async def get_hub_tools(self, hub_name: str) -> list[str]:
358
+ """Get all subtools for a specific hub (setup/evaluate).
359
+
360
+ Args:
361
+ hub_name: Name of the hub (e.g., "setup", "evaluate")
362
+
363
+ Returns:
364
+ List of available function names for the hub
365
+ """
366
+ try:
367
+ # Read the hub's functions catalogue resource
368
+ result = await self.read_resource(f"file:///{hub_name}/functions")
369
+ if result and result.contents:
370
+ # Parse the JSON list of function names
371
+ import json
372
+
373
+ functions = json.loads(result.contents[0].text) # type: ignore
374
+ return functions
375
+ except Exception as e:
376
+ if self.verbose:
377
+ logger.debug("Could not read hub functions for '%s': %s", hub_name, e)
378
+ return []