hud-python 0.4.48__py3-none-any.whl → 0.4.50__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/cli/dev.py CHANGED
@@ -1,828 +1,699 @@
1
- """MCP Development Proxy - Hot-reload environments with MCP over HTTP."""
1
+ """MCP Development Server - Hot-reload Python modules."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import base64
7
- import json
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 build_and_update(directory: str | Path, image_name: str, no_cache: bool = False) -> None:
30
- """Build Docker image and update pyproject.toml."""
31
- if not build_environment(directory, image_name, no_cache):
32
- raise click.Abort
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
- def create_proxy_server(
36
- directory: str | Path,
37
- image_name: str,
38
- no_reload: bool = False,
39
- full_reload: bool = False,
40
- verbose: bool = False,
41
- docker_args: list[str] | None = None,
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
- # Get the original CMD from the image
48
- original_cmd = get_docker_cmd(image_name)
49
- if not original_cmd:
50
- hud_console.warning(f"Could not extract CMD from {image_name}, using default")
51
- original_cmd = ["python", "-m", "hud_controller.server"]
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
- # Generate unique container name from image to avoid conflicts between multiple instances
54
- import os
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
- pid = str(os.getpid())[-6:] # Last 6 digits of process ID for uniqueness
57
- base_name = image_name.replace(":", "-").replace("/", "-")
58
- container_name = f"{base_name}-{pid}"
75
+ hud_console.info("")
76
+ hud_console.info(f"{hud_console.sym.SUCCESS} Hot-reload enabled")
77
+ hud_console.info("")
59
78
 
60
- # Build the docker run command
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
- # Check for .env file in the project directory and add env vars
77
- env_file = project_path / ".env"
78
- loaded_env_vars = {}
79
- if env_file.exists():
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
- from hud.cli.utils.config import parse_env_file
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
- env_contents = env_file.read_text(encoding="utf-8")
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
- # Append the image name and CMD
99
- docker_cmd.append(image_name)
100
- if original_cmd:
101
- docker_cmd.extend(original_cmd)
102
-
103
- # Disable hot-reload if interactive mode is enabled
104
- if interactive:
105
- no_reload = True
106
-
107
- # Validate reload options
108
- if no_reload and full_reload:
109
- hud_console.warning("Cannot use --full-reload with --no-reload, ignoring --full-reload")
110
- full_reload = False
111
-
112
- # Create configuration following MCPConfig schema
113
- config = {
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
- # Debug output - only if verbose
133
+ # Configure logging
124
134
  if verbose:
125
- if full_reload:
126
- hud_console.info("Mode: Full reload (container restart on file changes)")
127
- hud_console.info("Note: Full container restart not yet implemented")
128
- else:
129
- hud_console.info("Mode: Container manages its own reload")
130
- hud_console.info("The container's CMD determines reload behavior")
131
- hud_console.command_example(f"docker logs -f {container_name}", "View container logs")
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
- # Show the full Docker command if there are environment variables (from .env or args)
134
- has_env_from_args = docker_args and any(
135
- arg == "-e" or arg.startswith("--env") for arg in docker_args
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
- has_env_from_file = bool(loaded_env_vars)
138
- if has_env_from_args or has_env_from_file:
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("Docker command with environment variables:")
141
- hud_console.info(" ".join(docker_cmd))
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
- proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
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 create proxy server: {e}")
148
- hud_console.info("")
149
- hud_console.info("💡 Tip: Run the following command to debug the container:")
150
- hud_console.info(f" hud debug {image_name}")
151
- raise
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
- return proxy
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
- async def start_mcp_proxy(
157
- directory: str | Path,
158
- image_name: str,
341
+
342
+ def run_with_reload(
343
+ module_name: str,
344
+ watch_paths: list[str],
159
345
  transport: str,
160
346
  port: int,
161
- no_reload: bool = False,
162
- full_reload: bool = False,
163
- verbose: bool = False,
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
- """Start the MCP development proxy server."""
170
- # Suppress FastMCP's verbose output FIRST
171
- import asyncio
172
- import logging
173
- import os
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
- from .utils.logging import find_free_port
372
+ process = None
373
+ stop_event = threading.Event()
374
+ is_first_run = True
178
375
 
179
- # Always disable the banner - we have our own output
180
- os.environ["FASTMCP_DISABLE_BANNER"] = "1"
376
+ def handle_signal(signum: int, frame: Any) -> None:
377
+ if process:
378
+ process.terminate()
379
+ sys.exit(0)
181
380
 
182
- # Configure logging BEFORE creating proxy
183
- if not verbose:
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
- if transport == "stdio":
269
- if verbose:
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 actual_port != port and verbose:
279
- hud_console.warning(f"Port {port} in use, using port {actual_port} instead")
387
+ if transport == "stdio":
388
+ cmd.append("--stdio")
280
389
 
281
- # Launch MCP Inspector if requested
282
- if inspector:
283
- server_url = f"http://localhost:{actual_port}/mcp"
390
+ if verbose:
391
+ cmd.append("--verbose")
392
+ hud_console.info(f"Starting: {' '.join(cmd)}")
284
393
 
285
- # Function to launch inspector in background
286
- async def launch_inspector() -> None:
287
- """Launch MCP Inspector and capture its output to extract the URL."""
288
- # Wait for server to be ready
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
- try:
292
- import platform
293
- import urllib.parse
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
- # Launch interactive mode if requested
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
- # Wait for server to be ready
347
- time.sleep(3)
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
- hud_console.section_title("Interactive Mode")
351
- hud_console.info("Starting interactive testing mode...")
352
- hud_console.info("Press Ctrl+C in the interactive session to exit")
353
-
354
- # Import and run interactive mode in a new event loop
355
- from .utils.interactive import run_interactive_mode
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
- # If container doesn't exist, wait and retry
409
- if container_name not in stdout.decode():
410
- await asyncio.sleep(1)
411
- continue
444
+ import time
412
445
 
413
- # Container exists! Show success if first time
414
- if not has_shown_started:
415
- log_hud_console.success("Container started! Streaming logs...")
416
- has_shown_started = True
446
+ time.sleep(0.1)
447
+ break
417
448
 
418
- # Now stream the logs
419
- try:
420
- process = await asyncio.create_subprocess_exec(
421
- "docker",
422
- "logs",
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
- def signal_handler(signum: int, frame: Any) -> None:
475
- """Handle signals by setting shutdown event."""
476
- hud_console.info(f"\n📡 Received signal {signum}, shutting down gracefully...")
477
- shutdown_event.set()
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
- # Register signal handlers - SIGINT is available on all platforms
480
- signal.signal(signal.SIGINT, signal_handler)
463
+ from hud.server import MCPServer
481
464
 
482
- # SIGTERM is not available on Windows
483
- if hasattr(signal, "SIGTERM"):
484
- signal.signal(signal.SIGTERM, signal_handler)
465
+ cwd = Path.cwd()
485
466
 
486
- # One more attempt to suppress the FastMCP server log
487
- if not verbose:
488
- # Re-apply the filter in case new handlers were created
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
- try:
530
- # Check if container exists
531
- check_result = await asyncio.create_subprocess_exec(
532
- "docker",
533
- "ps",
534
- "--format",
535
- "{{.Names}}",
536
- "--filter",
537
- f"name={container_name}",
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
- # Start Docker logs streaming if enabled
562
- log_task = None
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
- if transport == "stdio":
567
- # Run with stdio transport
568
- await proxy.run_async(
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
- # Stop the container before showing next steps
612
- await stop_container()
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
- # Suppress the graceful shutdown error and other FastMCP/uvicorn internal errors
630
- error_msg = str(e)
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
- # Always try to stop container on exit
650
- await stop_container()
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
- def run_mcp_dev_server(
654
- directory: str = ".",
655
- image: str | None = None,
656
- build: bool = False,
657
- no_cache: bool = False,
658
- transport: str = "http",
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
- # No external dependencies needed for hot-reload anymore!
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
- # Resolve image name
689
- resolved_image, source = get_image_name(directory, image)
544
+ # Add user-provided Docker arguments
545
+ if docker_args:
546
+ docker_cmd.extend(docker_args)
690
547
 
691
- # Update pyproject.toml with auto-generated name if needed
692
- if source == "auto":
693
- update_pyproject_toml(directory, resolved_image)
548
+ # Append the image name
549
+ docker_cmd.append(image_name)
694
550
 
695
- # Build if requested
696
- if build or no_cache:
697
- build_and_update(directory, resolved_image, no_cache)
551
+ # Print startup info
552
+ hud_console.header("HUD Development Mode (Docker)")
698
553
 
699
- # Check if image exists
700
- if not image_exists(resolved_image) and not build:
701
- if click.confirm(f"Image {resolved_image} not found. Build it now?"):
702
- build_and_update(directory, resolved_image)
703
- else:
704
- raise click.Abort
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
- # Generate server name from image
707
- server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
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
- # For HTTP transport, find available port first
710
- actual_port = port
711
- if transport == "http":
712
- from .utils.logging import find_free_port
713
-
714
- actual_port = find_free_port(port)
715
- if actual_port is None:
716
- hud_console.error(f"No available ports found starting from {port}")
717
- raise click.Abort
718
- if actual_port != port and verbose:
719
- hud_console.warning(f"Port {port} in use, using port {actual_port}")
720
-
721
- # Create config
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
- # For the deeplink, we only need the server config
733
- server_config_json = json.dumps(server_config, indent=2)
734
- config_base64 = base64.b64encode(server_config_json.encode()).decode()
590
+ # Create and run proxy with HUD helpers
591
+ async def run_proxy() -> None:
592
+ from fastmcp import FastMCP
735
593
 
736
- # Generate deeplink
737
- deeplink = (
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
- # Show header with gold border
742
- hud_console.info("") # Empty line before header
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
- hud_console.progress_message(
757
- f"❗ If any issues arise, run `hud debug {resolved_image}` to debug the container"
758
- )
600
+ # Import all tools from the FastMCP proxy
601
+ await proxy.import_server(fastmcp_proxy)
759
602
 
760
- # Show environment variables if provided
761
- if docker_args and any(arg == "-e" or arg.startswith("--env") for arg in docker_args):
762
- hud_console.section_title("Environment Variables")
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
- hud_console.info("") # Empty line
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
- start_mcp_proxy(
808
- directory,
809
- resolved_image,
810
- transport,
811
- port,
812
- no_reload,
813
- full_reload,
814
- verbose,
815
- inspector,
816
- no_logs,
817
- interactive,
818
- docker_args or [],
819
- )
820
- )
821
- except Exception as e:
822
- hud_console.error(f"Failed to start MCP server: {e}")
823
- hud_console.info("")
824
- hud_console.info("💡 Tip: Run the following command to debug the container:")
825
- hud_console.info(f" hud debug {resolved_image}")
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
- hud_console.info("This will help identify connection issues or initialization failures.")
828
- raise
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)