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.
- hud/__init__.py +22 -22
- hud/agents/__init__.py +13 -15
- hud/agents/base.py +599 -599
- hud/agents/claude.py +373 -373
- hud/agents/langchain.py +261 -250
- hud/agents/misc/__init__.py +7 -7
- hud/agents/misc/response_agent.py +82 -80
- hud/agents/openai.py +352 -352
- hud/agents/openai_chat_generic.py +154 -154
- hud/agents/tests/__init__.py +1 -1
- hud/agents/tests/test_base.py +742 -742
- hud/agents/tests/test_claude.py +324 -324
- hud/agents/tests/test_client.py +363 -363
- hud/agents/tests/test_openai.py +237 -237
- hud/cli/__init__.py +617 -617
- hud/cli/__main__.py +8 -8
- hud/cli/analyze.py +371 -371
- hud/cli/analyze_metadata.py +230 -230
- hud/cli/build.py +498 -427
- hud/cli/clone.py +185 -185
- hud/cli/cursor.py +92 -92
- hud/cli/debug.py +392 -392
- hud/cli/docker_utils.py +83 -83
- hud/cli/init.py +280 -281
- hud/cli/interactive.py +353 -353
- hud/cli/mcp_server.py +764 -756
- hud/cli/pull.py +330 -336
- hud/cli/push.py +404 -370
- hud/cli/remote_runner.py +311 -311
- hud/cli/runner.py +160 -160
- hud/cli/tests/__init__.py +3 -3
- hud/cli/tests/test_analyze.py +284 -284
- hud/cli/tests/test_cli_init.py +265 -265
- hud/cli/tests/test_cli_main.py +27 -27
- hud/cli/tests/test_clone.py +142 -142
- hud/cli/tests/test_cursor.py +253 -253
- hud/cli/tests/test_debug.py +453 -453
- hud/cli/tests/test_mcp_server.py +139 -139
- hud/cli/tests/test_utils.py +388 -388
- hud/cli/utils.py +263 -263
- hud/clients/README.md +143 -143
- hud/clients/__init__.py +16 -16
- hud/clients/base.py +378 -379
- hud/clients/fastmcp.py +222 -222
- hud/clients/mcp_use.py +298 -278
- hud/clients/tests/__init__.py +1 -1
- hud/clients/tests/test_client_integration.py +111 -111
- hud/clients/tests/test_fastmcp.py +342 -342
- hud/clients/tests/test_protocol.py +188 -188
- hud/clients/utils/__init__.py +1 -1
- hud/clients/utils/retry_transport.py +160 -160
- hud/datasets.py +327 -322
- hud/misc/__init__.py +1 -1
- hud/misc/claude_plays_pokemon.py +292 -292
- hud/otel/__init__.py +35 -35
- hud/otel/collector.py +142 -142
- hud/otel/config.py +164 -164
- hud/otel/context.py +536 -536
- hud/otel/exporters.py +366 -366
- hud/otel/instrumentation.py +97 -97
- hud/otel/processors.py +118 -118
- hud/otel/tests/__init__.py +1 -1
- hud/otel/tests/test_processors.py +197 -197
- hud/server/__init__.py +5 -5
- hud/server/context.py +114 -114
- hud/server/helper/__init__.py +5 -5
- hud/server/low_level.py +132 -132
- hud/server/server.py +170 -166
- hud/server/tests/__init__.py +3 -3
- hud/settings.py +73 -73
- hud/shared/__init__.py +5 -5
- hud/shared/exceptions.py +180 -180
- hud/shared/requests.py +264 -264
- hud/shared/tests/test_exceptions.py +157 -157
- hud/shared/tests/test_requests.py +275 -275
- hud/telemetry/__init__.py +25 -25
- hud/telemetry/instrument.py +379 -379
- hud/telemetry/job.py +309 -309
- hud/telemetry/replay.py +74 -74
- hud/telemetry/trace.py +83 -83
- hud/tools/__init__.py +33 -33
- hud/tools/base.py +365 -365
- hud/tools/bash.py +161 -161
- hud/tools/computer/__init__.py +15 -15
- hud/tools/computer/anthropic.py +437 -437
- hud/tools/computer/hud.py +376 -376
- hud/tools/computer/openai.py +295 -295
- hud/tools/computer/settings.py +82 -82
- hud/tools/edit.py +314 -314
- hud/tools/executors/__init__.py +30 -30
- hud/tools/executors/base.py +539 -539
- hud/tools/executors/pyautogui.py +621 -621
- 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 -511
- hud/tools/playwright.py +412 -412
- hud/tools/tests/__init__.py +3 -3
- hud/tools/tests/test_base.py +282 -282
- hud/tools/tests/test_bash.py +158 -158
- hud/tools/tests/test_bash_extended.py +197 -197
- hud/tools/tests/test_computer.py +425 -425
- hud/tools/tests/test_computer_actions.py +34 -34
- hud/tools/tests/test_edit.py +259 -259
- hud/tools/tests/test_init.py +27 -27
- hud/tools/tests/test_playwright_tool.py +183 -183
- hud/tools/tests/test_tools.py +145 -145
- hud/tools/tests/test_utils.py +156 -156
- hud/tools/types.py +72 -72
- hud/tools/utils.py +50 -50
- hud/types.py +136 -136
- hud/utils/__init__.py +10 -10
- hud/utils/async_utils.py +65 -65
- hud/utils/design.py +236 -168
- hud/utils/mcp.py +55 -55
- hud/utils/progress.py +149 -149
- hud/utils/telemetry.py +66 -66
- hud/utils/tests/test_async_utils.py +173 -173
- hud/utils/tests/test_init.py +17 -17
- hud/utils/tests/test_progress.py +261 -261
- hud/utils/tests/test_telemetry.py +82 -82
- hud/utils/tests/test_version.py +8 -8
- hud/version.py +7 -7
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/METADATA +10 -8
- hud_python-0.4.3.dist-info/RECORD +131 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
- hud/agents/art.py +0 -101
- hud_python-0.4.1.dist-info/RECORD +0 -132
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/WHEEL +0 -0
- {hud_python-0.4.1.dist-info → hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/clients/fastmcp.py
CHANGED
|
@@ -1,222 +1,222 @@
|
|
|
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 =
|
|
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
|
-
try:
|
|
86
|
-
await self._stack.enter_async_context(self._client)
|
|
87
|
-
except Exception as e:
|
|
88
|
-
# Check for authentication errors
|
|
89
|
-
error_msg = str(e)
|
|
90
|
-
if "401" in error_msg or "Unauthorized" in error_msg:
|
|
91
|
-
# Check if connecting to HUD API
|
|
92
|
-
for server_config in mcp_config.values():
|
|
93
|
-
url = server_config.get("url", "")
|
|
94
|
-
if "mcp.hud.so" in url:
|
|
95
|
-
raise RuntimeError(
|
|
96
|
-
"Authentication failed for HUD API. "
|
|
97
|
-
"Please ensure your HUD_API_KEY environment variable is set correctly. "
|
|
98
|
-
"You can get an API key at https://app.hud.so"
|
|
99
|
-
) from e
|
|
100
|
-
# Generic 401 error
|
|
101
|
-
raise RuntimeError(
|
|
102
|
-
f"Authentication failed (401 Unauthorized). "
|
|
103
|
-
f"Please check your credentials or API key."
|
|
104
|
-
) from e
|
|
105
|
-
raise
|
|
106
|
-
|
|
107
|
-
# Configure validation for output schemas based on client setting
|
|
108
|
-
from mcp.client.session import ValidationOptions
|
|
109
|
-
|
|
110
|
-
if (
|
|
111
|
-
hasattr(self._client, "_session_state")
|
|
112
|
-
and self._client._session_state.session is not None
|
|
113
|
-
):
|
|
114
|
-
self._client._session_state.session._validation_options = ValidationOptions(
|
|
115
|
-
strict_output_validation=self._strict_validation
|
|
116
|
-
)
|
|
117
|
-
|
|
118
|
-
logger.info("FastMCP client connected")
|
|
119
|
-
|
|
120
|
-
async def list_tools(self) -> list[types.Tool]:
|
|
121
|
-
"""List all available tools."""
|
|
122
|
-
if self._client is None:
|
|
123
|
-
raise ValueError("Client is not connected, call initialize() first")
|
|
124
|
-
return await self._client.list_tools()
|
|
125
|
-
|
|
126
|
-
async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
|
|
127
|
-
"""Execute a tool by name."""
|
|
128
|
-
if self._client is None:
|
|
129
|
-
raise ValueError("Client is not connected, call initialize() first")
|
|
130
|
-
|
|
131
|
-
# FastMCP returns a different result type, convert it
|
|
132
|
-
result = await self._client.call_tool(
|
|
133
|
-
name=tool_call.name,
|
|
134
|
-
arguments=tool_call.arguments or {},
|
|
135
|
-
raise_on_error=False, # Don't raise, return error in result
|
|
136
|
-
)
|
|
137
|
-
|
|
138
|
-
# Convert FastMCP result to MCPToolResult
|
|
139
|
-
return MCPToolResult(
|
|
140
|
-
content=result.content,
|
|
141
|
-
isError=result.is_error,
|
|
142
|
-
structuredContent=result.structured_content,
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
async def list_resources(self) -> list[types.Resource]:
|
|
146
|
-
"""List all available resources."""
|
|
147
|
-
if self._client is None:
|
|
148
|
-
raise ValueError("Client is not connected, call initialize() first")
|
|
149
|
-
return await self._client.list_resources()
|
|
150
|
-
|
|
151
|
-
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult | None:
|
|
152
|
-
"""Read a resource by URI."""
|
|
153
|
-
if self._client is None:
|
|
154
|
-
raise ValueError("Client is not connected, call initialize() first")
|
|
155
|
-
try:
|
|
156
|
-
contents = await self._client.read_resource(uri)
|
|
157
|
-
return types.ReadResourceResult(contents=contents)
|
|
158
|
-
except McpError as e:
|
|
159
|
-
if "telemetry://" in str(uri):
|
|
160
|
-
logger.debug("Telemetry resource not supported by server: %s", e)
|
|
161
|
-
elif self.verbose:
|
|
162
|
-
logger.debug("MCP resource error for '%s': %s", uri, e)
|
|
163
|
-
return None
|
|
164
|
-
except Exception as e:
|
|
165
|
-
if "telemetry://" in str(uri):
|
|
166
|
-
logger.debug("Failed to fetch telemetry: %s", e)
|
|
167
|
-
else:
|
|
168
|
-
logger.warning("Unexpected error reading resource '%s': %s", uri, e)
|
|
169
|
-
return None
|
|
170
|
-
|
|
171
|
-
async def _disconnect(self) -> None:
|
|
172
|
-
"""Close the client connection, ensuring the underlying transport is terminated."""
|
|
173
|
-
if self._client is None:
|
|
174
|
-
logger.warning("Client is not connected, cannot disconnect")
|
|
175
|
-
return
|
|
176
|
-
|
|
177
|
-
# First close any active async context stack (this triggers client.__aexit__()).
|
|
178
|
-
if self._stack:
|
|
179
|
-
await self._stack.aclose()
|
|
180
|
-
self._stack = None
|
|
181
|
-
|
|
182
|
-
try:
|
|
183
|
-
# Close the FastMCP client - this calls transport.close()
|
|
184
|
-
await self._client.close()
|
|
185
|
-
|
|
186
|
-
# CRITICAL: Cancel any lingering transport tasks to ensure subprocess termination
|
|
187
|
-
# FastMCP's StdioTransport creates asyncio tasks that can outlive the client
|
|
188
|
-
# We need to handle nested transport structures (MCPConfigTransport -> StdioTransport)
|
|
189
|
-
transport = getattr(self._client, "transport", None)
|
|
190
|
-
if transport:
|
|
191
|
-
# If it's an MCPConfigTransport with a nested transport
|
|
192
|
-
if hasattr(transport, "transport"):
|
|
193
|
-
transport = transport.transport
|
|
194
|
-
|
|
195
|
-
# Now check if it's a StdioTransport with a _connect_task
|
|
196
|
-
if (
|
|
197
|
-
hasattr(transport, "_connect_task")
|
|
198
|
-
and transport._connect_task
|
|
199
|
-
and not transport._connect_task.done()
|
|
200
|
-
):
|
|
201
|
-
logger.debug("Canceling lingering StdioTransport connect task")
|
|
202
|
-
transport._connect_task.cancel()
|
|
203
|
-
try:
|
|
204
|
-
await transport._connect_task
|
|
205
|
-
except asyncio.CancelledError:
|
|
206
|
-
logger.debug("Transport task cancelled successfully")
|
|
207
|
-
except Exception as e:
|
|
208
|
-
logger.debug("Error canceling transport task: %s", e)
|
|
209
|
-
|
|
210
|
-
except Exception as e:
|
|
211
|
-
logger.debug("Error while closing FastMCP client transport: %s", e)
|
|
212
|
-
|
|
213
|
-
logger.debug("FastMCP client closed")
|
|
214
|
-
|
|
215
|
-
async def __aenter__(self: Any) -> Any:
|
|
216
|
-
"""Async context manager entry."""
|
|
217
|
-
await self.initialize()
|
|
218
|
-
return self
|
|
219
|
-
|
|
220
|
-
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
221
|
-
"""Async context manager exit."""
|
|
222
|
-
await self.shutdown()
|
|
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 = 10 * 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
|
+
try:
|
|
86
|
+
await self._stack.enter_async_context(self._client)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
# Check for authentication errors
|
|
89
|
+
error_msg = str(e)
|
|
90
|
+
if "401" in error_msg or "Unauthorized" in error_msg:
|
|
91
|
+
# Check if connecting to HUD API
|
|
92
|
+
for server_config in mcp_config.values():
|
|
93
|
+
url = server_config.get("url", "")
|
|
94
|
+
if "mcp.hud.so" in url:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
"Authentication failed for HUD API. "
|
|
97
|
+
"Please ensure your HUD_API_KEY environment variable is set correctly. "
|
|
98
|
+
"You can get an API key at https://app.hud.so"
|
|
99
|
+
) from e
|
|
100
|
+
# Generic 401 error
|
|
101
|
+
raise RuntimeError(
|
|
102
|
+
f"Authentication failed (401 Unauthorized). "
|
|
103
|
+
f"Please check your credentials or API key."
|
|
104
|
+
) from e
|
|
105
|
+
raise
|
|
106
|
+
|
|
107
|
+
# Configure validation for output schemas based on client setting
|
|
108
|
+
from mcp.client.session import ValidationOptions
|
|
109
|
+
|
|
110
|
+
if (
|
|
111
|
+
hasattr(self._client, "_session_state")
|
|
112
|
+
and self._client._session_state.session is not None
|
|
113
|
+
):
|
|
114
|
+
self._client._session_state.session._validation_options = ValidationOptions(
|
|
115
|
+
strict_output_validation=self._strict_validation
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
logger.info("FastMCP client connected")
|
|
119
|
+
|
|
120
|
+
async def list_tools(self) -> list[types.Tool]:
|
|
121
|
+
"""List all available tools."""
|
|
122
|
+
if self._client is None:
|
|
123
|
+
raise ValueError("Client is not connected, call initialize() first")
|
|
124
|
+
return await self._client.list_tools()
|
|
125
|
+
|
|
126
|
+
async def _call_tool(self, tool_call: MCPToolCall) -> MCPToolResult:
|
|
127
|
+
"""Execute a tool by name."""
|
|
128
|
+
if self._client is None:
|
|
129
|
+
raise ValueError("Client is not connected, call initialize() first")
|
|
130
|
+
|
|
131
|
+
# FastMCP returns a different result type, convert it
|
|
132
|
+
result = await self._client.call_tool(
|
|
133
|
+
name=tool_call.name,
|
|
134
|
+
arguments=tool_call.arguments or {},
|
|
135
|
+
raise_on_error=False, # Don't raise, return error in result
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
# Convert FastMCP result to MCPToolResult
|
|
139
|
+
return MCPToolResult(
|
|
140
|
+
content=result.content,
|
|
141
|
+
isError=result.is_error,
|
|
142
|
+
structuredContent=result.structured_content,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
async def list_resources(self) -> list[types.Resource]:
|
|
146
|
+
"""List all available resources."""
|
|
147
|
+
if self._client is None:
|
|
148
|
+
raise ValueError("Client is not connected, call initialize() first")
|
|
149
|
+
return await self._client.list_resources()
|
|
150
|
+
|
|
151
|
+
async def read_resource(self, uri: str | AnyUrl) -> types.ReadResourceResult | None:
|
|
152
|
+
"""Read a resource by URI."""
|
|
153
|
+
if self._client is None:
|
|
154
|
+
raise ValueError("Client is not connected, call initialize() first")
|
|
155
|
+
try:
|
|
156
|
+
contents = await self._client.read_resource(uri)
|
|
157
|
+
return types.ReadResourceResult(contents=contents)
|
|
158
|
+
except McpError as e:
|
|
159
|
+
if "telemetry://" in str(uri):
|
|
160
|
+
logger.debug("Telemetry resource not supported by server: %s", e)
|
|
161
|
+
elif self.verbose:
|
|
162
|
+
logger.debug("MCP resource error for '%s': %s", uri, e)
|
|
163
|
+
return None
|
|
164
|
+
except Exception as e:
|
|
165
|
+
if "telemetry://" in str(uri):
|
|
166
|
+
logger.debug("Failed to fetch telemetry: %s", e)
|
|
167
|
+
else:
|
|
168
|
+
logger.warning("Unexpected error reading resource '%s': %s", uri, e)
|
|
169
|
+
return None
|
|
170
|
+
|
|
171
|
+
async def _disconnect(self) -> None:
|
|
172
|
+
"""Close the client connection, ensuring the underlying transport is terminated."""
|
|
173
|
+
if self._client is None:
|
|
174
|
+
logger.warning("Client is not connected, cannot disconnect")
|
|
175
|
+
return
|
|
176
|
+
|
|
177
|
+
# First close any active async context stack (this triggers client.__aexit__()).
|
|
178
|
+
if self._stack:
|
|
179
|
+
await self._stack.aclose()
|
|
180
|
+
self._stack = None
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
# Close the FastMCP client - this calls transport.close()
|
|
184
|
+
await self._client.close()
|
|
185
|
+
|
|
186
|
+
# CRITICAL: Cancel any lingering transport tasks to ensure subprocess termination
|
|
187
|
+
# FastMCP's StdioTransport creates asyncio tasks that can outlive the client
|
|
188
|
+
# We need to handle nested transport structures (MCPConfigTransport -> StdioTransport)
|
|
189
|
+
transport = getattr(self._client, "transport", None)
|
|
190
|
+
if transport:
|
|
191
|
+
# If it's an MCPConfigTransport with a nested transport
|
|
192
|
+
if hasattr(transport, "transport"):
|
|
193
|
+
transport = transport.transport
|
|
194
|
+
|
|
195
|
+
# Now check if it's a StdioTransport with a _connect_task
|
|
196
|
+
if (
|
|
197
|
+
hasattr(transport, "_connect_task")
|
|
198
|
+
and transport._connect_task
|
|
199
|
+
and not transport._connect_task.done()
|
|
200
|
+
):
|
|
201
|
+
logger.debug("Canceling lingering StdioTransport connect task")
|
|
202
|
+
transport._connect_task.cancel()
|
|
203
|
+
try:
|
|
204
|
+
await transport._connect_task
|
|
205
|
+
except asyncio.CancelledError:
|
|
206
|
+
logger.debug("Transport task cancelled successfully")
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.debug("Error canceling transport task: %s", e)
|
|
209
|
+
|
|
210
|
+
except Exception as e:
|
|
211
|
+
logger.debug("Error while closing FastMCP client transport: %s", e)
|
|
212
|
+
|
|
213
|
+
logger.debug("FastMCP client closed")
|
|
214
|
+
|
|
215
|
+
async def __aenter__(self: Any) -> Any:
|
|
216
|
+
"""Async context manager entry."""
|
|
217
|
+
await self.initialize()
|
|
218
|
+
return self
|
|
219
|
+
|
|
220
|
+
async def __aexit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
221
|
+
"""Async context manager exit."""
|
|
222
|
+
await self.shutdown()
|