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.
- hud/__init__.py +22 -89
- hud/agents/__init__.py +17 -0
- hud/agents/art.py +101 -0
- hud/agents/base.py +599 -0
- hud/{mcp → agents}/claude.py +373 -321
- hud/{mcp → agents}/langchain.py +250 -250
- hud/agents/misc/__init__.py +7 -0
- hud/{agent → agents}/misc/response_agent.py +80 -80
- hud/{mcp → agents}/openai.py +352 -334
- hud/agents/openai_chat_generic.py +154 -0
- hud/{mcp → agents}/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -0
- hud/agents/tests/test_claude.py +324 -0
- hud/{mcp → agents}/tests/test_client.py +363 -324
- hud/{mcp → agents}/tests/test_openai.py +237 -238
- hud/cli/__init__.py +617 -0
- hud/cli/__main__.py +8 -0
- hud/cli/analyze.py +371 -0
- hud/cli/analyze_metadata.py +230 -0
- hud/cli/build.py +427 -0
- hud/cli/clone.py +185 -0
- hud/cli/cursor.py +92 -0
- hud/cli/debug.py +392 -0
- hud/cli/docker_utils.py +83 -0
- hud/cli/init.py +281 -0
- hud/cli/interactive.py +353 -0
- hud/cli/mcp_server.py +756 -0
- hud/cli/pull.py +336 -0
- hud/cli/push.py +379 -0
- hud/cli/remote_runner.py +311 -0
- hud/cli/runner.py +160 -0
- hud/cli/tests/__init__.py +3 -0
- hud/cli/tests/test_analyze.py +284 -0
- hud/cli/tests/test_cli_init.py +265 -0
- hud/cli/tests/test_cli_main.py +27 -0
- hud/cli/tests/test_clone.py +142 -0
- hud/cli/tests/test_cursor.py +253 -0
- hud/cli/tests/test_debug.py +453 -0
- hud/cli/tests/test_mcp_server.py +139 -0
- hud/cli/tests/test_utils.py +388 -0
- hud/cli/utils.py +263 -0
- hud/clients/README.md +143 -0
- hud/clients/__init__.py +16 -0
- hud/clients/base.py +354 -0
- hud/clients/fastmcp.py +202 -0
- hud/clients/mcp_use.py +278 -0
- hud/clients/tests/__init__.py +1 -0
- hud/clients/tests/test_client_integration.py +111 -0
- hud/clients/tests/test_fastmcp.py +342 -0
- hud/clients/tests/test_protocol.py +188 -0
- hud/clients/utils/__init__.py +1 -0
- hud/clients/utils/retry_transport.py +160 -0
- hud/datasets.py +322 -192
- hud/misc/__init__.py +1 -0
- hud/{agent → misc}/claude_plays_pokemon.py +292 -283
- hud/otel/__init__.py +35 -0
- hud/otel/collector.py +142 -0
- hud/otel/config.py +164 -0
- hud/otel/context.py +536 -0
- hud/otel/exporters.py +366 -0
- hud/otel/instrumentation.py +97 -0
- hud/otel/processors.py +118 -0
- hud/otel/tests/__init__.py +1 -0
- hud/otel/tests/test_processors.py +197 -0
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -0
- hud/server/helper/__init__.py +5 -0
- hud/server/low_level.py +132 -0
- hud/server/server.py +166 -0
- hud/server/tests/__init__.py +3 -0
- hud/settings.py +73 -79
- hud/shared/__init__.py +5 -0
- hud/{exceptions.py → shared/exceptions.py} +180 -180
- hud/{server → shared}/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -0
- hud/{server → shared}/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -30
- hud/telemetry/instrument.py +379 -0
- hud/telemetry/job.py +309 -141
- hud/telemetry/replay.py +74 -0
- hud/telemetry/trace.py +83 -0
- hud/tools/__init__.py +33 -34
- hud/tools/base.py +365 -65
- hud/tools/bash.py +161 -137
- hud/tools/computer/__init__.py +15 -13
- hud/tools/computer/anthropic.py +437 -414
- hud/tools/computer/hud.py +376 -328
- hud/tools/computer/openai.py +295 -286
- hud/tools/computer/settings.py +82 -0
- hud/tools/edit.py +314 -290
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -532
- hud/tools/executors/pyautogui.py +621 -619
- hud/tools/executors/tests/__init__.py +1 -1
- hud/tools/executors/tests/test_base_executor.py +338 -338
- hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
- hud/tools/executors/xdo.py +511 -503
- hud/tools/{playwright_tool.py → playwright.py} +412 -379
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -0
- hud/tools/tests/test_bash.py +158 -152
- hud/tools/tests/test_bash_extended.py +197 -0
- hud/tools/tests/test_computer.py +425 -52
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -240
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -157
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -0
- hud/tools/utils.py +50 -50
- hud/types.py +136 -89
- hud/utils/__init__.py +10 -16
- hud/utils/async_utils.py +65 -0
- hud/utils/design.py +168 -0
- hud/utils/mcp.py +55 -0
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -0
- hud/utils/tests/test_init.py +17 -21
- hud/utils/tests/test_progress.py +261 -225
- hud/utils/tests/test_telemetry.py +82 -37
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- hud_python-0.4.0.dist-info/METADATA +474 -0
- hud_python-0.4.0.dist-info/RECORD +132 -0
- hud_python-0.4.0.dist-info/entry_points.txt +3 -0
- {hud_python-0.3.4.dist-info → hud_python-0.4.0.dist-info}/licenses/LICENSE +21 -21
- hud/adapters/__init__.py +0 -8
- hud/adapters/claude/__init__.py +0 -5
- hud/adapters/claude/adapter.py +0 -180
- hud/adapters/claude/tests/__init__.py +0 -1
- hud/adapters/claude/tests/test_adapter.py +0 -519
- hud/adapters/common/__init__.py +0 -6
- hud/adapters/common/adapter.py +0 -178
- hud/adapters/common/tests/test_adapter.py +0 -289
- hud/adapters/common/types.py +0 -446
- hud/adapters/operator/__init__.py +0 -5
- hud/adapters/operator/adapter.py +0 -108
- hud/adapters/operator/tests/__init__.py +0 -1
- hud/adapters/operator/tests/test_adapter.py +0 -370
- hud/agent/__init__.py +0 -19
- hud/agent/base.py +0 -126
- hud/agent/claude.py +0 -271
- hud/agent/langchain.py +0 -215
- hud/agent/misc/__init__.py +0 -3
- hud/agent/operator.py +0 -268
- hud/agent/tests/__init__.py +0 -1
- hud/agent/tests/test_base.py +0 -202
- hud/env/__init__.py +0 -11
- hud/env/client.py +0 -35
- hud/env/docker_client.py +0 -349
- hud/env/environment.py +0 -446
- hud/env/local_docker_client.py +0 -358
- hud/env/remote_client.py +0 -212
- hud/env/remote_docker_client.py +0 -292
- hud/gym.py +0 -130
- hud/job.py +0 -773
- hud/mcp/__init__.py +0 -17
- hud/mcp/base.py +0 -631
- hud/mcp/client.py +0 -312
- hud/mcp/tests/test_base.py +0 -512
- hud/mcp/tests/test_claude.py +0 -294
- hud/task.py +0 -149
- hud/taskset.py +0 -237
- hud/telemetry/_trace.py +0 -347
- hud/telemetry/context.py +0 -230
- hud/telemetry/exporter.py +0 -575
- hud/telemetry/instrumentation/__init__.py +0 -3
- hud/telemetry/instrumentation/mcp.py +0 -259
- hud/telemetry/instrumentation/registry.py +0 -59
- hud/telemetry/mcp_models.py +0 -270
- hud/telemetry/tests/__init__.py +0 -1
- hud/telemetry/tests/test_context.py +0 -210
- hud/telemetry/tests/test_trace.py +0 -312
- hud/tools/helper/README.md +0 -56
- hud/tools/helper/__init__.py +0 -9
- hud/tools/helper/mcp_server.py +0 -78
- hud/tools/helper/server_initialization.py +0 -115
- hud/tools/helper/utils.py +0 -58
- hud/trajectory.py +0 -94
- hud/utils/agent.py +0 -37
- hud/utils/common.py +0 -256
- hud/utils/config.py +0 -120
- hud/utils/deprecation.py +0 -115
- hud/utils/misc.py +0 -53
- hud/utils/tests/test_common.py +0 -277
- hud/utils/tests/test_config.py +0 -129
- hud_python-0.3.4.dist-info/METADATA +0 -284
- hud_python-0.3.4.dist-info/RECORD +0 -120
- /hud/{adapters/common → shared}/tests/__init__.py +0 -0
- {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()
|