hud-python 0.4.11__py3-none-any.whl → 0.4.13__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/__main__.py +8 -0
- hud/agents/base.py +7 -8
- hud/agents/langchain.py +2 -2
- hud/agents/tests/test_openai.py +3 -1
- hud/cli/__init__.py +114 -52
- hud/cli/build.py +121 -71
- hud/cli/debug.py +2 -2
- hud/cli/{mcp_server.py → dev.py} +101 -38
- hud/cli/eval.py +175 -90
- hud/cli/init.py +442 -64
- hud/cli/list_func.py +72 -71
- hud/cli/pull.py +1 -2
- hud/cli/push.py +35 -23
- hud/cli/remove.py +35 -41
- hud/cli/tests/test_analyze.py +2 -1
- hud/cli/tests/test_analyze_metadata.py +42 -49
- hud/cli/tests/test_build.py +28 -52
- hud/cli/tests/test_cursor.py +1 -1
- hud/cli/tests/test_debug.py +1 -1
- hud/cli/tests/test_list_func.py +75 -64
- hud/cli/tests/test_main_module.py +30 -0
- hud/cli/tests/test_mcp_server.py +3 -3
- hud/cli/tests/test_pull.py +30 -61
- hud/cli/tests/test_push.py +70 -89
- hud/cli/tests/test_registry.py +36 -38
- hud/cli/tests/test_utils.py +1 -1
- hud/cli/utils/__init__.py +1 -0
- hud/cli/{docker_utils.py → utils/docker.py} +36 -0
- hud/cli/{env_utils.py → utils/environment.py} +7 -7
- hud/cli/{interactive.py → utils/interactive.py} +91 -19
- hud/cli/{analyze_metadata.py → utils/metadata.py} +12 -8
- hud/cli/{registry.py → utils/registry.py} +28 -30
- hud/cli/{remote_runner.py → utils/remote_runner.py} +1 -1
- hud/cli/utils/runner.py +134 -0
- hud/cli/utils/server.py +250 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +5 -13
- hud/clients/mcp_use.py +6 -10
- hud/server/server.py +35 -5
- hud/shared/exceptions.py +11 -0
- hud/shared/tests/test_exceptions.py +22 -0
- hud/telemetry/tests/__init__.py +0 -0
- hud/telemetry/tests/test_replay.py +40 -0
- hud/telemetry/tests/test_trace.py +63 -0
- hud/tools/base.py +20 -3
- hud/tools/computer/hud.py +15 -6
- hud/tools/executors/tests/test_base_executor.py +27 -0
- hud/tools/response.py +12 -8
- hud/tools/tests/test_response.py +60 -0
- hud/tools/tests/test_tools_init.py +49 -0
- hud/utils/design.py +19 -8
- hud/utils/mcp.py +17 -5
- hud/utils/tests/test_mcp.py +112 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/METADATA +16 -13
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/RECORD +62 -52
- hud/cli/runner.py +0 -160
- /hud/cli/{cursor.py → utils/cursor.py} +0 -0
- /hud/cli/{utils.py → utils/logging.py} +0 -0
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/WHEEL +0 -0
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.11.dist-info → hud_python-0.4.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -112,52 +112,103 @@ class InteractiveMCPTester:
|
|
|
112
112
|
choices = []
|
|
113
113
|
tool_map = {}
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
# Group tools by hub for better organization
|
|
116
|
+
hub_groups = {}
|
|
117
|
+
regular_tools = []
|
|
118
|
+
|
|
119
|
+
for tool in self.tools:
|
|
117
120
|
if "/" in tool.name:
|
|
118
121
|
hub, name = tool.name.split("/", 1)
|
|
119
|
-
|
|
122
|
+
if hub not in hub_groups:
|
|
123
|
+
hub_groups[hub] = []
|
|
124
|
+
hub_groups[hub].append((name, tool))
|
|
120
125
|
else:
|
|
121
|
-
|
|
126
|
+
regular_tools.append(tool)
|
|
127
|
+
|
|
128
|
+
# Add regular tools first
|
|
129
|
+
if regular_tools:
|
|
130
|
+
# Add a separator for regular tools section
|
|
131
|
+
if len(hub_groups) > 0:
|
|
132
|
+
choices.append("───── Regular Tools ─────")
|
|
133
|
+
|
|
134
|
+
for tool in regular_tools:
|
|
135
|
+
# Format: Bold tool name with color + dim description
|
|
136
|
+
if tool.description:
|
|
137
|
+
display = f"• {tool.name} │ {tool.description}"
|
|
138
|
+
else:
|
|
139
|
+
display = f"• {tool.name}"
|
|
140
|
+
|
|
141
|
+
choices.append(display)
|
|
142
|
+
tool_map[display] = tool
|
|
122
143
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
144
|
+
# Add hub-grouped tools with visual separation
|
|
145
|
+
for hub_name, tools in sorted(hub_groups.items()):
|
|
146
|
+
# Add a visual separator for each hub
|
|
147
|
+
choices.append(f"───── {hub_name} ─────")
|
|
126
148
|
|
|
127
|
-
|
|
128
|
-
|
|
149
|
+
for name, tool in sorted(tools, key=lambda x: x[0]):
|
|
150
|
+
# Format with hub indicator and better separation
|
|
151
|
+
if tool.description:
|
|
152
|
+
# Remove redundant description text
|
|
153
|
+
desc = tool.description
|
|
154
|
+
# Truncate long descriptions
|
|
155
|
+
if len(desc) > 60:
|
|
156
|
+
desc = desc[:57] + "..."
|
|
157
|
+
display = f"• {name} │ {desc}"
|
|
158
|
+
else:
|
|
159
|
+
display = f"• {name}"
|
|
160
|
+
|
|
161
|
+
choices.append(display)
|
|
162
|
+
tool_map[display] = tool
|
|
129
163
|
|
|
130
|
-
# Add quit option
|
|
164
|
+
# Add quit option with spacing
|
|
165
|
+
choices.append("─────────────────────")
|
|
131
166
|
choices.append("❌ Quit")
|
|
132
167
|
|
|
133
168
|
# Show selection menu with arrow keys
|
|
134
169
|
console.print("\n[cyan]Select a tool (use arrow keys):[/cyan]")
|
|
135
170
|
|
|
136
171
|
try:
|
|
137
|
-
#
|
|
172
|
+
# Create custom Choice objects for better formatting
|
|
173
|
+
from questionary import Choice
|
|
174
|
+
|
|
175
|
+
formatted_choices = []
|
|
176
|
+
for choice in choices:
|
|
177
|
+
if choice.startswith("─────"):
|
|
178
|
+
# Separator - make it unselectable and styled
|
|
179
|
+
formatted_choices.append(Choice(title=choice, disabled=True, shortcut_key=None)) # type: ignore[arg-type]
|
|
180
|
+
elif choice == "❌ Quit":
|
|
181
|
+
formatted_choices.append(choice)
|
|
182
|
+
else:
|
|
183
|
+
formatted_choices.append(choice)
|
|
184
|
+
|
|
185
|
+
# Use questionary's async select with enhanced styling
|
|
138
186
|
selected = await questionary.select(
|
|
139
187
|
"",
|
|
140
|
-
choices=
|
|
188
|
+
choices=formatted_choices,
|
|
141
189
|
style=questionary.Style(
|
|
142
190
|
[
|
|
143
191
|
("question", ""),
|
|
144
|
-
("pointer", "fg:#ff9d00 bold"),
|
|
145
|
-
("highlighted", "fg:#
|
|
146
|
-
("selected", "fg:#
|
|
147
|
-
("separator", "fg:#
|
|
148
|
-
("instruction", "fg:#858585 italic"),
|
|
192
|
+
("pointer", "fg:#ff9d00 bold"), # Orange pointer
|
|
193
|
+
("highlighted", "fg:#00d7ff bold"), # Bright cyan for highlighted
|
|
194
|
+
("selected", "fg:#00ff00 bold"), # Green for selected
|
|
195
|
+
("separator", "fg:#666666"), # Gray for separators
|
|
196
|
+
("instruction", "fg:#858585 italic"), # Dim instructions
|
|
197
|
+
("disabled", "fg:#666666"), # Gray for disabled items
|
|
198
|
+
("text", "fg:#ffffff"), # White text
|
|
149
199
|
]
|
|
150
200
|
),
|
|
201
|
+
instruction="(Use ↑/↓ arrows, Enter to select, Esc to cancel)",
|
|
151
202
|
).unsafe_ask_async()
|
|
152
203
|
|
|
153
204
|
if selected is None:
|
|
154
205
|
console.print("[yellow]No selection made (ESC or Ctrl+C pressed)[/yellow]")
|
|
155
206
|
return None
|
|
156
207
|
|
|
157
|
-
if selected == "❌ Quit":
|
|
208
|
+
if selected == "❌ Quit" or selected.startswith("─────"):
|
|
158
209
|
return None
|
|
159
210
|
|
|
160
|
-
return tool_map
|
|
211
|
+
return tool_map.get(selected)
|
|
161
212
|
|
|
162
213
|
except KeyboardInterrupt:
|
|
163
214
|
console.print("[yellow]Interrupted by user[/yellow]")
|
|
@@ -236,6 +287,27 @@ class InteractiveMCPTester:
|
|
|
236
287
|
if not value_str and not is_required:
|
|
237
288
|
continue
|
|
238
289
|
value = [v.strip() for v in value_str.split(",")]
|
|
290
|
+
elif prop_type == "object":
|
|
291
|
+
# For object types, allow JSON input
|
|
292
|
+
console.print(f"[dim]Enter JSON object for {prop_name}:[/dim]")
|
|
293
|
+
value_str = await questionary.text(
|
|
294
|
+
prompt + " (JSON format)", default="{}"
|
|
295
|
+
).unsafe_ask_async()
|
|
296
|
+
if not value_str and not is_required:
|
|
297
|
+
continue
|
|
298
|
+
try:
|
|
299
|
+
value = json.loads(value_str)
|
|
300
|
+
except json.JSONDecodeError as e:
|
|
301
|
+
console.print(f"[red]Invalid JSON: {e}[/red]")
|
|
302
|
+
# Try again
|
|
303
|
+
value_str = await questionary.text(
|
|
304
|
+
prompt + " (JSON format, please fix the error)", default=value_str
|
|
305
|
+
).unsafe_ask_async()
|
|
306
|
+
try:
|
|
307
|
+
value = json.loads(value_str)
|
|
308
|
+
except json.JSONDecodeError:
|
|
309
|
+
console.print("[red]Still invalid JSON, using empty object[/red]")
|
|
310
|
+
value = {}
|
|
239
311
|
else: # string or unknown
|
|
240
312
|
value = await questionary.text(prompt, default="").unsafe_ask_async()
|
|
241
313
|
if not value and not is_required:
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
-
from pathlib import Path
|
|
6
5
|
from urllib.parse import quote
|
|
7
6
|
|
|
8
7
|
import requests
|
|
@@ -13,7 +12,11 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
|
13
12
|
from hud.settings import settings
|
|
14
13
|
from hud.utils.design import HUDDesign
|
|
15
14
|
|
|
16
|
-
from .registry import
|
|
15
|
+
from .registry import (
|
|
16
|
+
extract_digest_from_image,
|
|
17
|
+
list_registry_entries,
|
|
18
|
+
load_from_registry,
|
|
19
|
+
)
|
|
17
20
|
|
|
18
21
|
console = Console()
|
|
19
22
|
design = HUDDesign()
|
|
@@ -60,13 +63,13 @@ def check_local_cache(reference: str) -> dict | None:
|
|
|
60
63
|
lock_data = load_from_registry(digest)
|
|
61
64
|
if lock_data:
|
|
62
65
|
return lock_data
|
|
63
|
-
|
|
66
|
+
|
|
64
67
|
# If not found and reference has a name, search by name pattern
|
|
65
68
|
if "/" in reference:
|
|
66
69
|
# Look for any cached version of this image
|
|
67
70
|
ref_base = reference.split("@")[0].split(":")[0]
|
|
68
|
-
|
|
69
|
-
for
|
|
71
|
+
|
|
72
|
+
for _, lock_file in list_registry_entries():
|
|
70
73
|
try:
|
|
71
74
|
with open(lock_file) as f:
|
|
72
75
|
lock_data = yaml.safe_load(f)
|
|
@@ -78,8 +81,8 @@ def check_local_cache(reference: str) -> dict | None:
|
|
|
78
81
|
if ref_base in img_base or img_base in ref_base:
|
|
79
82
|
return lock_data
|
|
80
83
|
except Exception:
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
design.error("Error loading lock file")
|
|
85
|
+
|
|
83
86
|
return None
|
|
84
87
|
|
|
85
88
|
|
|
@@ -87,7 +90,7 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
|
|
|
87
90
|
"""Analyze environment from cached or registry metadata."""
|
|
88
91
|
import json
|
|
89
92
|
|
|
90
|
-
from .analyze import display_interactive, display_markdown
|
|
93
|
+
from hud.cli.analyze import display_interactive, display_markdown
|
|
91
94
|
|
|
92
95
|
design.header("MCP Environment Analysis", icon="🔍")
|
|
93
96
|
design.info(f"Looking up: {reference}")
|
|
@@ -146,6 +149,7 @@ async def analyze_from_metadata(reference: str, output_format: str, verbose: boo
|
|
|
146
149
|
|
|
147
150
|
# Save to local cache for next time
|
|
148
151
|
from .registry import save_to_registry
|
|
152
|
+
|
|
149
153
|
save_to_registry(lock_data, lock_data.get("image", ""), verbose=False)
|
|
150
154
|
else:
|
|
151
155
|
progress.update(task, description="[red]✗ Not found[/red]")
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
|
-
from typing import
|
|
6
|
+
from typing import Any
|
|
7
7
|
|
|
8
8
|
import yaml
|
|
9
9
|
|
|
@@ -17,10 +17,10 @@ def get_registry_dir() -> Path:
|
|
|
17
17
|
|
|
18
18
|
def extract_digest_from_image(image_ref: str) -> str:
|
|
19
19
|
"""Extract a digest identifier from a Docker image reference.
|
|
20
|
-
|
|
20
|
+
|
|
21
21
|
Args:
|
|
22
22
|
image_ref: Docker image reference (e.g., "image:tag@sha256:abc123...")
|
|
23
|
-
|
|
23
|
+
|
|
24
24
|
Returns:
|
|
25
25
|
Digest string for use as directory name (max 12 chars)
|
|
26
26
|
"""
|
|
@@ -41,13 +41,13 @@ def extract_digest_from_image(image_ref: str) -> str:
|
|
|
41
41
|
|
|
42
42
|
def extract_name_and_tag(image_ref: str) -> tuple[str, str]:
|
|
43
43
|
"""Extract organization/name and tag from Docker image reference.
|
|
44
|
-
|
|
44
|
+
|
|
45
45
|
Args:
|
|
46
46
|
image_ref: Docker image reference
|
|
47
|
-
|
|
47
|
+
|
|
48
48
|
Returns:
|
|
49
49
|
Tuple of (name, tag) where name includes org/repo
|
|
50
|
-
|
|
50
|
+
|
|
51
51
|
Examples:
|
|
52
52
|
docker.io/hudpython/test_init:latest@sha256:... -> (hudpython/test_init, latest)
|
|
53
53
|
hudpython/myenv:v1.0 -> (hudpython/myenv, v1.0)
|
|
@@ -56,54 +56,52 @@ def extract_name_and_tag(image_ref: str) -> tuple[str, str]:
|
|
|
56
56
|
# Remove digest if present
|
|
57
57
|
if "@" in image_ref:
|
|
58
58
|
image_ref = image_ref.split("@")[0]
|
|
59
|
-
|
|
59
|
+
|
|
60
60
|
# Remove registry prefix if present
|
|
61
61
|
if image_ref.startswith(("docker.io/", "registry-1.docker.io/", "index.docker.io/")):
|
|
62
62
|
image_ref = "/".join(image_ref.split("/")[1:])
|
|
63
|
-
|
|
63
|
+
|
|
64
64
|
# Extract tag
|
|
65
65
|
if ":" in image_ref:
|
|
66
66
|
name, tag = image_ref.rsplit(":", 1)
|
|
67
67
|
else:
|
|
68
68
|
name = image_ref
|
|
69
69
|
tag = "latest"
|
|
70
|
-
|
|
70
|
+
|
|
71
71
|
return name, tag
|
|
72
72
|
|
|
73
73
|
|
|
74
74
|
def save_to_registry(
|
|
75
|
-
lock_data:
|
|
76
|
-
|
|
77
|
-
verbose: bool = False
|
|
78
|
-
) -> Optional[Path]:
|
|
75
|
+
lock_data: dict[str, Any], image_ref: str, verbose: bool = False
|
|
76
|
+
) -> Path | None:
|
|
79
77
|
"""Save environment lock data to the local registry.
|
|
80
|
-
|
|
78
|
+
|
|
81
79
|
Args:
|
|
82
80
|
lock_data: The lock file data to save
|
|
83
81
|
image_ref: Docker image reference for digest extraction
|
|
84
82
|
verbose: Whether to show verbose output
|
|
85
|
-
|
|
83
|
+
|
|
86
84
|
Returns:
|
|
87
85
|
Path to the saved lock file, or None if save failed
|
|
88
86
|
"""
|
|
89
87
|
design = HUDDesign()
|
|
90
|
-
|
|
88
|
+
|
|
91
89
|
try:
|
|
92
90
|
# Extract digest for registry storage
|
|
93
91
|
digest = extract_digest_from_image(image_ref)
|
|
94
|
-
|
|
92
|
+
|
|
95
93
|
# Store under ~/.hud/envs/<digest>/
|
|
96
94
|
local_env_dir = get_registry_dir() / digest
|
|
97
95
|
local_env_dir.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
|
|
96
|
+
|
|
99
97
|
local_lock_path = local_env_dir / "hud.lock.yaml"
|
|
100
98
|
with open(local_lock_path, "w") as f:
|
|
101
99
|
yaml.dump(lock_data, f, default_flow_style=False, sort_keys=False)
|
|
102
|
-
|
|
100
|
+
|
|
103
101
|
design.success(f"Added to local registry: {digest}")
|
|
104
102
|
if verbose:
|
|
105
103
|
design.info(f"Registry location: {local_lock_path}")
|
|
106
|
-
|
|
104
|
+
|
|
107
105
|
return local_lock_path
|
|
108
106
|
except Exception as e:
|
|
109
107
|
if verbose:
|
|
@@ -111,20 +109,20 @@ def save_to_registry(
|
|
|
111
109
|
return None
|
|
112
110
|
|
|
113
111
|
|
|
114
|
-
def load_from_registry(digest: str) ->
|
|
112
|
+
def load_from_registry(digest: str) -> dict[str, Any] | None:
|
|
115
113
|
"""Load environment lock data from the local registry.
|
|
116
|
-
|
|
114
|
+
|
|
117
115
|
Args:
|
|
118
116
|
digest: The digest/identifier of the environment
|
|
119
|
-
|
|
117
|
+
|
|
120
118
|
Returns:
|
|
121
119
|
Lock data dictionary, or None if not found
|
|
122
120
|
"""
|
|
123
121
|
lock_path = get_registry_dir() / digest / "hud.lock.yaml"
|
|
124
|
-
|
|
122
|
+
|
|
125
123
|
if not lock_path.exists():
|
|
126
124
|
return None
|
|
127
|
-
|
|
125
|
+
|
|
128
126
|
try:
|
|
129
127
|
with open(lock_path) as f:
|
|
130
128
|
return yaml.safe_load(f)
|
|
@@ -134,22 +132,22 @@ def load_from_registry(digest: str) -> Optional[Dict[str, Any]]:
|
|
|
134
132
|
|
|
135
133
|
def list_registry_entries() -> list[tuple[str, Path]]:
|
|
136
134
|
"""List all entries in the local registry.
|
|
137
|
-
|
|
135
|
+
|
|
138
136
|
Returns:
|
|
139
137
|
List of (digest, lock_path) tuples
|
|
140
138
|
"""
|
|
141
139
|
registry_dir = get_registry_dir()
|
|
142
|
-
|
|
140
|
+
|
|
143
141
|
if not registry_dir.exists():
|
|
144
142
|
return []
|
|
145
|
-
|
|
143
|
+
|
|
146
144
|
entries = []
|
|
147
145
|
for digest_dir in registry_dir.iterdir():
|
|
148
146
|
if not digest_dir.is_dir():
|
|
149
147
|
continue
|
|
150
|
-
|
|
148
|
+
|
|
151
149
|
lock_file = digest_dir / "hud.lock.yaml"
|
|
152
150
|
if lock_file.exists():
|
|
153
151
|
entries.append((digest_dir.name, lock_file))
|
|
154
|
-
|
|
152
|
+
|
|
155
153
|
return entries
|
|
@@ -199,7 +199,7 @@ async def run_remote_http(
|
|
|
199
199
|
verbose: bool = False,
|
|
200
200
|
) -> None:
|
|
201
201
|
"""Run remote MCP server with HTTP transport."""
|
|
202
|
-
from .
|
|
202
|
+
from .logging import find_free_port
|
|
203
203
|
|
|
204
204
|
# Find available port
|
|
205
205
|
actual_port = find_free_port(port)
|
hud/cli/utils/runner.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Run Docker images as MCP servers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from hud.utils.design import HUDDesign
|
|
10
|
+
|
|
11
|
+
from .logging import find_free_port
|
|
12
|
+
from .server import MCPServerManager, run_server_with_interactive
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def run_stdio_server(image: str, docker_args: list[str], verbose: bool) -> None:
|
|
16
|
+
"""Run Docker image as stdio MCP server (direct passthrough)."""
|
|
17
|
+
design = HUDDesign() # Use stderr for stdio mode
|
|
18
|
+
|
|
19
|
+
# Build docker command
|
|
20
|
+
docker_cmd = ["docker", "run", "--rm", "-i", *docker_args, image]
|
|
21
|
+
|
|
22
|
+
if verbose:
|
|
23
|
+
design.info(f"🐳 Running: {' '.join(docker_cmd)}")
|
|
24
|
+
|
|
25
|
+
# Run docker directly with stdio passthrough
|
|
26
|
+
try:
|
|
27
|
+
result = subprocess.run(docker_cmd, stdin=sys.stdin) # noqa: S603
|
|
28
|
+
sys.exit(result.returncode)
|
|
29
|
+
except KeyboardInterrupt:
|
|
30
|
+
design.info("\n👋 Shutting down...")
|
|
31
|
+
sys.exit(0)
|
|
32
|
+
except Exception as e:
|
|
33
|
+
design.error(f"Error: {e}")
|
|
34
|
+
sys.exit(1)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def run_http_server(image: str, docker_args: list[str], port: int, verbose: bool) -> None:
|
|
38
|
+
"""Run Docker image as HTTP MCP server (proxy mode)."""
|
|
39
|
+
design = HUDDesign()
|
|
40
|
+
|
|
41
|
+
# Create server manager
|
|
42
|
+
server_manager = MCPServerManager(image, docker_args)
|
|
43
|
+
|
|
44
|
+
# Find available port
|
|
45
|
+
actual_port = find_free_port(port)
|
|
46
|
+
if actual_port is None:
|
|
47
|
+
design.error(f"No available ports found starting from {port}")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
if actual_port != port:
|
|
51
|
+
design.warning(f"Port {port} in use, using port {actual_port} instead")
|
|
52
|
+
|
|
53
|
+
# Clean up any existing container
|
|
54
|
+
server_manager.cleanup_container()
|
|
55
|
+
|
|
56
|
+
# Build docker command
|
|
57
|
+
docker_cmd = server_manager.build_docker_command()
|
|
58
|
+
|
|
59
|
+
# Create MCP config
|
|
60
|
+
config = server_manager.create_mcp_config(docker_cmd)
|
|
61
|
+
|
|
62
|
+
# Create proxy
|
|
63
|
+
proxy = server_manager.create_proxy(config)
|
|
64
|
+
|
|
65
|
+
# Show header
|
|
66
|
+
design.info("") # Empty line
|
|
67
|
+
design.header("HUD MCP Server", icon="🌐")
|
|
68
|
+
|
|
69
|
+
# Show configuration
|
|
70
|
+
design.section_title("Server Information")
|
|
71
|
+
design.info(f"Port: {actual_port}")
|
|
72
|
+
design.info(f"URL: http://localhost:{actual_port}/mcp")
|
|
73
|
+
design.info(f"Container: {server_manager.container_name}")
|
|
74
|
+
design.info("")
|
|
75
|
+
design.progress_message("Press Ctrl+C to stop")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
await server_manager.run_http_server(proxy, actual_port, verbose)
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
design.info("\n👋 Shutting down...")
|
|
81
|
+
finally:
|
|
82
|
+
# Clean up container
|
|
83
|
+
server_manager.cleanup_container()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
async def run_http_server_interactive(
|
|
87
|
+
image: str, docker_args: list[str], port: int, verbose: bool
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Run Docker image as HTTP MCP server with interactive testing."""
|
|
90
|
+
# Create server manager
|
|
91
|
+
server_manager = MCPServerManager(image, docker_args)
|
|
92
|
+
|
|
93
|
+
# Use the shared utility function
|
|
94
|
+
await run_server_with_interactive(server_manager, port, verbose)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_mcp_server(
|
|
98
|
+
image: str,
|
|
99
|
+
docker_args: list[str],
|
|
100
|
+
transport: str,
|
|
101
|
+
port: int,
|
|
102
|
+
verbose: bool,
|
|
103
|
+
interactive: bool = False,
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Run Docker image as MCP server with specified transport."""
|
|
106
|
+
if transport == "stdio":
|
|
107
|
+
if interactive:
|
|
108
|
+
design = HUDDesign()
|
|
109
|
+
design.error("Interactive mode requires HTTP transport")
|
|
110
|
+
sys.exit(1)
|
|
111
|
+
run_stdio_server(image, docker_args, verbose)
|
|
112
|
+
elif transport == "http":
|
|
113
|
+
if interactive:
|
|
114
|
+
# Run in interactive mode
|
|
115
|
+
asyncio.run(run_http_server_interactive(image, docker_args, port, verbose))
|
|
116
|
+
else:
|
|
117
|
+
try:
|
|
118
|
+
asyncio.run(run_http_server(image, docker_args, port, verbose))
|
|
119
|
+
except Exception as e:
|
|
120
|
+
# Suppress the graceful shutdown errors
|
|
121
|
+
if not any(
|
|
122
|
+
x in str(e)
|
|
123
|
+
for x in [
|
|
124
|
+
"timeout graceful shutdown exceeded",
|
|
125
|
+
"Cancel 0 running task(s)",
|
|
126
|
+
"Application shutdown complete",
|
|
127
|
+
]
|
|
128
|
+
):
|
|
129
|
+
design = HUDDesign()
|
|
130
|
+
design.error(f"Unexpected error: {e}")
|
|
131
|
+
else:
|
|
132
|
+
design = HUDDesign()
|
|
133
|
+
design.error(f"Unknown transport: {transport}")
|
|
134
|
+
sys.exit(1)
|