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/cli/interactive.py
CHANGED
|
@@ -1,353 +1,353 @@
|
|
|
1
|
-
"""Interactive mode for testing MCP environments."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
from typing import Any
|
|
7
|
-
|
|
8
|
-
import questionary
|
|
9
|
-
from mcp.types import TextContent
|
|
10
|
-
from rich.console import Console
|
|
11
|
-
from rich.panel import Panel
|
|
12
|
-
from rich.prompt import Prompt
|
|
13
|
-
from rich.syntax import Syntax
|
|
14
|
-
from rich.tree import Tree
|
|
15
|
-
|
|
16
|
-
from hud.clients import MCPClient
|
|
17
|
-
from hud.utils.design import HUDDesign
|
|
18
|
-
|
|
19
|
-
console = Console()
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class InteractiveMCPTester:
|
|
23
|
-
"""Interactive MCP environment tester."""
|
|
24
|
-
|
|
25
|
-
def __init__(self, server_url: str, verbose: bool = False) -> None:
|
|
26
|
-
"""Initialize the interactive tester.
|
|
27
|
-
|
|
28
|
-
Args:
|
|
29
|
-
server_url: URL of the MCP server (e.g., http://localhost:8765/mcp)
|
|
30
|
-
verbose: Enable verbose output
|
|
31
|
-
"""
|
|
32
|
-
self.server_url = server_url
|
|
33
|
-
self.verbose = verbose
|
|
34
|
-
self.client: MCPClient | None = None
|
|
35
|
-
self.tools: list[Any] = []
|
|
36
|
-
self.design = HUDDesign()
|
|
37
|
-
|
|
38
|
-
async def connect(self) -> bool:
|
|
39
|
-
"""Connect to the MCP server."""
|
|
40
|
-
try:
|
|
41
|
-
# Create MCP config for HTTP transport
|
|
42
|
-
config = {"server": {"url": self.server_url}}
|
|
43
|
-
|
|
44
|
-
self.client = MCPClient(
|
|
45
|
-
mcp_config=config,
|
|
46
|
-
verbose=self.verbose,
|
|
47
|
-
auto_trace=False, # Disable telemetry for interactive testing
|
|
48
|
-
)
|
|
49
|
-
await self.client.initialize()
|
|
50
|
-
|
|
51
|
-
# Fetch available tools
|
|
52
|
-
self.tools = await self.client.list_tools()
|
|
53
|
-
|
|
54
|
-
return True
|
|
55
|
-
except Exception as e:
|
|
56
|
-
self.design.error(f"Failed to connect: {e}")
|
|
57
|
-
return False
|
|
58
|
-
|
|
59
|
-
async def disconnect(self) -> None:
|
|
60
|
-
"""Disconnect from the MCP server."""
|
|
61
|
-
if self.client:
|
|
62
|
-
await self.client.shutdown()
|
|
63
|
-
self.client = None
|
|
64
|
-
|
|
65
|
-
def display_tools(self) -> None:
|
|
66
|
-
"""Display available tools in a nice format."""
|
|
67
|
-
if not self.tools:
|
|
68
|
-
console.print("[yellow]No tools available[/yellow]")
|
|
69
|
-
return
|
|
70
|
-
|
|
71
|
-
# Group tools by hub
|
|
72
|
-
regular_tools = []
|
|
73
|
-
hub_tools = {}
|
|
74
|
-
|
|
75
|
-
for tool in self.tools:
|
|
76
|
-
if "/" in tool.name:
|
|
77
|
-
hub, name = tool.name.split("/", 1)
|
|
78
|
-
if hub not in hub_tools:
|
|
79
|
-
hub_tools[hub] = []
|
|
80
|
-
hub_tools[hub].append(tool)
|
|
81
|
-
else:
|
|
82
|
-
regular_tools.append(tool)
|
|
83
|
-
|
|
84
|
-
# Display tools tree
|
|
85
|
-
tree = Tree("🔧 Available Tools")
|
|
86
|
-
|
|
87
|
-
if regular_tools:
|
|
88
|
-
regular_node = tree.add("[cyan]Regular Tools[/cyan]")
|
|
89
|
-
for i, tool in enumerate(regular_tools, 1):
|
|
90
|
-
tool_node = regular_node.add(f"{i}. [white]{tool.name}[/white]")
|
|
91
|
-
if tool.description:
|
|
92
|
-
tool_node.add(f"[dim]{tool.description}[/dim]")
|
|
93
|
-
|
|
94
|
-
# Add hub tools
|
|
95
|
-
tool_index = len(regular_tools) + 1
|
|
96
|
-
for hub_name, tools in hub_tools.items():
|
|
97
|
-
hub_node = tree.add(f"[yellow]{hub_name} Hub[/yellow]")
|
|
98
|
-
for tool in tools:
|
|
99
|
-
tool_node = hub_node.add(f"{tool_index}. [white]{tool.name}[/white]")
|
|
100
|
-
if tool.description:
|
|
101
|
-
tool_node.add(f"[dim]{tool.description}[/dim]")
|
|
102
|
-
tool_index += 1
|
|
103
|
-
|
|
104
|
-
console.print(tree)
|
|
105
|
-
|
|
106
|
-
async def select_tool(self) -> Any | None:
|
|
107
|
-
"""Let user select a tool."""
|
|
108
|
-
if not self.tools:
|
|
109
|
-
return None
|
|
110
|
-
|
|
111
|
-
# Build choices list
|
|
112
|
-
choices = []
|
|
113
|
-
tool_map = {}
|
|
114
|
-
|
|
115
|
-
for _, tool in enumerate(self.tools):
|
|
116
|
-
# Create display name
|
|
117
|
-
if "/" in tool.name:
|
|
118
|
-
hub, name = tool.name.split("/", 1)
|
|
119
|
-
display = f"[{hub}] {name}"
|
|
120
|
-
else:
|
|
121
|
-
display = tool.name
|
|
122
|
-
|
|
123
|
-
# Add description if available
|
|
124
|
-
if tool.description:
|
|
125
|
-
display += f" - {tool.description}"
|
|
126
|
-
|
|
127
|
-
choices.append(display)
|
|
128
|
-
tool_map[display] = tool
|
|
129
|
-
|
|
130
|
-
# Add quit option
|
|
131
|
-
choices.append("❌ Quit")
|
|
132
|
-
|
|
133
|
-
# Show selection menu with arrow keys
|
|
134
|
-
console.print("\n[cyan]Select a tool (use arrow keys):[/cyan]")
|
|
135
|
-
|
|
136
|
-
try:
|
|
137
|
-
# Use questionary's async select with custom styling
|
|
138
|
-
selected = await questionary.select(
|
|
139
|
-
"",
|
|
140
|
-
choices=choices,
|
|
141
|
-
style=questionary.Style(
|
|
142
|
-
[
|
|
143
|
-
("question", ""),
|
|
144
|
-
("pointer", "fg:#ff9d00 bold"),
|
|
145
|
-
("highlighted", "fg:#ff9d00 bold"),
|
|
146
|
-
("selected", "fg:#cc5454"),
|
|
147
|
-
("separator", "fg:#6c6c6c"),
|
|
148
|
-
("instruction", "fg:#858585 italic"),
|
|
149
|
-
]
|
|
150
|
-
),
|
|
151
|
-
).unsafe_ask_async()
|
|
152
|
-
|
|
153
|
-
if selected is None:
|
|
154
|
-
console.print("[yellow]No selection made (ESC or Ctrl+C pressed)[/yellow]")
|
|
155
|
-
return None
|
|
156
|
-
|
|
157
|
-
if selected == "❌ Quit":
|
|
158
|
-
return None
|
|
159
|
-
|
|
160
|
-
return tool_map[selected]
|
|
161
|
-
|
|
162
|
-
except KeyboardInterrupt:
|
|
163
|
-
console.print("[yellow]Interrupted by user[/yellow]")
|
|
164
|
-
return None
|
|
165
|
-
except Exception as e:
|
|
166
|
-
console.print(f"[red]Error in tool selection: {e}[/red]")
|
|
167
|
-
return None
|
|
168
|
-
|
|
169
|
-
async def get_tool_arguments(self, tool: Any) -> dict[str, Any] | None:
|
|
170
|
-
"""Prompt user for tool arguments."""
|
|
171
|
-
if not hasattr(tool, "inputSchema") or not tool.inputSchema:
|
|
172
|
-
return {}
|
|
173
|
-
|
|
174
|
-
schema = tool.inputSchema
|
|
175
|
-
|
|
176
|
-
# Show schema
|
|
177
|
-
console.print("\n[yellow]Tool Parameters:[/yellow]")
|
|
178
|
-
schema_str = json.dumps(schema, indent=2)
|
|
179
|
-
syntax = Syntax(schema_str, "json", theme="monokai", line_numbers=False)
|
|
180
|
-
console.print(Panel(syntax, title=f"{tool.name} Schema", border_style="dim"))
|
|
181
|
-
|
|
182
|
-
# Handle different schema types
|
|
183
|
-
if schema.get("type") == "object":
|
|
184
|
-
properties = schema.get("properties", {})
|
|
185
|
-
required = schema.get("required", [])
|
|
186
|
-
|
|
187
|
-
if not properties:
|
|
188
|
-
return {}
|
|
189
|
-
|
|
190
|
-
# Prompt for each property
|
|
191
|
-
args = {}
|
|
192
|
-
for prop_name, prop_schema in properties.items():
|
|
193
|
-
prop_type = prop_schema.get("type", "string")
|
|
194
|
-
description = prop_schema.get("description", "")
|
|
195
|
-
is_required = prop_name in required
|
|
196
|
-
|
|
197
|
-
# Build prompt
|
|
198
|
-
prompt = f"{prop_name}"
|
|
199
|
-
if description:
|
|
200
|
-
prompt += f" ({description})"
|
|
201
|
-
if not is_required:
|
|
202
|
-
prompt += " [optional]"
|
|
203
|
-
|
|
204
|
-
# Get value based on type
|
|
205
|
-
if prop_type == "boolean":
|
|
206
|
-
if is_required:
|
|
207
|
-
value = await questionary.confirm(prompt).unsafe_ask_async()
|
|
208
|
-
else:
|
|
209
|
-
# For optional booleans, offer a choice
|
|
210
|
-
choice = await questionary.select(
|
|
211
|
-
prompt, choices=["true", "false", "skip (leave unset)"]
|
|
212
|
-
).unsafe_ask_async()
|
|
213
|
-
if choice == "skip (leave unset)":
|
|
214
|
-
continue
|
|
215
|
-
value = choice == "true"
|
|
216
|
-
elif prop_type == "number" or prop_type == "integer":
|
|
217
|
-
value_str = await questionary.text(
|
|
218
|
-
prompt,
|
|
219
|
-
default="",
|
|
220
|
-
validate=lambda text, pt=prop_type, req=is_required: True
|
|
221
|
-
if not text and not req
|
|
222
|
-
else (
|
|
223
|
-
text.replace("-", "").replace(".", "").isdigit()
|
|
224
|
-
if pt == "number"
|
|
225
|
-
else text.replace("-", "").isdigit()
|
|
226
|
-
)
|
|
227
|
-
or f"Please enter a valid {pt}",
|
|
228
|
-
).unsafe_ask_async()
|
|
229
|
-
if not value_str and not is_required:
|
|
230
|
-
continue
|
|
231
|
-
value = int(value_str) if prop_type == "integer" else float(value_str)
|
|
232
|
-
elif prop_type == "array":
|
|
233
|
-
value_str = await questionary.text(
|
|
234
|
-
prompt + " (comma-separated)", default=""
|
|
235
|
-
).unsafe_ask_async()
|
|
236
|
-
if not value_str and not is_required:
|
|
237
|
-
continue
|
|
238
|
-
value = [v.strip() for v in value_str.split(",")]
|
|
239
|
-
else: # string or unknown
|
|
240
|
-
value = await questionary.text(prompt, default="").unsafe_ask_async()
|
|
241
|
-
if not value and not is_required:
|
|
242
|
-
continue
|
|
243
|
-
|
|
244
|
-
args[prop_name] = value
|
|
245
|
-
|
|
246
|
-
return args
|
|
247
|
-
else:
|
|
248
|
-
# For non-object schemas, just get a single value
|
|
249
|
-
console.print("[yellow]Enter value (or press Enter to skip):[/yellow]")
|
|
250
|
-
value = Prompt.ask("Value", default="")
|
|
251
|
-
return {"value": value} if value else {}
|
|
252
|
-
|
|
253
|
-
async def call_tool(self, tool: Any, arguments: dict[str, Any]) -> None:
|
|
254
|
-
"""Call a tool and display results."""
|
|
255
|
-
if not self.client:
|
|
256
|
-
return
|
|
257
|
-
|
|
258
|
-
try:
|
|
259
|
-
# Show what we're calling
|
|
260
|
-
console.print(f"\n[cyan]Calling {tool.name}...[/cyan]")
|
|
261
|
-
if arguments:
|
|
262
|
-
console.print(f"[dim]Arguments: {json.dumps(arguments, indent=2)}[/dim]")
|
|
263
|
-
|
|
264
|
-
# Make the call
|
|
265
|
-
result = await self.client.call_tool(name=tool.name, arguments=arguments)
|
|
266
|
-
|
|
267
|
-
# Display results
|
|
268
|
-
console.print("\n[green]✓ Tool executed successfully[/green]")
|
|
269
|
-
|
|
270
|
-
if result.isError:
|
|
271
|
-
console.print("[red]Error result:[/red]")
|
|
272
|
-
|
|
273
|
-
# Display content blocks
|
|
274
|
-
for content in result.content:
|
|
275
|
-
if isinstance(content, TextContent):
|
|
276
|
-
console.print(
|
|
277
|
-
Panel(
|
|
278
|
-
content.text,
|
|
279
|
-
title="Result",
|
|
280
|
-
border_style="green" if not result.isError else "red",
|
|
281
|
-
)
|
|
282
|
-
)
|
|
283
|
-
else:
|
|
284
|
-
# Handle other content types
|
|
285
|
-
console.print(json.dumps(content, indent=2))
|
|
286
|
-
|
|
287
|
-
except Exception as e:
|
|
288
|
-
console.print(f"[red]✗ Tool execution failed: {e}[/red]")
|
|
289
|
-
|
|
290
|
-
async def run(self) -> None:
|
|
291
|
-
"""Run the interactive testing loop."""
|
|
292
|
-
self.design.header("Interactive MCP Tester")
|
|
293
|
-
|
|
294
|
-
# Connect to server
|
|
295
|
-
console.print(f"[cyan]Connecting to {self.server_url}...[/cyan]")
|
|
296
|
-
if not await self.connect():
|
|
297
|
-
return
|
|
298
|
-
|
|
299
|
-
console.print("[green]✓ Connected successfully[/green]")
|
|
300
|
-
console.print(f"[dim]Found {len(self.tools)} tools[/dim]\n")
|
|
301
|
-
|
|
302
|
-
try:
|
|
303
|
-
while True:
|
|
304
|
-
# Select tool
|
|
305
|
-
tool = await self.select_tool()
|
|
306
|
-
if not tool:
|
|
307
|
-
break
|
|
308
|
-
|
|
309
|
-
# Get arguments
|
|
310
|
-
console.print(f"\n[cyan]Selected: {tool.name}[/cyan]")
|
|
311
|
-
arguments = await self.get_tool_arguments(tool)
|
|
312
|
-
if arguments is None:
|
|
313
|
-
console.print("[yellow]Skipping tool call[/yellow]")
|
|
314
|
-
continue
|
|
315
|
-
|
|
316
|
-
# Call tool
|
|
317
|
-
await self.call_tool(tool, arguments)
|
|
318
|
-
|
|
319
|
-
# Just add a separator and continue to tool selection
|
|
320
|
-
console.print("\n" + "─" * 50)
|
|
321
|
-
|
|
322
|
-
finally:
|
|
323
|
-
# Disconnect
|
|
324
|
-
console.print("\n[cyan]Disconnecting...[/cyan]")
|
|
325
|
-
await self.disconnect()
|
|
326
|
-
|
|
327
|
-
# Show next steps tutorial
|
|
328
|
-
self.design.section_title("Next Steps")
|
|
329
|
-
self.design.info("🏗️ Ready to test with real agents? Run:")
|
|
330
|
-
self.design.info(" [cyan]hud build[/cyan]")
|
|
331
|
-
self.design.info("")
|
|
332
|
-
self.design.info("This will:")
|
|
333
|
-
self.design.info(" 1. Build your environment image")
|
|
334
|
-
self.design.info(" 2. Generate a hud.lock.yaml file")
|
|
335
|
-
self.design.info(" 3. Prepare it for testing with agents")
|
|
336
|
-
self.design.info("")
|
|
337
|
-
self.design.info("Then you can:")
|
|
338
|
-
self.design.info(" • Test locally: [cyan]hud run <image>[/cyan]")
|
|
339
|
-
self.design.info(" • Push to registry: [cyan]hud push --image <registry/name>[/cyan]")
|
|
340
|
-
self.design.info(" • Use with agents via the lock file")
|
|
341
|
-
|
|
342
|
-
console.print("\n[dim]Happy testing! 🎉[/dim]")
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
async def run_interactive_mode(server_url: str, verbose: bool = False) -> None:
|
|
346
|
-
"""Run interactive MCP testing mode.
|
|
347
|
-
|
|
348
|
-
Args:
|
|
349
|
-
server_url: URL of the MCP server
|
|
350
|
-
verbose: Enable verbose output
|
|
351
|
-
"""
|
|
352
|
-
tester = InteractiveMCPTester(server_url, verbose)
|
|
353
|
-
await tester.run()
|
|
1
|
+
"""Interactive mode for testing MCP environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import questionary
|
|
9
|
+
from mcp.types import TextContent
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.panel import Panel
|
|
12
|
+
from rich.prompt import Prompt
|
|
13
|
+
from rich.syntax import Syntax
|
|
14
|
+
from rich.tree import Tree
|
|
15
|
+
|
|
16
|
+
from hud.clients import MCPClient
|
|
17
|
+
from hud.utils.design import HUDDesign
|
|
18
|
+
|
|
19
|
+
console = Console()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InteractiveMCPTester:
|
|
23
|
+
"""Interactive MCP environment tester."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, server_url: str, verbose: bool = False) -> None:
|
|
26
|
+
"""Initialize the interactive tester.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
server_url: URL of the MCP server (e.g., http://localhost:8765/mcp)
|
|
30
|
+
verbose: Enable verbose output
|
|
31
|
+
"""
|
|
32
|
+
self.server_url = server_url
|
|
33
|
+
self.verbose = verbose
|
|
34
|
+
self.client: MCPClient | None = None
|
|
35
|
+
self.tools: list[Any] = []
|
|
36
|
+
self.design = HUDDesign()
|
|
37
|
+
|
|
38
|
+
async def connect(self) -> bool:
|
|
39
|
+
"""Connect to the MCP server."""
|
|
40
|
+
try:
|
|
41
|
+
# Create MCP config for HTTP transport
|
|
42
|
+
config = {"server": {"url": self.server_url}}
|
|
43
|
+
|
|
44
|
+
self.client = MCPClient(
|
|
45
|
+
mcp_config=config,
|
|
46
|
+
verbose=self.verbose,
|
|
47
|
+
auto_trace=False, # Disable telemetry for interactive testing
|
|
48
|
+
)
|
|
49
|
+
await self.client.initialize()
|
|
50
|
+
|
|
51
|
+
# Fetch available tools
|
|
52
|
+
self.tools = await self.client.list_tools()
|
|
53
|
+
|
|
54
|
+
return True
|
|
55
|
+
except Exception as e:
|
|
56
|
+
self.design.error(f"Failed to connect: {e}")
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
async def disconnect(self) -> None:
|
|
60
|
+
"""Disconnect from the MCP server."""
|
|
61
|
+
if self.client:
|
|
62
|
+
await self.client.shutdown()
|
|
63
|
+
self.client = None
|
|
64
|
+
|
|
65
|
+
def display_tools(self) -> None:
|
|
66
|
+
"""Display available tools in a nice format."""
|
|
67
|
+
if not self.tools:
|
|
68
|
+
console.print("[yellow]No tools available[/yellow]")
|
|
69
|
+
return
|
|
70
|
+
|
|
71
|
+
# Group tools by hub
|
|
72
|
+
regular_tools = []
|
|
73
|
+
hub_tools = {}
|
|
74
|
+
|
|
75
|
+
for tool in self.tools:
|
|
76
|
+
if "/" in tool.name:
|
|
77
|
+
hub, name = tool.name.split("/", 1)
|
|
78
|
+
if hub not in hub_tools:
|
|
79
|
+
hub_tools[hub] = []
|
|
80
|
+
hub_tools[hub].append(tool)
|
|
81
|
+
else:
|
|
82
|
+
regular_tools.append(tool)
|
|
83
|
+
|
|
84
|
+
# Display tools tree
|
|
85
|
+
tree = Tree("🔧 Available Tools")
|
|
86
|
+
|
|
87
|
+
if regular_tools:
|
|
88
|
+
regular_node = tree.add("[cyan]Regular Tools[/cyan]")
|
|
89
|
+
for i, tool in enumerate(regular_tools, 1):
|
|
90
|
+
tool_node = regular_node.add(f"{i}. [white]{tool.name}[/white]")
|
|
91
|
+
if tool.description:
|
|
92
|
+
tool_node.add(f"[dim]{tool.description}[/dim]")
|
|
93
|
+
|
|
94
|
+
# Add hub tools
|
|
95
|
+
tool_index = len(regular_tools) + 1
|
|
96
|
+
for hub_name, tools in hub_tools.items():
|
|
97
|
+
hub_node = tree.add(f"[yellow]{hub_name} Hub[/yellow]")
|
|
98
|
+
for tool in tools:
|
|
99
|
+
tool_node = hub_node.add(f"{tool_index}. [white]{tool.name}[/white]")
|
|
100
|
+
if tool.description:
|
|
101
|
+
tool_node.add(f"[dim]{tool.description}[/dim]")
|
|
102
|
+
tool_index += 1
|
|
103
|
+
|
|
104
|
+
console.print(tree)
|
|
105
|
+
|
|
106
|
+
async def select_tool(self) -> Any | None:
|
|
107
|
+
"""Let user select a tool."""
|
|
108
|
+
if not self.tools:
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
# Build choices list
|
|
112
|
+
choices = []
|
|
113
|
+
tool_map = {}
|
|
114
|
+
|
|
115
|
+
for _, tool in enumerate(self.tools):
|
|
116
|
+
# Create display name
|
|
117
|
+
if "/" in tool.name:
|
|
118
|
+
hub, name = tool.name.split("/", 1)
|
|
119
|
+
display = f"[{hub}] {name}"
|
|
120
|
+
else:
|
|
121
|
+
display = tool.name
|
|
122
|
+
|
|
123
|
+
# Add description if available
|
|
124
|
+
if tool.description:
|
|
125
|
+
display += f" - {tool.description}"
|
|
126
|
+
|
|
127
|
+
choices.append(display)
|
|
128
|
+
tool_map[display] = tool
|
|
129
|
+
|
|
130
|
+
# Add quit option
|
|
131
|
+
choices.append("❌ Quit")
|
|
132
|
+
|
|
133
|
+
# Show selection menu with arrow keys
|
|
134
|
+
console.print("\n[cyan]Select a tool (use arrow keys):[/cyan]")
|
|
135
|
+
|
|
136
|
+
try:
|
|
137
|
+
# Use questionary's async select with custom styling
|
|
138
|
+
selected = await questionary.select(
|
|
139
|
+
"",
|
|
140
|
+
choices=choices,
|
|
141
|
+
style=questionary.Style(
|
|
142
|
+
[
|
|
143
|
+
("question", ""),
|
|
144
|
+
("pointer", "fg:#ff9d00 bold"),
|
|
145
|
+
("highlighted", "fg:#ff9d00 bold"),
|
|
146
|
+
("selected", "fg:#cc5454"),
|
|
147
|
+
("separator", "fg:#6c6c6c"),
|
|
148
|
+
("instruction", "fg:#858585 italic"),
|
|
149
|
+
]
|
|
150
|
+
),
|
|
151
|
+
).unsafe_ask_async()
|
|
152
|
+
|
|
153
|
+
if selected is None:
|
|
154
|
+
console.print("[yellow]No selection made (ESC or Ctrl+C pressed)[/yellow]")
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
if selected == "❌ Quit":
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
return tool_map[selected]
|
|
161
|
+
|
|
162
|
+
except KeyboardInterrupt:
|
|
163
|
+
console.print("[yellow]Interrupted by user[/yellow]")
|
|
164
|
+
return None
|
|
165
|
+
except Exception as e:
|
|
166
|
+
console.print(f"[red]Error in tool selection: {e}[/red]")
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
async def get_tool_arguments(self, tool: Any) -> dict[str, Any] | None:
|
|
170
|
+
"""Prompt user for tool arguments."""
|
|
171
|
+
if not hasattr(tool, "inputSchema") or not tool.inputSchema:
|
|
172
|
+
return {}
|
|
173
|
+
|
|
174
|
+
schema = tool.inputSchema
|
|
175
|
+
|
|
176
|
+
# Show schema
|
|
177
|
+
console.print("\n[yellow]Tool Parameters:[/yellow]")
|
|
178
|
+
schema_str = json.dumps(schema, indent=2)
|
|
179
|
+
syntax = Syntax(schema_str, "json", theme="monokai", line_numbers=False)
|
|
180
|
+
console.print(Panel(syntax, title=f"{tool.name} Schema", border_style="dim"))
|
|
181
|
+
|
|
182
|
+
# Handle different schema types
|
|
183
|
+
if schema.get("type") == "object":
|
|
184
|
+
properties = schema.get("properties", {})
|
|
185
|
+
required = schema.get("required", [])
|
|
186
|
+
|
|
187
|
+
if not properties:
|
|
188
|
+
return {}
|
|
189
|
+
|
|
190
|
+
# Prompt for each property
|
|
191
|
+
args = {}
|
|
192
|
+
for prop_name, prop_schema in properties.items():
|
|
193
|
+
prop_type = prop_schema.get("type", "string")
|
|
194
|
+
description = prop_schema.get("description", "")
|
|
195
|
+
is_required = prop_name in required
|
|
196
|
+
|
|
197
|
+
# Build prompt
|
|
198
|
+
prompt = f"{prop_name}"
|
|
199
|
+
if description:
|
|
200
|
+
prompt += f" ({description})"
|
|
201
|
+
if not is_required:
|
|
202
|
+
prompt += " [optional]"
|
|
203
|
+
|
|
204
|
+
# Get value based on type
|
|
205
|
+
if prop_type == "boolean":
|
|
206
|
+
if is_required:
|
|
207
|
+
value = await questionary.confirm(prompt).unsafe_ask_async()
|
|
208
|
+
else:
|
|
209
|
+
# For optional booleans, offer a choice
|
|
210
|
+
choice = await questionary.select(
|
|
211
|
+
prompt, choices=["true", "false", "skip (leave unset)"]
|
|
212
|
+
).unsafe_ask_async()
|
|
213
|
+
if choice == "skip (leave unset)":
|
|
214
|
+
continue
|
|
215
|
+
value = choice == "true"
|
|
216
|
+
elif prop_type == "number" or prop_type == "integer":
|
|
217
|
+
value_str = await questionary.text(
|
|
218
|
+
prompt,
|
|
219
|
+
default="",
|
|
220
|
+
validate=lambda text, pt=prop_type, req=is_required: True
|
|
221
|
+
if not text and not req
|
|
222
|
+
else (
|
|
223
|
+
text.replace("-", "").replace(".", "").isdigit()
|
|
224
|
+
if pt == "number"
|
|
225
|
+
else text.replace("-", "").isdigit()
|
|
226
|
+
)
|
|
227
|
+
or f"Please enter a valid {pt}",
|
|
228
|
+
).unsafe_ask_async()
|
|
229
|
+
if not value_str and not is_required:
|
|
230
|
+
continue
|
|
231
|
+
value = int(value_str) if prop_type == "integer" else float(value_str)
|
|
232
|
+
elif prop_type == "array":
|
|
233
|
+
value_str = await questionary.text(
|
|
234
|
+
prompt + " (comma-separated)", default=""
|
|
235
|
+
).unsafe_ask_async()
|
|
236
|
+
if not value_str and not is_required:
|
|
237
|
+
continue
|
|
238
|
+
value = [v.strip() for v in value_str.split(",")]
|
|
239
|
+
else: # string or unknown
|
|
240
|
+
value = await questionary.text(prompt, default="").unsafe_ask_async()
|
|
241
|
+
if not value and not is_required:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
args[prop_name] = value
|
|
245
|
+
|
|
246
|
+
return args
|
|
247
|
+
else:
|
|
248
|
+
# For non-object schemas, just get a single value
|
|
249
|
+
console.print("[yellow]Enter value (or press Enter to skip):[/yellow]")
|
|
250
|
+
value = Prompt.ask("Value", default="")
|
|
251
|
+
return {"value": value} if value else {}
|
|
252
|
+
|
|
253
|
+
async def call_tool(self, tool: Any, arguments: dict[str, Any]) -> None:
|
|
254
|
+
"""Call a tool and display results."""
|
|
255
|
+
if not self.client:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
try:
|
|
259
|
+
# Show what we're calling
|
|
260
|
+
console.print(f"\n[cyan]Calling {tool.name}...[/cyan]")
|
|
261
|
+
if arguments:
|
|
262
|
+
console.print(f"[dim]Arguments: {json.dumps(arguments, indent=2)}[/dim]")
|
|
263
|
+
|
|
264
|
+
# Make the call
|
|
265
|
+
result = await self.client.call_tool(name=tool.name, arguments=arguments)
|
|
266
|
+
|
|
267
|
+
# Display results
|
|
268
|
+
console.print("\n[green]✓ Tool executed successfully[/green]")
|
|
269
|
+
|
|
270
|
+
if result.isError:
|
|
271
|
+
console.print("[red]Error result:[/red]")
|
|
272
|
+
|
|
273
|
+
# Display content blocks
|
|
274
|
+
for content in result.content:
|
|
275
|
+
if isinstance(content, TextContent):
|
|
276
|
+
console.print(
|
|
277
|
+
Panel(
|
|
278
|
+
content.text,
|
|
279
|
+
title="Result",
|
|
280
|
+
border_style="green" if not result.isError else "red",
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
else:
|
|
284
|
+
# Handle other content types
|
|
285
|
+
console.print(json.dumps(content, indent=2))
|
|
286
|
+
|
|
287
|
+
except Exception as e:
|
|
288
|
+
console.print(f"[red]✗ Tool execution failed: {e}[/red]")
|
|
289
|
+
|
|
290
|
+
async def run(self) -> None:
|
|
291
|
+
"""Run the interactive testing loop."""
|
|
292
|
+
self.design.header("Interactive MCP Tester")
|
|
293
|
+
|
|
294
|
+
# Connect to server
|
|
295
|
+
console.print(f"[cyan]Connecting to {self.server_url}...[/cyan]")
|
|
296
|
+
if not await self.connect():
|
|
297
|
+
return
|
|
298
|
+
|
|
299
|
+
console.print("[green]✓ Connected successfully[/green]")
|
|
300
|
+
console.print(f"[dim]Found {len(self.tools)} tools[/dim]\n")
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
while True:
|
|
304
|
+
# Select tool
|
|
305
|
+
tool = await self.select_tool()
|
|
306
|
+
if not tool:
|
|
307
|
+
break
|
|
308
|
+
|
|
309
|
+
# Get arguments
|
|
310
|
+
console.print(f"\n[cyan]Selected: {tool.name}[/cyan]")
|
|
311
|
+
arguments = await self.get_tool_arguments(tool)
|
|
312
|
+
if arguments is None:
|
|
313
|
+
console.print("[yellow]Skipping tool call[/yellow]")
|
|
314
|
+
continue
|
|
315
|
+
|
|
316
|
+
# Call tool
|
|
317
|
+
await self.call_tool(tool, arguments)
|
|
318
|
+
|
|
319
|
+
# Just add a separator and continue to tool selection
|
|
320
|
+
console.print("\n" + "─" * 50)
|
|
321
|
+
|
|
322
|
+
finally:
|
|
323
|
+
# Disconnect
|
|
324
|
+
console.print("\n[cyan]Disconnecting...[/cyan]")
|
|
325
|
+
await self.disconnect()
|
|
326
|
+
|
|
327
|
+
# Show next steps tutorial
|
|
328
|
+
self.design.section_title("Next Steps")
|
|
329
|
+
self.design.info("🏗️ Ready to test with real agents? Run:")
|
|
330
|
+
self.design.info(" [cyan]hud build[/cyan]")
|
|
331
|
+
self.design.info("")
|
|
332
|
+
self.design.info("This will:")
|
|
333
|
+
self.design.info(" 1. Build your environment image")
|
|
334
|
+
self.design.info(" 2. Generate a hud.lock.yaml file")
|
|
335
|
+
self.design.info(" 3. Prepare it for testing with agents")
|
|
336
|
+
self.design.info("")
|
|
337
|
+
self.design.info("Then you can:")
|
|
338
|
+
self.design.info(" • Test locally: [cyan]hud run <image>[/cyan]")
|
|
339
|
+
self.design.info(" • Push to registry: [cyan]hud push --image <registry/name>[/cyan]")
|
|
340
|
+
self.design.info(" • Use with agents via the lock file")
|
|
341
|
+
|
|
342
|
+
console.print("\n[dim]Happy testing! 🎉[/dim]")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
async def run_interactive_mode(server_url: str, verbose: bool = False) -> None:
|
|
346
|
+
"""Run interactive MCP testing mode.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
server_url: URL of the MCP server
|
|
350
|
+
verbose: Enable verbose output
|
|
351
|
+
"""
|
|
352
|
+
tester = InteractiveMCPTester(server_url, verbose)
|
|
353
|
+
await tester.run()
|