hud-python 0.4.48__py3-none-any.whl → 0.4.49__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/agents/base.py +40 -34
- hud/agents/grounded_openai.py +1 -1
- hud/cli/__init__.py +78 -213
- hud/cli/build.py +105 -45
- hud/cli/dev.py +614 -743
- hud/cli/flows/tasks.py +98 -17
- hud/cli/init.py +18 -14
- hud/cli/push.py +27 -9
- hud/cli/rl/local_runner.py +3 -3
- hud/cli/tests/test_eval.py +168 -119
- hud/cli/tests/test_mcp_server.py +6 -95
- hud/cli/utils/env_check.py +9 -9
- hud/cli/utils/source_hash.py +1 -1
- hud/server/__init__.py +2 -1
- hud/server/router.py +160 -0
- hud/server/server.py +246 -79
- hud/tools/base.py +9 -1
- hud/utils/hud_console.py +43 -0
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.48.dist-info → hud_python-0.4.49.dist-info}/METADATA +1 -1
- {hud_python-0.4.48.dist-info → hud_python-0.4.49.dist-info}/RECORD +25 -24
- {hud_python-0.4.48.dist-info → hud_python-0.4.49.dist-info}/WHEEL +0 -0
- {hud_python-0.4.48.dist-info → hud_python-0.4.49.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.48.dist-info → hud_python-0.4.49.dist-info}/licenses/LICENSE +0 -0
hud/cli/dev.py
CHANGED
|
@@ -1,828 +1,699 @@
|
|
|
1
|
-
"""MCP Development
|
|
1
|
+
"""MCP Development Server - Hot-reload Python modules."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
-
import
|
|
7
|
-
import
|
|
6
|
+
import importlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
8
10
|
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import threading
|
|
9
13
|
from pathlib import Path
|
|
10
14
|
from typing import Any
|
|
11
15
|
|
|
12
|
-
import click
|
|
13
|
-
from fastmcp import FastMCP
|
|
14
|
-
|
|
15
16
|
from hud.utils.hud_console import HUDConsole
|
|
16
17
|
|
|
17
|
-
from .utils.docker import get_docker_cmd
|
|
18
|
-
from .utils.environment import (
|
|
19
|
-
build_environment,
|
|
20
|
-
get_image_name,
|
|
21
|
-
image_exists,
|
|
22
|
-
update_pyproject_toml,
|
|
23
|
-
)
|
|
24
|
-
|
|
25
|
-
# Global hud_console instance
|
|
26
18
|
hud_console = HUDConsole()
|
|
27
19
|
|
|
28
20
|
|
|
29
|
-
def
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
21
|
+
def show_dev_server_info(
|
|
22
|
+
server_name: str,
|
|
23
|
+
port: int,
|
|
24
|
+
transport: str,
|
|
25
|
+
inspector: bool,
|
|
26
|
+
interactive: bool,
|
|
27
|
+
env_dir: Path | None = None,
|
|
28
|
+
) -> str:
|
|
29
|
+
"""Show consistent server info for both Python and Docker modes.
|
|
33
30
|
|
|
31
|
+
Returns the Cursor deeplink URL.
|
|
32
|
+
"""
|
|
33
|
+
import base64
|
|
34
|
+
import json
|
|
35
|
+
|
|
36
|
+
# Generate Cursor deeplink
|
|
37
|
+
server_config = {"url": f"http://localhost:{port}/mcp"}
|
|
38
|
+
config_json = json.dumps(server_config, indent=2)
|
|
39
|
+
config_base64 = base64.b64encode(config_json.encode()).decode()
|
|
40
|
+
cursor_deeplink = (
|
|
41
|
+
f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
|
|
42
|
+
)
|
|
34
43
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
interactive: bool = False,
|
|
43
|
-
) -> FastMCP:
|
|
44
|
-
"""Create an HTTP proxy server that forwards to Docker container with hot-reload."""
|
|
45
|
-
project_path = Path(directory)
|
|
44
|
+
# Server section
|
|
45
|
+
hud_console.section_title("Server")
|
|
46
|
+
hud_console.info(f"{hud_console.sym.ITEM} {server_name}")
|
|
47
|
+
if transport == "http":
|
|
48
|
+
hud_console.info(f"{hud_console.sym.ITEM} http://localhost:{port}/mcp")
|
|
49
|
+
else:
|
|
50
|
+
hud_console.info(f"{hud_console.sym.ITEM} (stdio)")
|
|
46
51
|
|
|
47
|
-
#
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
hud_console.
|
|
51
|
-
|
|
52
|
+
# Quick Links (only for HTTP mode)
|
|
53
|
+
if transport == "http":
|
|
54
|
+
hud_console.section_title("Quick Links")
|
|
55
|
+
hud_console.info(f"{hud_console.sym.ITEM} Docs: http://localhost:{port}/docs")
|
|
56
|
+
hud_console.info(f"{hud_console.sym.ITEM} Cursor: {cursor_deeplink}")
|
|
52
57
|
|
|
53
|
-
|
|
54
|
-
|
|
58
|
+
# Check for VNC (browser environment)
|
|
59
|
+
if env_dir and (env_dir / "environment" / "server.py").exists():
|
|
60
|
+
try:
|
|
61
|
+
content = (env_dir / "environment" / "server.py").read_text()
|
|
62
|
+
if "x11vnc" in content.lower() or "vnc" in content.lower():
|
|
63
|
+
hud_console.info(f"{hud_console.sym.ITEM} VNC: http://localhost:8080/vnc.html")
|
|
64
|
+
except Exception: # noqa: S110
|
|
65
|
+
pass
|
|
66
|
+
|
|
67
|
+
# Inspector/Interactive status
|
|
68
|
+
if inspector or interactive:
|
|
69
|
+
hud_console.info("")
|
|
70
|
+
if inspector:
|
|
71
|
+
hud_console.info(f"{hud_console.sym.SUCCESS} Inspector launching...")
|
|
72
|
+
if interactive:
|
|
73
|
+
hud_console.info(f"{hud_console.sym.SUCCESS} Interactive mode enabled")
|
|
55
74
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
75
|
+
hud_console.info("")
|
|
76
|
+
hud_console.info(f"{hud_console.sym.SUCCESS} Hot-reload enabled")
|
|
77
|
+
hud_console.info("")
|
|
59
78
|
|
|
60
|
-
|
|
61
|
-
docker_cmd = [
|
|
62
|
-
"docker",
|
|
63
|
-
"run",
|
|
64
|
-
"--rm",
|
|
65
|
-
"-i",
|
|
66
|
-
"--name",
|
|
67
|
-
container_name,
|
|
68
|
-
"-v",
|
|
69
|
-
f"{project_path.absolute()}:/app:rw",
|
|
70
|
-
"-e",
|
|
71
|
-
"PYTHONPATH=/app",
|
|
72
|
-
"-e",
|
|
73
|
-
"PYTHONUNBUFFERED=1", # Ensure Python output is not buffered
|
|
74
|
-
]
|
|
79
|
+
return cursor_deeplink
|
|
75
80
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
81
|
+
|
|
82
|
+
def auto_detect_module() -> tuple[str, Path | None] | tuple[None, None]:
|
|
83
|
+
"""Auto-detect MCP module in current directory.
|
|
84
|
+
|
|
85
|
+
Looks for 'mcp' defined in either __init__.py or server.py.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
Tuple of (module_name, parent_dir_to_add_to_path) or (None, None)
|
|
89
|
+
"""
|
|
90
|
+
cwd = Path.cwd()
|
|
91
|
+
|
|
92
|
+
# First check __init__.py
|
|
93
|
+
init_file = cwd / "__init__.py"
|
|
94
|
+
if init_file.exists():
|
|
80
95
|
try:
|
|
81
|
-
|
|
96
|
+
content = init_file.read_text(encoding="utf-8")
|
|
97
|
+
if "mcp" in content and ("= MCPServer" in content or "= FastMCP" in content):
|
|
98
|
+
return (cwd.name, None)
|
|
99
|
+
except Exception: # noqa: S110
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
# Then check main.py in current directory
|
|
103
|
+
main_file = cwd / "main.py"
|
|
104
|
+
if main_file.exists() and init_file.exists():
|
|
105
|
+
try:
|
|
106
|
+
content = main_file.read_text(encoding="utf-8")
|
|
107
|
+
if "mcp" in content and ("= MCPServer" in content or "= FastMCP" in content):
|
|
108
|
+
# Need to import as package.main, add parent to sys.path
|
|
109
|
+
return (f"{cwd.name}.main", cwd.parent)
|
|
110
|
+
except Exception: # noqa: S110
|
|
111
|
+
pass
|
|
82
112
|
|
|
83
|
-
|
|
84
|
-
loaded_env_vars = parse_env_file(env_contents)
|
|
85
|
-
for key, value in loaded_env_vars.items():
|
|
86
|
-
docker_cmd.extend(["-e", f"{key}={value}"])
|
|
87
|
-
if verbose and loaded_env_vars:
|
|
88
|
-
hud_console.info(
|
|
89
|
-
f"Loaded {len(loaded_env_vars)} environment variable(s) from .env file"
|
|
90
|
-
)
|
|
91
|
-
except Exception as e:
|
|
92
|
-
hud_console.warning(f"Failed to load .env file: {e}")
|
|
113
|
+
return (None, None)
|
|
93
114
|
|
|
94
|
-
# Add user-provided Docker arguments
|
|
95
|
-
if docker_args:
|
|
96
|
-
docker_cmd.extend(docker_args)
|
|
97
115
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
"mcpServers": {
|
|
115
|
-
"default": {
|
|
116
|
-
"command": docker_cmd[0],
|
|
117
|
-
"args": docker_cmd[1:] if len(docker_cmd) > 1 else [],
|
|
118
|
-
# transport defaults to stdio
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
116
|
+
def should_use_docker_mode(cwd: Path) -> bool:
|
|
117
|
+
"""Check if environment requires Docker mode (has Dockerfile in current dir)."""
|
|
118
|
+
return (cwd / "Dockerfile").exists()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def run_mcp_module(
|
|
122
|
+
module_name: str,
|
|
123
|
+
transport: str,
|
|
124
|
+
port: int,
|
|
125
|
+
verbose: bool,
|
|
126
|
+
inspector: bool,
|
|
127
|
+
interactive: bool,
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Run an MCP module directly."""
|
|
130
|
+
# Check if this is a reload (not first run)
|
|
131
|
+
is_reload = os.environ.get("_HUD_DEV_RELOAD") == "1"
|
|
122
132
|
|
|
123
|
-
#
|
|
133
|
+
# Configure logging
|
|
124
134
|
if verbose:
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
logging.basicConfig(
|
|
136
|
+
stream=sys.stderr, level=logging.DEBUG, format="[%(levelname)s] %(message)s"
|
|
137
|
+
)
|
|
138
|
+
else:
|
|
139
|
+
# Suppress tracebacks in logs unless verbose
|
|
140
|
+
logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(message)s")
|
|
141
|
+
|
|
142
|
+
# Suppress FastMCP's verbose error logging
|
|
143
|
+
logging.getLogger("fastmcp.tools.tool_manager").setLevel(logging.WARNING)
|
|
144
|
+
|
|
145
|
+
# On reload, suppress most startup logs
|
|
146
|
+
if is_reload:
|
|
147
|
+
logging.getLogger("hud.server.server").setLevel(logging.ERROR)
|
|
148
|
+
logging.getLogger("mcp.server").setLevel(logging.ERROR)
|
|
149
|
+
logging.getLogger("mcp.server.streamable_http_manager").setLevel(logging.ERROR)
|
|
150
|
+
|
|
151
|
+
# Suppress deprecation warnings on reload
|
|
152
|
+
import warnings
|
|
153
|
+
|
|
154
|
+
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
155
|
+
|
|
156
|
+
# Ensure proper directory is in sys.path based on module name
|
|
157
|
+
cwd = Path.cwd()
|
|
158
|
+
if "." in module_name:
|
|
159
|
+
# For package.module imports (like server.server), add parent to sys.path
|
|
160
|
+
parent = str(cwd.parent)
|
|
161
|
+
if parent not in sys.path:
|
|
162
|
+
sys.path.insert(0, parent)
|
|
163
|
+
else:
|
|
164
|
+
# For simple module imports, add current directory
|
|
165
|
+
cwd_str = str(cwd)
|
|
166
|
+
if cwd_str not in sys.path:
|
|
167
|
+
sys.path.insert(0, cwd_str)
|
|
168
|
+
|
|
169
|
+
# Import the module
|
|
170
|
+
try:
|
|
171
|
+
module = importlib.import_module(module_name)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
hud_console.error(f"Failed to import module '{module_name}'")
|
|
174
|
+
hud_console.info(f"Error: {e}")
|
|
175
|
+
hud_console.info("")
|
|
176
|
+
hud_console.info("[bold cyan]Troubleshooting:[/bold cyan]")
|
|
177
|
+
hud_console.info(" • Verify module exists and is importable")
|
|
178
|
+
hud_console.info(" • Check for __init__.py in module directory")
|
|
179
|
+
hud_console.info(" • Check for import errors in the module")
|
|
180
|
+
if verbose:
|
|
181
|
+
import traceback
|
|
182
|
+
|
|
183
|
+
hud_console.info("")
|
|
184
|
+
hud_console.info("[bold cyan]Full traceback:[/bold cyan]")
|
|
185
|
+
hud_console.info(traceback.format_exc())
|
|
186
|
+
sys.exit(1)
|
|
187
|
+
|
|
188
|
+
# Look for 'mcp' attribute - check module __dict__ directly
|
|
189
|
+
# Debug: print what's in the module
|
|
190
|
+
if verbose:
|
|
191
|
+
hud_console.info(f"Module attributes: {dir(module)}")
|
|
192
|
+
module_dict = module.__dict__ if hasattr(module, "__dict__") else {}
|
|
193
|
+
hud_console.info(f"Module __dict__ keys: {list(module_dict.keys())}")
|
|
132
194
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
195
|
+
mcp_server = None
|
|
196
|
+
|
|
197
|
+
# Try different ways to access the mcp variable
|
|
198
|
+
if hasattr(module, "mcp"):
|
|
199
|
+
mcp_server = module.mcp
|
|
200
|
+
elif hasattr(module, "__dict__") and "mcp" in module.__dict__:
|
|
201
|
+
mcp_server = module.__dict__["mcp"]
|
|
202
|
+
|
|
203
|
+
if mcp_server is None:
|
|
204
|
+
hud_console.error(f"Module '{module_name}' does not have 'mcp' defined")
|
|
205
|
+
hud_console.info("")
|
|
206
|
+
available = [k for k in dir(module) if not k.startswith("_")]
|
|
207
|
+
hud_console.info(f"Available in module: {available}")
|
|
208
|
+
hud_console.info("")
|
|
209
|
+
hud_console.info("[bold cyan]Expected structure:[/bold cyan]")
|
|
210
|
+
hud_console.info(" from hud.server import MCPServer")
|
|
211
|
+
hud_console.info(" mcp = MCPServer(name='my-server')")
|
|
212
|
+
raise AttributeError(f"Module '{module_name}' must define 'mcp'")
|
|
213
|
+
|
|
214
|
+
# Only show full header on first run, brief message on reload
|
|
215
|
+
if is_reload:
|
|
216
|
+
hud_console.info(f"{hud_console.sym.SUCCESS} Reloaded")
|
|
217
|
+
# Run server without showing full UI
|
|
218
|
+
else:
|
|
219
|
+
# Show full header on first run
|
|
220
|
+
hud_console.info("")
|
|
221
|
+
hud_console.header("HUD Development Server")
|
|
222
|
+
|
|
223
|
+
# Show server info only on first run
|
|
224
|
+
if not is_reload:
|
|
225
|
+
show_dev_server_info(
|
|
226
|
+
server_name=mcp_server.name or "mcp-server",
|
|
227
|
+
port=port,
|
|
228
|
+
transport=transport,
|
|
229
|
+
inspector=inspector,
|
|
230
|
+
interactive=interactive,
|
|
231
|
+
env_dir=Path.cwd().parent if (Path.cwd().parent / "environment").exists() else None,
|
|
136
232
|
)
|
|
137
|
-
|
|
138
|
-
|
|
233
|
+
|
|
234
|
+
# Check if there's an environment backend and remind user to start it (first run only)
|
|
235
|
+
if not is_reload:
|
|
236
|
+
cwd = Path.cwd()
|
|
237
|
+
env_dir = cwd.parent / "environment"
|
|
238
|
+
if env_dir.exists() and (env_dir / "server.py").exists():
|
|
139
239
|
hud_console.info("")
|
|
140
|
-
hud_console.info(
|
|
141
|
-
|
|
240
|
+
hud_console.info(
|
|
241
|
+
f"{hud_console.sym.FLOW} Don't forget to start the environment backend:"
|
|
242
|
+
)
|
|
243
|
+
hud_console.info(" cd ../environment && uvicorn server:app --reload")
|
|
244
|
+
|
|
245
|
+
# Launch inspector if requested (first run only)
|
|
246
|
+
if inspector and transport == "http":
|
|
247
|
+
await launch_inspector(port)
|
|
248
|
+
|
|
249
|
+
# Launch interactive mode if requested (first run only)
|
|
250
|
+
if interactive and transport == "http":
|
|
251
|
+
launch_interactive_thread(port, verbose)
|
|
252
|
+
|
|
253
|
+
hud_console.info("")
|
|
254
|
+
|
|
255
|
+
# Configure server options
|
|
256
|
+
run_kwargs = {
|
|
257
|
+
"transport": transport,
|
|
258
|
+
"show_banner": False,
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if transport == "http":
|
|
262
|
+
run_kwargs["port"] = port
|
|
263
|
+
run_kwargs["path"] = "/mcp"
|
|
264
|
+
run_kwargs["host"] = "0.0.0.0" # noqa: S104
|
|
265
|
+
run_kwargs["log_level"] = "INFO" if verbose else "ERROR"
|
|
266
|
+
|
|
267
|
+
# Run the server
|
|
268
|
+
await mcp_server.run_async(**run_kwargs)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def launch_inspector(port: int) -> None:
|
|
272
|
+
"""Launch MCP Inspector in background."""
|
|
273
|
+
await asyncio.sleep(2)
|
|
142
274
|
|
|
143
|
-
# Create the HTTP proxy server using config
|
|
144
275
|
try:
|
|
145
|
-
|
|
276
|
+
import platform
|
|
277
|
+
import urllib.parse
|
|
278
|
+
|
|
279
|
+
server_url = f"http://localhost:{port}/mcp"
|
|
280
|
+
encoded_url = urllib.parse.quote(server_url)
|
|
281
|
+
inspector_url = f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
|
|
282
|
+
|
|
283
|
+
hud_console.section_title("MCP Inspector")
|
|
284
|
+
hud_console.link(inspector_url)
|
|
285
|
+
|
|
286
|
+
env = os.environ.copy()
|
|
287
|
+
env["DANGEROUSLY_OMIT_AUTH"] = "true"
|
|
288
|
+
env["MCP_AUTO_OPEN_ENABLED"] = "true"
|
|
289
|
+
|
|
290
|
+
cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
|
|
291
|
+
|
|
292
|
+
if platform.system() == "Windows":
|
|
293
|
+
subprocess.Popen( # noqa: S602, ASYNC220
|
|
294
|
+
cmd,
|
|
295
|
+
env=env,
|
|
296
|
+
shell=True,
|
|
297
|
+
stdout=subprocess.DEVNULL,
|
|
298
|
+
stderr=subprocess.DEVNULL,
|
|
299
|
+
)
|
|
300
|
+
else:
|
|
301
|
+
subprocess.Popen( # noqa: S603, ASYNC220
|
|
302
|
+
cmd,
|
|
303
|
+
env=env,
|
|
304
|
+
stdout=subprocess.DEVNULL,
|
|
305
|
+
stderr=subprocess.DEVNULL,
|
|
306
|
+
)
|
|
307
|
+
|
|
146
308
|
except Exception as e:
|
|
147
|
-
hud_console.error(f"Failed to
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
309
|
+
hud_console.error(f"Failed to launch inspector: {e}")
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def launch_interactive_thread(port: int, verbose: bool) -> None:
|
|
313
|
+
"""Launch interactive testing mode in separate thread."""
|
|
314
|
+
import time
|
|
315
|
+
|
|
316
|
+
def run_interactive() -> None:
|
|
317
|
+
time.sleep(2)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
hud_console.section_title("Interactive Mode")
|
|
321
|
+
hud_console.info("Starting interactive testing mode...")
|
|
322
|
+
|
|
323
|
+
from .utils.interactive import run_interactive_mode
|
|
152
324
|
|
|
153
|
-
|
|
325
|
+
server_url = f"http://localhost:{port}/mcp"
|
|
154
326
|
|
|
327
|
+
loop = asyncio.new_event_loop()
|
|
328
|
+
asyncio.set_event_loop(loop)
|
|
329
|
+
try:
|
|
330
|
+
loop.run_until_complete(run_interactive_mode(server_url, verbose))
|
|
331
|
+
finally:
|
|
332
|
+
loop.close()
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
if verbose:
|
|
336
|
+
hud_console.error(f"Interactive mode error: {e}")
|
|
337
|
+
|
|
338
|
+
interactive_thread = threading.Thread(target=run_interactive, daemon=True)
|
|
339
|
+
interactive_thread.start()
|
|
155
340
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
341
|
+
|
|
342
|
+
def run_with_reload(
|
|
343
|
+
module_name: str,
|
|
344
|
+
watch_paths: list[str],
|
|
159
345
|
transport: str,
|
|
160
346
|
port: int,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
inspector: bool = False,
|
|
165
|
-
no_logs: bool = False,
|
|
166
|
-
interactive: bool = False,
|
|
167
|
-
docker_args: list[str] | None = None,
|
|
347
|
+
verbose: bool,
|
|
348
|
+
inspector: bool,
|
|
349
|
+
interactive: bool,
|
|
168
350
|
) -> None:
|
|
169
|
-
"""
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
351
|
+
"""Run module with file watching and auto-reload."""
|
|
352
|
+
try:
|
|
353
|
+
import watchfiles
|
|
354
|
+
except ImportError:
|
|
355
|
+
hud_console.error("watchfiles required. Install: pip install watchfiles")
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
# Resolve watch paths
|
|
359
|
+
resolved_paths = []
|
|
360
|
+
for path_str in watch_paths:
|
|
361
|
+
path = Path(path_str).resolve()
|
|
362
|
+
if path.is_file():
|
|
363
|
+
resolved_paths.append(str(path.parent))
|
|
364
|
+
else:
|
|
365
|
+
resolved_paths.append(str(path))
|
|
366
|
+
|
|
367
|
+
if verbose:
|
|
368
|
+
hud_console.info(f"Watching: {', '.join(resolved_paths)}")
|
|
369
|
+
|
|
174
370
|
import signal
|
|
175
|
-
import sys
|
|
176
371
|
|
|
177
|
-
|
|
372
|
+
process = None
|
|
373
|
+
stop_event = threading.Event()
|
|
374
|
+
is_first_run = True
|
|
178
375
|
|
|
179
|
-
|
|
180
|
-
|
|
376
|
+
def handle_signal(signum: int, frame: Any) -> None:
|
|
377
|
+
if process:
|
|
378
|
+
process.terminate()
|
|
379
|
+
sys.exit(0)
|
|
181
380
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
# Create a filter to block the specific "Starting MCP server" message
|
|
185
|
-
class _BlockStartingMCPFilter(logging.Filter):
|
|
186
|
-
def filter(self, record: logging.LogRecord) -> bool:
|
|
187
|
-
return "Starting MCP server" not in record.getMessage()
|
|
188
|
-
|
|
189
|
-
# Set environment variable for FastMCP logging
|
|
190
|
-
os.environ["FASTMCP_LOG_LEVEL"] = "ERROR"
|
|
191
|
-
os.environ["LOG_LEVEL"] = "ERROR"
|
|
192
|
-
os.environ["UVICORN_LOG_LEVEL"] = "ERROR"
|
|
193
|
-
# Suppress uvicorn's annoying shutdown messages
|
|
194
|
-
os.environ["UVICORN_ACCESS_LOG"] = "0"
|
|
195
|
-
|
|
196
|
-
# Configure logging to suppress INFO
|
|
197
|
-
logging.basicConfig(level=logging.ERROR, force=True)
|
|
198
|
-
|
|
199
|
-
# Set root logger to ERROR to suppress all INFO messages
|
|
200
|
-
root_logger = logging.getLogger()
|
|
201
|
-
root_logger.setLevel(logging.ERROR)
|
|
202
|
-
|
|
203
|
-
# Add filter to all handlers
|
|
204
|
-
block_filter = _BlockStartingMCPFilter()
|
|
205
|
-
for handler in root_logger.handlers:
|
|
206
|
-
handler.addFilter(block_filter)
|
|
207
|
-
|
|
208
|
-
# Also specifically suppress these loggers
|
|
209
|
-
for logger_name in [
|
|
210
|
-
"fastmcp",
|
|
211
|
-
"fastmcp.server",
|
|
212
|
-
"fastmcp.server.server",
|
|
213
|
-
"FastMCP",
|
|
214
|
-
"FastMCP.fastmcp.server.server",
|
|
215
|
-
"mcp",
|
|
216
|
-
"mcp.server",
|
|
217
|
-
"mcp.server.lowlevel",
|
|
218
|
-
"mcp.server.lowlevel.server",
|
|
219
|
-
"uvicorn",
|
|
220
|
-
"uvicorn.access",
|
|
221
|
-
"uvicorn.error",
|
|
222
|
-
"hud.server",
|
|
223
|
-
"hud.server.server",
|
|
224
|
-
]:
|
|
225
|
-
logger = logging.getLogger(logger_name)
|
|
226
|
-
logger.setLevel(logging.ERROR)
|
|
227
|
-
# Add filter to this logger too
|
|
228
|
-
logger.addFilter(block_filter)
|
|
229
|
-
|
|
230
|
-
# Suppress deprecation warnings
|
|
231
|
-
import warnings
|
|
232
|
-
|
|
233
|
-
warnings.filterwarnings("ignore", category=DeprecationWarning)
|
|
234
|
-
|
|
235
|
-
# CRITICAL: For stdio transport, ALL output must go to stderr
|
|
236
|
-
if transport == "stdio":
|
|
237
|
-
# Configure root logger to use stderr
|
|
238
|
-
root_logger = logging.getLogger()
|
|
239
|
-
root_logger.handlers.clear()
|
|
240
|
-
stderr_handler = logging.StreamHandler(sys.stderr)
|
|
241
|
-
root_logger.addHandler(stderr_handler)
|
|
242
|
-
|
|
243
|
-
# Validate project directory exists
|
|
244
|
-
project_path = Path(directory)
|
|
245
|
-
if not project_path.exists():
|
|
246
|
-
hud_console.error(f"Project directory not found: {project_path}")
|
|
247
|
-
raise click.Abort
|
|
248
|
-
|
|
249
|
-
# Extract container name from the proxy configuration (must match create_proxy_server naming)
|
|
250
|
-
import os
|
|
251
|
-
|
|
252
|
-
pid = str(os.getpid())[-6:] # Last 6 digits of process ID for uniqueness
|
|
253
|
-
base_name = image_name.replace(":", "-").replace("/", "-")
|
|
254
|
-
container_name = f"{base_name}-{pid}"
|
|
255
|
-
|
|
256
|
-
# Remove any existing container with the same name (silently)
|
|
257
|
-
# Note: The proxy creates containers on-demand when clients connect
|
|
258
|
-
try: # noqa: SIM105
|
|
259
|
-
subprocess.run( # noqa: S603, ASYNC221
|
|
260
|
-
["docker", "rm", "-f", container_name], # noqa: S607
|
|
261
|
-
stdout=subprocess.DEVNULL,
|
|
262
|
-
stderr=subprocess.DEVNULL,
|
|
263
|
-
check=False, # Don't raise error if container doesn't exist
|
|
264
|
-
)
|
|
265
|
-
except Exception: # noqa: S110
|
|
266
|
-
pass
|
|
381
|
+
signal.signal(signal.SIGTERM, handle_signal)
|
|
382
|
+
signal.signal(signal.SIGINT, handle_signal)
|
|
267
383
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
hud_console.info("Starting stdio proxy (each connection gets its own container)")
|
|
271
|
-
else:
|
|
272
|
-
# Find available port for HTTP
|
|
273
|
-
actual_port = find_free_port(port)
|
|
274
|
-
if actual_port is None:
|
|
275
|
-
hud_console.error(f"No available ports found starting from {port}")
|
|
276
|
-
raise click.Abort
|
|
384
|
+
while True:
|
|
385
|
+
cmd = [sys.executable, "-m", "hud", "dev", module_name, f"--port={port}"]
|
|
277
386
|
|
|
278
|
-
if
|
|
279
|
-
|
|
387
|
+
if transport == "stdio":
|
|
388
|
+
cmd.append("--stdio")
|
|
280
389
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
390
|
+
if verbose:
|
|
391
|
+
cmd.append("--verbose")
|
|
392
|
+
hud_console.info(f"Starting: {' '.join(cmd)}")
|
|
284
393
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await asyncio.sleep(3)
|
|
394
|
+
# Mark as reload after first run to suppress logs
|
|
395
|
+
env = {**os.environ, "_HUD_DEV_CHILD": "1"}
|
|
396
|
+
if not is_first_run:
|
|
397
|
+
env["_HUD_DEV_RELOAD"] = "1"
|
|
290
398
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
# Build the direct URL with query params to auto-connect
|
|
296
|
-
encoded_url = urllib.parse.quote(server_url)
|
|
297
|
-
inspector_url = (
|
|
298
|
-
f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
|
|
299
|
-
)
|
|
300
|
-
|
|
301
|
-
# Print inspector info cleanly
|
|
302
|
-
hud_console.section_title("MCP Inspector")
|
|
303
|
-
hud_console.link(inspector_url)
|
|
304
|
-
|
|
305
|
-
# Set environment to disable auth (for development only)
|
|
306
|
-
env = os.environ.copy()
|
|
307
|
-
env["DANGEROUSLY_OMIT_AUTH"] = "true"
|
|
308
|
-
env["MCP_AUTO_OPEN_ENABLED"] = "true"
|
|
309
|
-
|
|
310
|
-
# Launch inspector
|
|
311
|
-
cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
|
|
312
|
-
|
|
313
|
-
# Run in background, suppressing output to avoid log interference
|
|
314
|
-
if platform.system() == "Windows":
|
|
315
|
-
subprocess.Popen( # noqa: S602, ASYNC220
|
|
316
|
-
cmd,
|
|
317
|
-
env=env,
|
|
318
|
-
shell=True,
|
|
319
|
-
stdout=subprocess.DEVNULL,
|
|
320
|
-
stderr=subprocess.DEVNULL,
|
|
321
|
-
)
|
|
322
|
-
else:
|
|
323
|
-
subprocess.Popen( # noqa: S603, ASYNC220
|
|
324
|
-
cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
|
325
|
-
)
|
|
326
|
-
|
|
327
|
-
except (FileNotFoundError, Exception):
|
|
328
|
-
# Silently fail - inspector is optional
|
|
329
|
-
hud_console.error("Failed to launch inspector")
|
|
330
|
-
|
|
331
|
-
# Launch inspector asynchronously so it doesn't block
|
|
332
|
-
asyncio.create_task(launch_inspector()) # noqa: RUF006
|
|
399
|
+
process = subprocess.Popen( # noqa: S603
|
|
400
|
+
cmd, env=env
|
|
401
|
+
)
|
|
333
402
|
|
|
334
|
-
|
|
335
|
-
if interactive:
|
|
336
|
-
if transport != "http":
|
|
337
|
-
hud_console.warning("Interactive mode only works with HTTP transport")
|
|
338
|
-
else:
|
|
339
|
-
server_url = f"http://localhost:{actual_port}/mcp"
|
|
340
|
-
|
|
341
|
-
# Function to launch interactive mode in a separate thread
|
|
342
|
-
def launch_interactive_thread() -> None:
|
|
343
|
-
"""Launch interactive testing mode in a separate thread."""
|
|
344
|
-
import time
|
|
403
|
+
is_first_run = False
|
|
345
404
|
|
|
346
|
-
|
|
347
|
-
|
|
405
|
+
try:
|
|
406
|
+
stop_event = threading.Event()
|
|
348
407
|
|
|
408
|
+
def _wait_and_set(
|
|
409
|
+
stop_event: threading.Event, process: subprocess.Popen[bytes]
|
|
410
|
+
) -> None:
|
|
411
|
+
try:
|
|
412
|
+
if process is not None:
|
|
413
|
+
process.wait()
|
|
414
|
+
finally:
|
|
415
|
+
stop_event.set()
|
|
416
|
+
|
|
417
|
+
threading.Thread(target=_wait_and_set, args=(stop_event, process), daemon=True).start()
|
|
418
|
+
|
|
419
|
+
for changes in watchfiles.watch(*resolved_paths, stop_event=stop_event):
|
|
420
|
+
relevant_changes = [
|
|
421
|
+
(change_type, path)
|
|
422
|
+
for change_type, path in changes
|
|
423
|
+
if any(path.endswith(ext) for ext in [".py", ".json", ".toml", ".yaml"])
|
|
424
|
+
and "__pycache__" not in path
|
|
425
|
+
and not Path(path).name.startswith(".")
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
if relevant_changes:
|
|
429
|
+
hud_console.flow("File changes detected, reloading...")
|
|
430
|
+
if verbose:
|
|
431
|
+
for change_type, path in relevant_changes:
|
|
432
|
+
hud_console.info(f" {change_type}: {path}")
|
|
433
|
+
|
|
434
|
+
if process is not None:
|
|
435
|
+
process.terminate()
|
|
349
436
|
try:
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
# Create a new event loop for the thread
|
|
358
|
-
loop = asyncio.new_event_loop()
|
|
359
|
-
asyncio.set_event_loop(loop)
|
|
360
|
-
try:
|
|
361
|
-
loop.run_until_complete(run_interactive_mode(server_url, verbose))
|
|
362
|
-
finally:
|
|
363
|
-
loop.close()
|
|
364
|
-
|
|
365
|
-
except Exception as e:
|
|
366
|
-
# Log error but don't crash the server
|
|
367
|
-
if verbose:
|
|
368
|
-
hud_console.error(f"Interactive mode error: {e}")
|
|
369
|
-
|
|
370
|
-
# Launch interactive mode in a separate thread
|
|
371
|
-
import threading
|
|
372
|
-
|
|
373
|
-
interactive_thread = threading.Thread(target=launch_interactive_thread, daemon=True)
|
|
374
|
-
interactive_thread.start()
|
|
375
|
-
|
|
376
|
-
# Function to stream Docker logs
|
|
377
|
-
async def stream_docker_logs() -> None:
|
|
378
|
-
"""Stream Docker container logs asynchronously.
|
|
379
|
-
|
|
380
|
-
Note: The Docker container is created on-demand when the first client connects.
|
|
381
|
-
Any environment variables passed via -e flags are included when the container starts.
|
|
382
|
-
"""
|
|
383
|
-
log_hud_console = hud_console
|
|
384
|
-
|
|
385
|
-
# Always show waiting message
|
|
386
|
-
log_hud_console.info("") # Empty line for spacing
|
|
387
|
-
log_hud_console.progress_message(
|
|
388
|
-
"⏳ Waiting for first client connection to start container..."
|
|
389
|
-
)
|
|
390
|
-
log_hud_console.info(f"📋 Looking for container: {container_name}") # noqa: G004
|
|
391
|
-
|
|
392
|
-
# Keep trying to stream logs - container is created on demand
|
|
393
|
-
has_shown_started = False
|
|
394
|
-
while True:
|
|
395
|
-
# Check if container exists first (silently)
|
|
396
|
-
check_result = await asyncio.create_subprocess_exec(
|
|
397
|
-
"docker",
|
|
398
|
-
"ps",
|
|
399
|
-
"--format",
|
|
400
|
-
"{{.Names}}",
|
|
401
|
-
"--filter",
|
|
402
|
-
f"name={container_name}",
|
|
403
|
-
stdout=asyncio.subprocess.PIPE,
|
|
404
|
-
stderr=asyncio.subprocess.DEVNULL,
|
|
405
|
-
)
|
|
406
|
-
stdout, _ = await check_result.communicate()
|
|
437
|
+
if process is not None:
|
|
438
|
+
process.wait(timeout=5)
|
|
439
|
+
except subprocess.TimeoutExpired:
|
|
440
|
+
if process is not None:
|
|
441
|
+
process.kill()
|
|
442
|
+
process.wait()
|
|
407
443
|
|
|
408
|
-
|
|
409
|
-
if container_name not in stdout.decode():
|
|
410
|
-
await asyncio.sleep(1)
|
|
411
|
-
continue
|
|
444
|
+
import time
|
|
412
445
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
log_hud_console.success("Container started! Streaming logs...")
|
|
416
|
-
has_shown_started = True
|
|
446
|
+
time.sleep(0.1)
|
|
447
|
+
break
|
|
417
448
|
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
process
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
"-f",
|
|
424
|
-
container_name,
|
|
425
|
-
stdout=asyncio.subprocess.PIPE,
|
|
426
|
-
stderr=asyncio.subprocess.STDOUT, # Combine streams for simplicity
|
|
427
|
-
)
|
|
428
|
-
|
|
429
|
-
if process.stdout:
|
|
430
|
-
async for line in process.stdout:
|
|
431
|
-
decoded_line = line.decode().rstrip()
|
|
432
|
-
if not decoded_line: # Skip empty lines
|
|
433
|
-
continue
|
|
434
|
-
|
|
435
|
-
# Skip docker daemon errors (these happen when container is removed)
|
|
436
|
-
if "Error response from daemon" in decoded_line:
|
|
437
|
-
continue
|
|
438
|
-
|
|
439
|
-
# Show all logs with gold formatting like hud debug
|
|
440
|
-
# Format all logs in gold/dim style like hud debug's stderr
|
|
441
|
-
# Use stdout console to avoid stderr redirection when not verbose
|
|
442
|
-
log_hud_console._stdout_console.print(
|
|
443
|
-
f"[rgb(192,150,12)]■[/rgb(192,150,12)] {decoded_line}", highlight=False
|
|
444
|
-
)
|
|
445
|
-
|
|
446
|
-
# Process ended - container might have been removed
|
|
447
|
-
await process.wait()
|
|
448
|
-
|
|
449
|
-
# Check if container still exists
|
|
450
|
-
await asyncio.sleep(1)
|
|
451
|
-
continue # Loop back to check if container exists
|
|
452
|
-
|
|
453
|
-
except Exception as e:
|
|
454
|
-
# Some unexpected error - show it so we can debug
|
|
455
|
-
log_hud_console.warning(f"Failed to stream Docker logs: {e}") # noqa: G004
|
|
456
|
-
if verbose:
|
|
457
|
-
import traceback
|
|
458
|
-
|
|
459
|
-
log_hud_console.warning(f"Traceback: {traceback.format_exc()}") # noqa: G004
|
|
460
|
-
await asyncio.sleep(1)
|
|
461
|
-
|
|
462
|
-
# Import contextlib here so it's available in the finally block
|
|
463
|
-
import contextlib
|
|
464
|
-
|
|
465
|
-
# CRITICAL: Create proxy AFTER all logging setup to prevent it from resetting logging config
|
|
466
|
-
# This is important because FastMCP might initialize loggers during creation
|
|
467
|
-
proxy = create_proxy_server(
|
|
468
|
-
directory, image_name, no_reload, full_reload, verbose, docker_args or [], interactive
|
|
469
|
-
)
|
|
449
|
+
except KeyboardInterrupt:
|
|
450
|
+
if process:
|
|
451
|
+
process.terminate()
|
|
452
|
+
process.wait()
|
|
453
|
+
break
|
|
470
454
|
|
|
471
|
-
# Set up signal handlers for graceful shutdown
|
|
472
|
-
shutdown_event = asyncio.Event()
|
|
473
455
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
456
|
+
def run_docker_dev_server(
|
|
457
|
+
port: int, verbose: bool, inspector: bool, interactive: bool, docker_args: list[str]
|
|
458
|
+
) -> None:
|
|
459
|
+
"""Run MCP server in Docker with volume mounts, expose via local HTTP proxy."""
|
|
460
|
+
import typer
|
|
461
|
+
import yaml
|
|
478
462
|
|
|
479
|
-
|
|
480
|
-
signal.signal(signal.SIGINT, signal_handler)
|
|
463
|
+
from hud.server import MCPServer
|
|
481
464
|
|
|
482
|
-
|
|
483
|
-
if hasattr(signal, "SIGTERM"):
|
|
484
|
-
signal.signal(signal.SIGTERM, signal_handler)
|
|
465
|
+
cwd = Path.cwd()
|
|
485
466
|
|
|
486
|
-
#
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
class BlockStartingMCPFilter(logging.Filter):
|
|
490
|
-
def filter(self, record: logging.LogRecord) -> bool:
|
|
491
|
-
return "Starting MCP server" not in record.getMessage()
|
|
492
|
-
|
|
493
|
-
block_filter = BlockStartingMCPFilter()
|
|
494
|
-
|
|
495
|
-
# Apply to all loggers again - comprehensive list
|
|
496
|
-
for logger_name in [
|
|
497
|
-
"", # root logger
|
|
498
|
-
"fastmcp",
|
|
499
|
-
"fastmcp.server",
|
|
500
|
-
"fastmcp.server.server",
|
|
501
|
-
"FastMCP",
|
|
502
|
-
"FastMCP.fastmcp.server.server",
|
|
503
|
-
"mcp",
|
|
504
|
-
"mcp.server",
|
|
505
|
-
"mcp.server.lowlevel",
|
|
506
|
-
"mcp.server.lowlevel.server",
|
|
507
|
-
"uvicorn",
|
|
508
|
-
"uvicorn.access",
|
|
509
|
-
"uvicorn.error",
|
|
510
|
-
"hud.server",
|
|
511
|
-
"hud.server.server",
|
|
512
|
-
]:
|
|
513
|
-
logger = logging.getLogger(logger_name)
|
|
514
|
-
logger.setLevel(logging.ERROR)
|
|
515
|
-
logger.addFilter(block_filter)
|
|
516
|
-
for handler in logger.handlers:
|
|
517
|
-
handler.addFilter(block_filter)
|
|
518
|
-
|
|
519
|
-
# Track if container has been stopped to avoid duplicate stops
|
|
520
|
-
container_stopped = False
|
|
521
|
-
|
|
522
|
-
# Function to stop the container gracefully
|
|
523
|
-
async def stop_container() -> None:
|
|
524
|
-
"""Stop the Docker container gracefully with SIGTERM, wait 30s, then SIGKILL if needed."""
|
|
525
|
-
nonlocal container_stopped
|
|
526
|
-
if container_stopped:
|
|
527
|
-
return # Already stopped, don't do it again
|
|
467
|
+
# Find environment directory (current or parent with hud.lock.yaml)
|
|
468
|
+
env_dir = cwd
|
|
469
|
+
lock_path = env_dir / "hud.lock.yaml"
|
|
528
470
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
stdout=asyncio.subprocess.PIPE,
|
|
539
|
-
stderr=asyncio.subprocess.DEVNULL,
|
|
540
|
-
)
|
|
541
|
-
stdout, _ = await check_result.communicate()
|
|
542
|
-
|
|
543
|
-
if container_name in stdout.decode():
|
|
544
|
-
hud_console.info("🛑 Stopping container gracefully...")
|
|
545
|
-
# Stop with 30 second timeout before SIGKILL
|
|
546
|
-
stop_result = await asyncio.create_subprocess_exec(
|
|
547
|
-
"docker",
|
|
548
|
-
"stop",
|
|
549
|
-
"--time=30",
|
|
550
|
-
container_name,
|
|
551
|
-
stdout=asyncio.subprocess.DEVNULL,
|
|
552
|
-
stderr=asyncio.subprocess.DEVNULL,
|
|
553
|
-
)
|
|
554
|
-
await stop_result.communicate()
|
|
555
|
-
hud_console.success("Container stopped successfully")
|
|
556
|
-
container_stopped = True
|
|
557
|
-
except Exception as e:
|
|
558
|
-
hud_console.warning(f"Failed to stop container: {e}")
|
|
471
|
+
if not lock_path.exists():
|
|
472
|
+
# Try parent directory
|
|
473
|
+
if (cwd.parent / "hud.lock.yaml").exists():
|
|
474
|
+
env_dir = cwd.parent
|
|
475
|
+
lock_path = env_dir / "hud.lock.yaml"
|
|
476
|
+
else:
|
|
477
|
+
hud_console.error("No hud.lock.yaml found")
|
|
478
|
+
hud_console.info("Run 'hud build' first to create an image")
|
|
479
|
+
raise typer.Exit(1)
|
|
559
480
|
|
|
481
|
+
# Load lock file to get image name
|
|
560
482
|
try:
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
if not no_logs:
|
|
564
|
-
log_task = asyncio.create_task(stream_docker_logs())
|
|
483
|
+
with open(lock_path) as f:
|
|
484
|
+
lock_data = yaml.safe_load(f)
|
|
565
485
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
transport="stdio", log_level="ERROR" if not verbose else "INFO", show_banner=False
|
|
570
|
-
)
|
|
571
|
-
else:
|
|
572
|
-
# Run with HTTP transport
|
|
573
|
-
# Temporarily redirect stderr to suppress uvicorn shutdown messages
|
|
574
|
-
import contextlib
|
|
575
|
-
import io
|
|
576
|
-
|
|
577
|
-
if not verbose:
|
|
578
|
-
# Create a dummy file to swallow unwanted stderr output
|
|
579
|
-
with contextlib.redirect_stderr(io.StringIO()):
|
|
580
|
-
await proxy.run_async(
|
|
581
|
-
transport="http",
|
|
582
|
-
host="0.0.0.0", # noqa: S104
|
|
583
|
-
port=actual_port,
|
|
584
|
-
path="/mcp", # Serve at /mcp endpoint
|
|
585
|
-
log_level="ERROR",
|
|
586
|
-
show_banner=False,
|
|
587
|
-
)
|
|
588
|
-
else:
|
|
589
|
-
await proxy.run_async(
|
|
590
|
-
transport="http",
|
|
591
|
-
host="0.0.0.0", # noqa: S104
|
|
592
|
-
port=actual_port,
|
|
593
|
-
path="/mcp", # Serve at /mcp endpoint
|
|
594
|
-
log_level="INFO",
|
|
595
|
-
show_banner=False,
|
|
596
|
-
)
|
|
597
|
-
except (ConnectionError, OSError) as e:
|
|
598
|
-
hud_console.error(f"Failed to connect to Docker container: {e}")
|
|
599
|
-
hud_console.info("")
|
|
600
|
-
hud_console.info("💡 Tip: Run the following command to debug the container:")
|
|
601
|
-
hud_console.info(f" hud debug {image_name}")
|
|
602
|
-
hud_console.info("")
|
|
603
|
-
hud_console.info("Common issues:")
|
|
604
|
-
hud_console.info(" • Container failed to start or crashed immediately")
|
|
605
|
-
hud_console.info(" • Server initialization failed")
|
|
606
|
-
hud_console.info(" • Port binding conflicts")
|
|
607
|
-
raise
|
|
608
|
-
except KeyboardInterrupt:
|
|
609
|
-
hud_console.info("\n👋 Shutting down...")
|
|
486
|
+
# Get image from new or legacy format
|
|
487
|
+
images = lock_data.get("images", {})
|
|
488
|
+
image_name = images.get("local") or lock_data.get("image")
|
|
610
489
|
|
|
611
|
-
|
|
612
|
-
|
|
490
|
+
if not image_name:
|
|
491
|
+
hud_console.error("No image reference found in hud.lock.yaml")
|
|
492
|
+
raise typer.Exit(1)
|
|
493
|
+
|
|
494
|
+
# Strip digest if present
|
|
495
|
+
if "@" in image_name:
|
|
496
|
+
image_name = image_name.split("@")[0]
|
|
613
497
|
|
|
614
|
-
# Show next steps tutorial
|
|
615
|
-
if not interactive: # Only show if not in interactive mode
|
|
616
|
-
hud_console.section_title("Next Steps")
|
|
617
|
-
hud_console.info("🏗️ Ready to test with real agents? Run:")
|
|
618
|
-
hud_console.info(f" [cyan]hud build {directory}[/cyan]")
|
|
619
|
-
hud_console.info("")
|
|
620
|
-
hud_console.info("This will:")
|
|
621
|
-
hud_console.info(" 1. Build your environment image")
|
|
622
|
-
hud_console.info(" 2. Generate a hud.lock.yaml file")
|
|
623
|
-
hud_console.info(" 3. Prepare it for testing with agents")
|
|
624
|
-
hud_console.info("")
|
|
625
|
-
hud_console.info("Then you can:")
|
|
626
|
-
hud_console.info(" • Test locally: [cyan]hud run <image>[/cyan]")
|
|
627
|
-
hud_console.info(" • Push to registry: [cyan]hud push --image <registry/name>[/cyan]")
|
|
628
498
|
except Exception as e:
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
if not any(
|
|
632
|
-
x in error_msg
|
|
633
|
-
for x in [
|
|
634
|
-
"timeout graceful shutdown exceeded",
|
|
635
|
-
"Cancel 0 running task(s)",
|
|
636
|
-
"Application shutdown complete",
|
|
637
|
-
]
|
|
638
|
-
):
|
|
639
|
-
hud_console.error(f"Unexpected error: {e}")
|
|
640
|
-
finally:
|
|
641
|
-
# Cancel log streaming task if it exists
|
|
642
|
-
if log_task and not log_task.done():
|
|
643
|
-
log_task.cancel()
|
|
644
|
-
try:
|
|
645
|
-
await log_task
|
|
646
|
-
except asyncio.CancelledError:
|
|
647
|
-
contextlib.suppress(asyncio.CancelledError)
|
|
499
|
+
hud_console.error(f"Failed to read lock file: {e}")
|
|
500
|
+
raise typer.Exit(1) from e
|
|
648
501
|
|
|
649
|
-
|
|
650
|
-
|
|
502
|
+
# Generate unique container name
|
|
503
|
+
pid = str(os.getpid())[-6:]
|
|
504
|
+
base_name = image_name.replace(":", "-").replace("/", "-")
|
|
505
|
+
container_name = f"{base_name}-dev-{pid}"
|
|
651
506
|
|
|
507
|
+
# Build docker run command with volume mounts
|
|
508
|
+
docker_cmd = [
|
|
509
|
+
"docker",
|
|
510
|
+
"run",
|
|
511
|
+
"--rm",
|
|
512
|
+
"-i",
|
|
513
|
+
"--name",
|
|
514
|
+
container_name,
|
|
515
|
+
# Mount both server and environment for hot-reload
|
|
516
|
+
"-v",
|
|
517
|
+
f"{env_dir.absolute()}/server:/app/server:rw",
|
|
518
|
+
"-v",
|
|
519
|
+
f"{env_dir.absolute()}/environment:/app/environment:rw",
|
|
520
|
+
"-e",
|
|
521
|
+
"PYTHONPATH=/app",
|
|
522
|
+
"-e",
|
|
523
|
+
"PYTHONUNBUFFERED=1",
|
|
524
|
+
"-e",
|
|
525
|
+
"HUD_DEV=1",
|
|
526
|
+
]
|
|
652
527
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
port: int = 8765,
|
|
660
|
-
no_reload: bool = False,
|
|
661
|
-
full_reload: bool = False,
|
|
662
|
-
verbose: bool = False,
|
|
663
|
-
inspector: bool = False,
|
|
664
|
-
no_logs: bool = False,
|
|
665
|
-
interactive: bool = False,
|
|
666
|
-
docker_args: list[str] | None = None,
|
|
667
|
-
) -> None:
|
|
668
|
-
"""Run MCP development server with hot-reload.
|
|
669
|
-
|
|
670
|
-
This command starts a development proxy that:
|
|
671
|
-
- Auto-detects or builds Docker images
|
|
672
|
-
- Mounts local source code for hot-reload
|
|
673
|
-
- Exposes an HTTP endpoint for MCP clients
|
|
674
|
-
|
|
675
|
-
Examples:
|
|
676
|
-
hud dev . # Auto-detect image from directory
|
|
677
|
-
hud dev . --build # Build image first
|
|
678
|
-
hud dev . --image custom:tag # Use specific image
|
|
679
|
-
hud dev . --no-cache # Force clean rebuild
|
|
680
|
-
"""
|
|
681
|
-
# Ensure directory exists
|
|
682
|
-
if not Path(directory).exists():
|
|
683
|
-
hud_console.error(f"Directory not found: {directory}")
|
|
684
|
-
raise click.Abort
|
|
528
|
+
# Load .env file if present
|
|
529
|
+
env_file = env_dir / ".env"
|
|
530
|
+
loaded_env_vars: dict[str, str] = {}
|
|
531
|
+
if env_file.exists():
|
|
532
|
+
try:
|
|
533
|
+
from hud.cli.utils.config import parse_env_file
|
|
685
534
|
|
|
686
|
-
|
|
535
|
+
env_contents = env_file.read_text(encoding="utf-8")
|
|
536
|
+
loaded_env_vars = parse_env_file(env_contents)
|
|
537
|
+
for key, value in loaded_env_vars.items():
|
|
538
|
+
docker_cmd.extend(["-e", f"{key}={value}"])
|
|
539
|
+
if verbose and loaded_env_vars:
|
|
540
|
+
hud_console.info(f"Loaded {len(loaded_env_vars)} env var(s) from .env")
|
|
541
|
+
except Exception as e:
|
|
542
|
+
hud_console.warning(f"Failed to load .env file: {e}")
|
|
687
543
|
|
|
688
|
-
#
|
|
689
|
-
|
|
544
|
+
# Add user-provided Docker arguments
|
|
545
|
+
if docker_args:
|
|
546
|
+
docker_cmd.extend(docker_args)
|
|
690
547
|
|
|
691
|
-
#
|
|
692
|
-
|
|
693
|
-
update_pyproject_toml(directory, resolved_image)
|
|
548
|
+
# Append the image name
|
|
549
|
+
docker_cmd.append(image_name)
|
|
694
550
|
|
|
695
|
-
#
|
|
696
|
-
|
|
697
|
-
build_and_update(directory, resolved_image, no_cache)
|
|
551
|
+
# Print startup info
|
|
552
|
+
hud_console.header("HUD Development Mode (Docker)")
|
|
698
553
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
554
|
+
if verbose:
|
|
555
|
+
hud_console.section_title("Docker Command")
|
|
556
|
+
hud_console.info(" ".join(docker_cmd))
|
|
557
|
+
|
|
558
|
+
# Create MCP config pointing to the Docker container's stdio
|
|
559
|
+
mcp_config = {
|
|
560
|
+
"docker": {
|
|
561
|
+
"command": docker_cmd[0],
|
|
562
|
+
"args": docker_cmd[1:],
|
|
563
|
+
}
|
|
564
|
+
}
|
|
705
565
|
|
|
706
|
-
#
|
|
707
|
-
|
|
566
|
+
# Show consistent server info
|
|
567
|
+
show_dev_server_info(
|
|
568
|
+
server_name=image_name,
|
|
569
|
+
port=port,
|
|
570
|
+
transport="http", # Docker mode always uses HTTP proxy
|
|
571
|
+
inspector=inspector,
|
|
572
|
+
interactive=interactive,
|
|
573
|
+
env_dir=env_dir,
|
|
574
|
+
)
|
|
708
575
|
|
|
709
|
-
#
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
if transport == "stdio":
|
|
723
|
-
server_config = {"command": "hud", "args": ["dev", directory, "--transport", "stdio"]}
|
|
724
|
-
# For stdio, include docker args in the command
|
|
725
|
-
if docker_args:
|
|
726
|
-
server_config["args"].extend(docker_args)
|
|
727
|
-
else:
|
|
728
|
-
server_config = {"url": f"http://localhost:{actual_port}/mcp"}
|
|
729
|
-
# Note: Environment variables are passed to the Docker container via the proxy,
|
|
730
|
-
# not included in the client configuration
|
|
576
|
+
# Suppress logs unless verbose
|
|
577
|
+
if not verbose:
|
|
578
|
+
logging.getLogger("fastmcp").setLevel(logging.ERROR)
|
|
579
|
+
logging.getLogger("mcp").setLevel(logging.ERROR)
|
|
580
|
+
logging.getLogger("uvicorn").setLevel(logging.ERROR)
|
|
581
|
+
os.environ["FASTMCP_DISABLE_BANNER"] = "1"
|
|
582
|
+
|
|
583
|
+
# Note about hot-reload behavior
|
|
584
|
+
hud_console.dim_info(
|
|
585
|
+
"",
|
|
586
|
+
"Container restarts on file changes (mounted volumes), if changing tools run hud dev again",
|
|
587
|
+
)
|
|
588
|
+
hud_console.info("")
|
|
731
589
|
|
|
732
|
-
#
|
|
733
|
-
|
|
734
|
-
|
|
590
|
+
# Create and run proxy with HUD helpers
|
|
591
|
+
async def run_proxy() -> None:
|
|
592
|
+
from fastmcp import FastMCP
|
|
735
593
|
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
|
|
739
|
-
)
|
|
594
|
+
# Create FastMCP proxy to Docker stdio
|
|
595
|
+
fastmcp_proxy = FastMCP.as_proxy(mcp_config, name="HUD Docker Dev Proxy")
|
|
740
596
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
hud_console.header("HUD Development Server")
|
|
744
|
-
|
|
745
|
-
# Always show the Docker image being used as the first thing after header
|
|
746
|
-
hud_console.section_title("Docker Image")
|
|
747
|
-
if source == "cache":
|
|
748
|
-
hud_console.info(f"📦 {resolved_image}")
|
|
749
|
-
elif source == "auto":
|
|
750
|
-
hud_console.info(f"🔧 {resolved_image} (auto-generated)")
|
|
751
|
-
elif source == "override":
|
|
752
|
-
hud_console.info(f"🎯 {resolved_image} (specified)")
|
|
753
|
-
else:
|
|
754
|
-
hud_console.info(f"🐳 {resolved_image}")
|
|
597
|
+
# Wrap in MCPServer to get /docs and REST wrappers
|
|
598
|
+
proxy = MCPServer(name="HUD Docker Dev Proxy")
|
|
755
599
|
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
)
|
|
600
|
+
# Import all tools from the FastMCP proxy
|
|
601
|
+
await proxy.import_server(fastmcp_proxy)
|
|
759
602
|
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
hud_console.info(
|
|
764
|
-
"The following environment variables will be passed to the Docker container:"
|
|
765
|
-
)
|
|
766
|
-
i = 0
|
|
767
|
-
while i < len(docker_args):
|
|
768
|
-
if docker_args[i] == "-e" and i + 1 < len(docker_args):
|
|
769
|
-
hud_console.info(f" • {docker_args[i + 1]}")
|
|
770
|
-
i += 2
|
|
771
|
-
elif docker_args[i].startswith("--env="):
|
|
772
|
-
hud_console.info(f" • {docker_args[i][6:]}")
|
|
773
|
-
i += 1
|
|
774
|
-
elif docker_args[i] == "--env" and i + 1 < len(docker_args):
|
|
775
|
-
hud_console.info(f" • {docker_args[i + 1]}")
|
|
776
|
-
i += 2
|
|
777
|
-
else:
|
|
778
|
-
i += 1
|
|
779
|
-
|
|
780
|
-
# Show hints about inspector and interactive mode
|
|
781
|
-
if transport == "http":
|
|
782
|
-
if not inspector and not interactive:
|
|
783
|
-
hud_console.progress_message("💡 Run with --inspector to launch MCP Inspector")
|
|
784
|
-
hud_console.progress_message("🧪 Run with --interactive for interactive testing mode")
|
|
785
|
-
elif not inspector:
|
|
786
|
-
hud_console.progress_message("💡 Run with --inspector to launch MCP Inspector")
|
|
787
|
-
elif not interactive:
|
|
788
|
-
hud_console.progress_message("🧪 Run with --interactive for interactive testing mode")
|
|
789
|
-
|
|
790
|
-
# Show configuration as JSON (just the server config, not wrapped)
|
|
791
|
-
full_config = {}
|
|
792
|
-
full_config[server_name] = server_config
|
|
793
|
-
|
|
794
|
-
hud_console.section_title("MCP Configuration (add this to any agent/client)")
|
|
795
|
-
hud_console.json_config(json.dumps(full_config, indent=2))
|
|
796
|
-
|
|
797
|
-
# Show connection info
|
|
798
|
-
hud_console.section_title(
|
|
799
|
-
"Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
|
|
800
|
-
)
|
|
801
|
-
hud_console.link(deeplink)
|
|
603
|
+
# Launch inspector if requested
|
|
604
|
+
if inspector:
|
|
605
|
+
await launch_inspector(port)
|
|
802
606
|
|
|
803
|
-
|
|
607
|
+
# Launch interactive mode if requested
|
|
608
|
+
if interactive:
|
|
609
|
+
launch_interactive_thread(port, verbose)
|
|
610
|
+
|
|
611
|
+
# Run proxy with HTTP transport
|
|
612
|
+
await proxy.run_async(
|
|
613
|
+
transport="http",
|
|
614
|
+
host="0.0.0.0", # noqa: S104
|
|
615
|
+
port=port,
|
|
616
|
+
path="/mcp",
|
|
617
|
+
log_level="error" if not verbose else "info",
|
|
618
|
+
show_banner=False,
|
|
619
|
+
)
|
|
804
620
|
|
|
805
621
|
try:
|
|
806
|
-
asyncio.run(
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
622
|
+
asyncio.run(run_proxy())
|
|
623
|
+
except KeyboardInterrupt:
|
|
624
|
+
hud_console.info("\n\nStopping...")
|
|
625
|
+
raise typer.Exit(0) from None
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def run_mcp_dev_server(
|
|
629
|
+
module: str | None,
|
|
630
|
+
stdio: bool,
|
|
631
|
+
port: int,
|
|
632
|
+
verbose: bool,
|
|
633
|
+
inspector: bool,
|
|
634
|
+
interactive: bool,
|
|
635
|
+
watch: list[str] | None,
|
|
636
|
+
docker: bool = False,
|
|
637
|
+
docker_args: list[str] | None = None,
|
|
638
|
+
) -> None:
|
|
639
|
+
"""Run MCP development server with hot-reload."""
|
|
640
|
+
docker_args = docker_args or []
|
|
641
|
+
cwd = Path.cwd()
|
|
642
|
+
|
|
643
|
+
# Auto-detect Docker mode if Dockerfile present and no module specified
|
|
644
|
+
if not docker and module is None and should_use_docker_mode(cwd):
|
|
645
|
+
hud_console.note("Detected Dockerfile - using Docker mode with volume mounts")
|
|
646
|
+
hud_console.dim_info("Tip", "Use 'hud dev --help' to see all options")
|
|
826
647
|
hud_console.info("")
|
|
827
|
-
|
|
828
|
-
|
|
648
|
+
run_docker_dev_server(port, verbose, inspector, interactive, docker_args)
|
|
649
|
+
return
|
|
650
|
+
|
|
651
|
+
# Route to Docker mode if explicitly requested
|
|
652
|
+
if docker:
|
|
653
|
+
run_docker_dev_server(port, verbose, inspector, interactive, docker_args)
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
transport = "stdio" if stdio else "http"
|
|
657
|
+
|
|
658
|
+
# Auto-detect module if not provided
|
|
659
|
+
if module is None:
|
|
660
|
+
module, extra_path = auto_detect_module()
|
|
661
|
+
if module is None:
|
|
662
|
+
hud_console.error("Could not auto-detect MCP module in current directory")
|
|
663
|
+
hud_console.info("")
|
|
664
|
+
hud_console.info("[bold cyan]Expected:[/bold cyan]")
|
|
665
|
+
hud_console.info(" • __init__.py file in current directory")
|
|
666
|
+
hud_console.info(" • Module must define 'mcp' variable")
|
|
667
|
+
hud_console.info("")
|
|
668
|
+
hud_console.info("[bold cyan]Examples:[/bold cyan]")
|
|
669
|
+
hud_console.info(" hud dev controller")
|
|
670
|
+
hud_console.info(" cd controller && hud dev")
|
|
671
|
+
hud_console.info(" hud dev --docker # For Docker-based environments")
|
|
672
|
+
hud_console.info("")
|
|
673
|
+
import sys
|
|
674
|
+
|
|
675
|
+
sys.exit(1)
|
|
676
|
+
|
|
677
|
+
if verbose:
|
|
678
|
+
hud_console.info(f"Auto-detected: {module}")
|
|
679
|
+
if extra_path:
|
|
680
|
+
hud_console.info(f"Adding to sys.path: {extra_path}")
|
|
681
|
+
|
|
682
|
+
# Add extra path to sys.path if needed (for package imports)
|
|
683
|
+
if extra_path:
|
|
684
|
+
import sys
|
|
685
|
+
|
|
686
|
+
sys.path.insert(0, str(extra_path))
|
|
687
|
+
else:
|
|
688
|
+
extra_path = None
|
|
689
|
+
|
|
690
|
+
# Determine watch paths
|
|
691
|
+
watch_paths = watch if watch else ["."]
|
|
692
|
+
|
|
693
|
+
# Check if child process
|
|
694
|
+
is_child = os.environ.get("_HUD_DEV_CHILD") == "1"
|
|
695
|
+
|
|
696
|
+
if is_child:
|
|
697
|
+
asyncio.run(run_mcp_module(module, transport, port, verbose, False, False))
|
|
698
|
+
else:
|
|
699
|
+
run_with_reload(module, watch_paths, transport, port, verbose, inspector, interactive)
|