hud-python 0.4.45__py3-none-any.whl → 0.5.13__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (282) hide show
  1. hud/__init__.py +27 -7
  2. hud/agents/__init__.py +70 -5
  3. hud/agents/base.py +238 -500
  4. hud/agents/claude.py +236 -247
  5. hud/agents/gateway.py +42 -0
  6. hud/agents/gemini.py +264 -0
  7. hud/agents/gemini_cua.py +324 -0
  8. hud/agents/grounded_openai.py +98 -100
  9. hud/agents/misc/integration_test_agent.py +51 -20
  10. hud/agents/misc/response_agent.py +48 -36
  11. hud/agents/openai.py +282 -296
  12. hud/agents/{openai_chat_generic.py → openai_chat.py} +63 -33
  13. hud/agents/operator.py +199 -0
  14. hud/agents/resolver.py +70 -0
  15. hud/agents/tests/conftest.py +133 -0
  16. hud/agents/tests/test_base.py +300 -622
  17. hud/agents/tests/test_base_runtime.py +233 -0
  18. hud/agents/tests/test_claude.py +381 -214
  19. hud/agents/tests/test_client.py +9 -10
  20. hud/agents/tests/test_gemini.py +369 -0
  21. hud/agents/tests/test_grounded_openai_agent.py +65 -50
  22. hud/agents/tests/test_openai.py +377 -140
  23. hud/agents/tests/test_operator.py +362 -0
  24. hud/agents/tests/test_resolver.py +192 -0
  25. hud/agents/tests/test_run_eval.py +179 -0
  26. hud/agents/types.py +148 -0
  27. hud/cli/__init__.py +493 -546
  28. hud/cli/analyze.py +43 -5
  29. hud/cli/build.py +699 -113
  30. hud/cli/debug.py +8 -5
  31. hud/cli/dev.py +889 -732
  32. hud/cli/eval.py +793 -667
  33. hud/cli/flows/dev.py +167 -0
  34. hud/cli/flows/init.py +191 -0
  35. hud/cli/flows/tasks.py +153 -56
  36. hud/cli/flows/templates.py +151 -0
  37. hud/cli/flows/tests/__init__.py +1 -0
  38. hud/cli/flows/tests/test_dev.py +126 -0
  39. hud/cli/init.py +60 -58
  40. hud/cli/pull.py +1 -1
  41. hud/cli/push.py +38 -13
  42. hud/cli/rft.py +311 -0
  43. hud/cli/rft_status.py +145 -0
  44. hud/cli/tests/test_analyze.py +5 -5
  45. hud/cli/tests/test_analyze_metadata.py +3 -2
  46. hud/cli/tests/test_analyze_module.py +120 -0
  47. hud/cli/tests/test_build.py +110 -8
  48. hud/cli/tests/test_build_failure.py +41 -0
  49. hud/cli/tests/test_build_module.py +50 -0
  50. hud/cli/tests/test_cli_init.py +6 -1
  51. hud/cli/tests/test_cli_more_wrappers.py +30 -0
  52. hud/cli/tests/test_cli_root.py +140 -0
  53. hud/cli/tests/test_convert.py +361 -0
  54. hud/cli/tests/test_debug.py +12 -10
  55. hud/cli/tests/test_dev.py +197 -0
  56. hud/cli/tests/test_eval.py +251 -0
  57. hud/cli/tests/test_eval_bedrock.py +51 -0
  58. hud/cli/tests/test_init.py +124 -0
  59. hud/cli/tests/test_main_module.py +11 -5
  60. hud/cli/tests/test_mcp_server.py +12 -100
  61. hud/cli/tests/test_push.py +1 -1
  62. hud/cli/tests/test_push_happy.py +74 -0
  63. hud/cli/tests/test_push_wrapper.py +23 -0
  64. hud/cli/tests/test_registry.py +1 -1
  65. hud/cli/tests/test_utils.py +1 -1
  66. hud/cli/{rl → utils}/celebrate.py +14 -12
  67. hud/cli/utils/config.py +18 -1
  68. hud/cli/utils/docker.py +130 -4
  69. hud/cli/utils/env_check.py +9 -9
  70. hud/cli/utils/git.py +136 -0
  71. hud/cli/utils/interactive.py +39 -5
  72. hud/cli/utils/metadata.py +70 -1
  73. hud/cli/utils/runner.py +1 -1
  74. hud/cli/utils/server.py +2 -2
  75. hud/cli/utils/source_hash.py +3 -3
  76. hud/cli/utils/tasks.py +4 -1
  77. hud/cli/utils/tests/__init__.py +0 -0
  78. hud/cli/utils/tests/test_config.py +58 -0
  79. hud/cli/utils/tests/test_docker.py +93 -0
  80. hud/cli/utils/tests/test_docker_hints.py +71 -0
  81. hud/cli/utils/tests/test_env_check.py +74 -0
  82. hud/cli/utils/tests/test_environment.py +42 -0
  83. hud/cli/utils/tests/test_git.py +142 -0
  84. hud/cli/utils/tests/test_interactive_module.py +60 -0
  85. hud/cli/utils/tests/test_local_runner.py +50 -0
  86. hud/cli/utils/tests/test_logging_utils.py +23 -0
  87. hud/cli/utils/tests/test_metadata.py +49 -0
  88. hud/cli/utils/tests/test_package_runner.py +35 -0
  89. hud/cli/utils/tests/test_registry_utils.py +49 -0
  90. hud/cli/utils/tests/test_remote_runner.py +25 -0
  91. hud/cli/utils/tests/test_runner_modules.py +52 -0
  92. hud/cli/utils/tests/test_source_hash.py +36 -0
  93. hud/cli/utils/tests/test_tasks.py +80 -0
  94. hud/cli/utils/version_check.py +258 -0
  95. hud/cli/{rl → utils}/viewer.py +2 -2
  96. hud/clients/README.md +12 -11
  97. hud/clients/__init__.py +4 -3
  98. hud/clients/base.py +166 -26
  99. hud/clients/environment.py +51 -0
  100. hud/clients/fastmcp.py +13 -6
  101. hud/clients/mcp_use.py +45 -15
  102. hud/clients/tests/test_analyze_scenarios.py +206 -0
  103. hud/clients/tests/test_protocol.py +9 -3
  104. hud/datasets/__init__.py +23 -20
  105. hud/datasets/loader.py +326 -0
  106. hud/datasets/runner.py +198 -105
  107. hud/datasets/tests/__init__.py +0 -0
  108. hud/datasets/tests/test_loader.py +221 -0
  109. hud/datasets/tests/test_utils.py +315 -0
  110. hud/datasets/utils.py +270 -90
  111. hud/environment/__init__.py +52 -0
  112. hud/environment/connection.py +258 -0
  113. hud/environment/connectors/__init__.py +33 -0
  114. hud/environment/connectors/base.py +68 -0
  115. hud/environment/connectors/local.py +177 -0
  116. hud/environment/connectors/mcp_config.py +137 -0
  117. hud/environment/connectors/openai.py +101 -0
  118. hud/environment/connectors/remote.py +172 -0
  119. hud/environment/environment.py +835 -0
  120. hud/environment/integrations/__init__.py +45 -0
  121. hud/environment/integrations/adk.py +67 -0
  122. hud/environment/integrations/anthropic.py +196 -0
  123. hud/environment/integrations/gemini.py +92 -0
  124. hud/environment/integrations/langchain.py +82 -0
  125. hud/environment/integrations/llamaindex.py +68 -0
  126. hud/environment/integrations/openai.py +238 -0
  127. hud/environment/mock.py +306 -0
  128. hud/environment/router.py +263 -0
  129. hud/environment/scenarios.py +620 -0
  130. hud/environment/tests/__init__.py +1 -0
  131. hud/environment/tests/test_connection.py +317 -0
  132. hud/environment/tests/test_connectors.py +205 -0
  133. hud/environment/tests/test_environment.py +593 -0
  134. hud/environment/tests/test_integrations.py +257 -0
  135. hud/environment/tests/test_local_connectors.py +242 -0
  136. hud/environment/tests/test_scenarios.py +1086 -0
  137. hud/environment/tests/test_tools.py +208 -0
  138. hud/environment/types.py +23 -0
  139. hud/environment/utils/__init__.py +35 -0
  140. hud/environment/utils/formats.py +215 -0
  141. hud/environment/utils/schema.py +171 -0
  142. hud/environment/utils/tool_wrappers.py +113 -0
  143. hud/eval/__init__.py +67 -0
  144. hud/eval/context.py +727 -0
  145. hud/eval/display.py +299 -0
  146. hud/eval/instrument.py +187 -0
  147. hud/eval/manager.py +533 -0
  148. hud/eval/parallel.py +268 -0
  149. hud/eval/task.py +372 -0
  150. hud/eval/tests/__init__.py +1 -0
  151. hud/eval/tests/test_context.py +178 -0
  152. hud/eval/tests/test_eval.py +210 -0
  153. hud/eval/tests/test_manager.py +152 -0
  154. hud/eval/tests/test_parallel.py +168 -0
  155. hud/eval/tests/test_task.py +291 -0
  156. hud/eval/types.py +65 -0
  157. hud/eval/utils.py +194 -0
  158. hud/patches/__init__.py +19 -0
  159. hud/patches/mcp_patches.py +308 -0
  160. hud/patches/warnings.py +54 -0
  161. hud/samples/browser.py +4 -4
  162. hud/server/__init__.py +2 -1
  163. hud/server/low_level.py +2 -1
  164. hud/server/router.py +164 -0
  165. hud/server/server.py +567 -80
  166. hud/server/tests/test_mcp_server_integration.py +11 -11
  167. hud/server/tests/test_mcp_server_more.py +1 -1
  168. hud/server/tests/test_server_extra.py +2 -0
  169. hud/settings.py +45 -3
  170. hud/shared/exceptions.py +36 -10
  171. hud/shared/hints.py +26 -1
  172. hud/shared/requests.py +15 -3
  173. hud/shared/tests/test_exceptions.py +40 -31
  174. hud/shared/tests/test_hints.py +167 -0
  175. hud/telemetry/__init__.py +20 -19
  176. hud/telemetry/exporter.py +201 -0
  177. hud/telemetry/instrument.py +165 -253
  178. hud/telemetry/tests/test_eval_telemetry.py +356 -0
  179. hud/telemetry/tests/test_exporter.py +258 -0
  180. hud/telemetry/tests/test_instrument.py +401 -0
  181. hud/tools/__init__.py +18 -2
  182. hud/tools/agent.py +223 -0
  183. hud/tools/apply_patch.py +639 -0
  184. hud/tools/base.py +54 -4
  185. hud/tools/bash.py +2 -2
  186. hud/tools/computer/__init__.py +36 -3
  187. hud/tools/computer/anthropic.py +2 -2
  188. hud/tools/computer/gemini.py +385 -0
  189. hud/tools/computer/hud.py +23 -6
  190. hud/tools/computer/openai.py +20 -21
  191. hud/tools/computer/qwen.py +434 -0
  192. hud/tools/computer/settings.py +37 -0
  193. hud/tools/edit.py +3 -7
  194. hud/tools/executors/base.py +4 -2
  195. hud/tools/executors/pyautogui.py +1 -1
  196. hud/tools/grounding/grounded_tool.py +13 -18
  197. hud/tools/grounding/grounder.py +10 -31
  198. hud/tools/grounding/tests/test_grounded_tool.py +26 -44
  199. hud/tools/jupyter.py +330 -0
  200. hud/tools/playwright.py +18 -3
  201. hud/tools/shell.py +308 -0
  202. hud/tools/tests/test_agent_tool.py +355 -0
  203. hud/tools/tests/test_apply_patch.py +718 -0
  204. hud/tools/tests/test_computer.py +4 -9
  205. hud/tools/tests/test_computer_actions.py +24 -2
  206. hud/tools/tests/test_jupyter_tool.py +181 -0
  207. hud/tools/tests/test_shell.py +596 -0
  208. hud/tools/tests/test_submit.py +85 -0
  209. hud/tools/tests/test_types.py +193 -0
  210. hud/tools/types.py +21 -1
  211. hud/types.py +194 -56
  212. hud/utils/__init__.py +2 -0
  213. hud/utils/env.py +67 -0
  214. hud/utils/hud_console.py +89 -18
  215. hud/utils/mcp.py +15 -58
  216. hud/utils/strict_schema.py +162 -0
  217. hud/utils/tests/test_init.py +1 -2
  218. hud/utils/tests/test_mcp.py +1 -28
  219. hud/utils/tests/test_pretty_errors.py +186 -0
  220. hud/utils/tests/test_tool_shorthand.py +154 -0
  221. hud/utils/tests/test_version.py +1 -1
  222. hud/utils/types.py +20 -0
  223. hud/version.py +1 -1
  224. hud_python-0.5.13.dist-info/METADATA +264 -0
  225. hud_python-0.5.13.dist-info/RECORD +305 -0
  226. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/WHEEL +1 -1
  227. hud/agents/langchain.py +0 -261
  228. hud/agents/lite_llm.py +0 -72
  229. hud/cli/rl/__init__.py +0 -180
  230. hud/cli/rl/config.py +0 -101
  231. hud/cli/rl/display.py +0 -133
  232. hud/cli/rl/gpu.py +0 -63
  233. hud/cli/rl/gpu_utils.py +0 -321
  234. hud/cli/rl/local_runner.py +0 -595
  235. hud/cli/rl/presets.py +0 -96
  236. hud/cli/rl/remote_runner.py +0 -463
  237. hud/cli/rl/rl_api.py +0 -150
  238. hud/cli/rl/vllm.py +0 -177
  239. hud/cli/rl/wait_utils.py +0 -89
  240. hud/datasets/parallel.py +0 -687
  241. hud/misc/__init__.py +0 -1
  242. hud/misc/claude_plays_pokemon.py +0 -292
  243. hud/otel/__init__.py +0 -35
  244. hud/otel/collector.py +0 -142
  245. hud/otel/config.py +0 -181
  246. hud/otel/context.py +0 -570
  247. hud/otel/exporters.py +0 -369
  248. hud/otel/instrumentation.py +0 -135
  249. hud/otel/processors.py +0 -121
  250. hud/otel/tests/__init__.py +0 -1
  251. hud/otel/tests/test_processors.py +0 -197
  252. hud/rl/README.md +0 -30
  253. hud/rl/__init__.py +0 -1
  254. hud/rl/actor.py +0 -176
  255. hud/rl/buffer.py +0 -405
  256. hud/rl/chat_template.jinja +0 -101
  257. hud/rl/config.py +0 -192
  258. hud/rl/distributed.py +0 -132
  259. hud/rl/learner.py +0 -637
  260. hud/rl/tests/__init__.py +0 -1
  261. hud/rl/tests/test_learner.py +0 -186
  262. hud/rl/train.py +0 -382
  263. hud/rl/types.py +0 -101
  264. hud/rl/utils/start_vllm_server.sh +0 -30
  265. hud/rl/utils.py +0 -524
  266. hud/rl/vllm_adapter.py +0 -143
  267. hud/telemetry/job.py +0 -352
  268. hud/telemetry/replay.py +0 -74
  269. hud/telemetry/tests/test_replay.py +0 -40
  270. hud/telemetry/tests/test_trace.py +0 -63
  271. hud/telemetry/trace.py +0 -158
  272. hud/utils/agent_factories.py +0 -86
  273. hud/utils/async_utils.py +0 -65
  274. hud/utils/group_eval.py +0 -223
  275. hud/utils/progress.py +0 -149
  276. hud/utils/tasks.py +0 -127
  277. hud/utils/tests/test_async_utils.py +0 -173
  278. hud/utils/tests/test_progress.py +0 -261
  279. hud_python-0.4.45.dist-info/METADATA +0 -552
  280. hud_python-0.4.45.dist-info/RECORD +0 -228
  281. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/entry_points.txt +0 -0
  282. {hud_python-0.4.45.dist-info → hud_python-0.5.13.dist-info}/licenses/LICENSE +0 -0
hud/cli/dev.py CHANGED
@@ -1,828 +1,985 @@
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 contextlib
7
+ import importlib
8
+ import importlib.util
9
+ import logging
10
+ import os
8
11
  import subprocess
12
+ import sys
13
+ import threading
9
14
  from pathlib import Path
10
15
  from typing import Any
11
16
 
12
- import click
13
- from fastmcp import FastMCP
17
+ import typer
14
18
 
15
19
  from hud.utils.hud_console import HUDConsole
16
20
 
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
21
  hud_console = HUDConsole()
27
22
 
28
23
 
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
24
+ def show_dev_server_info(
25
+ server_name: str,
26
+ port: int,
27
+ transport: str,
28
+ inspector: bool,
29
+ interactive: bool,
30
+ env_dir: Path | None = None,
31
+ docker_mode: bool = False,
32
+ telemetry: dict[str, Any] | None = None,
33
+ ) -> str:
34
+ """Show consistent server info for both Python and Docker modes.
35
+
36
+ Returns the Cursor deeplink URL.
37
+ """
38
+ import base64
39
+ import json
40
+
41
+ # Generate Cursor deeplink
42
+ server_config = {"url": f"http://localhost:{port}/mcp"}
43
+ config_json = json.dumps(server_config, indent=2)
44
+ config_base64 = base64.b64encode(config_json.encode()).decode()
45
+ cursor_deeplink = (
46
+ f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
47
+ )
33
48
 
49
+ # Server section
50
+ hud_console.section_title("Server")
51
+ hud_console.info(f"{hud_console.sym.ITEM} {server_name}")
52
+ if transport == "http":
53
+ hud_console.info(f"{hud_console.sym.ITEM} http://localhost:{port}/mcp")
54
+ else:
55
+ hud_console.info(f"{hud_console.sym.ITEM} (stdio)")
34
56
 
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)
57
+ # Quick Links (only for HTTP mode)
58
+ if transport == "http":
59
+ hud_console.section_title("Quick Links")
60
+ hud_console.info(f"{hud_console.sym.ITEM} Docs: http://localhost:{port}/docs")
61
+ hud_console.info(f"{hud_console.sym.ITEM} Cursor:")
62
+ # Display the Cursor link on its own line to prevent wrapping
63
+ hud_console.link(cursor_deeplink)
64
+
65
+ # Show eval endpoint if in Docker mode
66
+ if docker_mode:
67
+ hud_console.info(
68
+ f"{hud_console.sym.ITEM} Eval API: http://localhost:{port}/eval (POST)"
69
+ )
46
70
 
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"]
71
+ # Show debugging URLs from telemetry
72
+ if telemetry:
73
+ if "live_url" in telemetry:
74
+ hud_console.info(f"{hud_console.sym.ITEM} Live URL: {telemetry['live_url']}")
75
+ if "vnc_url" in telemetry:
76
+ hud_console.info(f"{hud_console.sym.ITEM} VNC URL: {telemetry['vnc_url']}")
77
+ if "cdp_url" in telemetry:
78
+ hud_console.info(f"{hud_console.sym.ITEM} CDP URL: {telemetry['cdp_url']}")
79
+
80
+ # Check for VNC (browser environment)
81
+ if env_dir and (env_dir / "environment" / "server.py").exists():
82
+ try:
83
+ content = (env_dir / "environment" / "server.py").read_text()
84
+ if "x11vnc" in content.lower() or "vnc" in content.lower():
85
+ hud_console.info(f"{hud_console.sym.ITEM} VNC: http://localhost:8080/vnc.html")
86
+ except Exception: # noqa: S110
87
+ pass
88
+
89
+ # Inspector/Interactive status
90
+ if inspector or interactive:
91
+ hud_console.info("")
92
+ if inspector:
93
+ hud_console.info(f"{hud_console.sym.SUCCESS} Inspector launching...")
94
+ if interactive:
95
+ hud_console.info(f"{hud_console.sym.SUCCESS} Interactive mode enabled")
52
96
 
53
- # Generate unique container name from image to avoid conflicts between multiple instances
54
- import os
97
+ hud_console.info("")
98
+ hud_console.info(f"{hud_console.sym.SUCCESS} Hot-reload enabled")
99
+ hud_console.info("")
55
100
 
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}"
59
-
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
- ]
101
+ return cursor_deeplink
102
+
103
+
104
+ def _has_mcp_or_env(content: str) -> bool:
105
+ """Check if file content defines an mcp or env variable."""
106
+ # Check for mcp = MCPServer(...) or mcp = FastMCP(...)
107
+ if "mcp" in content and ("= MCPServer" in content or "= FastMCP" in content):
108
+ return True
109
+ # Check for env = Environment(...)
110
+ return "env" in content and "= Environment" in content
111
+
112
+
113
+ def auto_detect_module() -> tuple[str, Path | None] | tuple[None, None]:
114
+ """Auto-detect MCP module in current directory.
75
115
 
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():
116
+ Looks for 'mcp' or 'env' defined in either __init__.py or main.py.
117
+ - 'mcp' with MCPServer or FastMCP
118
+ - 'env' with Environment
119
+
120
+ Returns:
121
+ Tuple of (module_name, parent_dir_to_add_to_path) or (None, None)
122
+ """
123
+ cwd = Path.cwd()
124
+
125
+ # First check __init__.py
126
+ init_file = cwd / "__init__.py"
127
+ if init_file.exists():
80
128
  try:
81
- from hud.cli.utils.config import parse_env_file
82
-
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}")
93
-
94
- # Add user-provided Docker arguments
95
- if docker_args:
96
- docker_cmd.extend(docker_args)
97
-
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
- }
129
+ content = init_file.read_text(encoding="utf-8")
130
+ if _has_mcp_or_env(content):
131
+ return (cwd.name, None)
132
+ except Exception: # noqa: S110
133
+ pass
134
+
135
+ # Then check main.py in current directory
136
+ main_file = cwd / "main.py"
137
+ if main_file.exists() and init_file.exists():
138
+ try:
139
+ content = main_file.read_text(encoding="utf-8")
140
+ if _has_mcp_or_env(content):
141
+ # Need to import as package.main, add parent to sys.path
142
+ return (f"{cwd.name}.main", cwd.parent)
143
+ except Exception: # noqa: S110
144
+ pass
145
+
146
+ return (None, None)
147
+
148
+
149
+ def should_use_docker_mode(cwd: Path) -> bool:
150
+ """Check if environment requires Docker mode (has Dockerfile in current dir).
151
+
152
+ Checks for Dockerfile.hud first (HUD-specific), then falls back to Dockerfile.
153
+ """
154
+ return (cwd / "Dockerfile.hud").exists() or (cwd / "Dockerfile").exists()
155
+
156
+
157
+ async def run_mcp_module(
158
+ module_spec: str,
159
+ transport: str,
160
+ port: int,
161
+ verbose: bool,
162
+ inspector: bool,
163
+ interactive: bool,
164
+ new_trace: bool = False,
165
+ ) -> None:
166
+ """Run an MCP module directly.
167
+
168
+ Args:
169
+ module_spec: Module specification in format "module" or "module:attribute"
170
+ e.g., "server" (looks for mcp), "env:env" (looks for env)
171
+ """
172
+ # Parse module:attribute format (like uvicorn/gunicorn)
173
+ if ":" in module_spec:
174
+ module_name, attr_name = module_spec.rsplit(":", 1)
175
+ else:
176
+ module_name = module_spec
177
+ attr_name = "mcp" # Default attribute
122
178
 
123
- # Debug output - only if verbose
179
+ # Check if this is a reload (not first run)
180
+ is_reload = os.environ.get("_HUD_DEV_RELOAD") == "1"
181
+
182
+ # Configure logging
124
183
  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")
184
+ logging.basicConfig(
185
+ stream=sys.stderr, level=logging.DEBUG, format="[%(levelname)s] %(message)s"
186
+ )
187
+ else:
188
+ # Suppress tracebacks in logs unless verbose
189
+ logging.basicConfig(stream=sys.stderr, level=logging.INFO, format="%(message)s")
190
+
191
+ # Suppress FastMCP's verbose logging
192
+ logging.getLogger("fastmcp.tools.tool_manager").setLevel(logging.WARNING)
193
+ logging.getLogger("fastmcp.server.server").setLevel(logging.WARNING)
194
+ logging.getLogger("fastmcp.server.openapi").setLevel(logging.WARNING)
195
+
196
+ # On reload, suppress most startup logs
197
+ if is_reload:
198
+ logging.getLogger("hud.server.server").setLevel(logging.ERROR)
199
+ logging.getLogger("mcp.server").setLevel(logging.ERROR)
200
+ logging.getLogger("mcp.server.streamable_http_manager").setLevel(logging.ERROR)
201
+
202
+ # Suppress deprecation warnings on reload
203
+ from hud.patches.warnings import apply_default_warning_filters
204
+
205
+ apply_default_warning_filters(verbose=False)
206
+
207
+ # Ensure proper directory is in sys.path based on module name
208
+ cwd = Path.cwd()
209
+ if "." in module_name:
210
+ # For package.module imports (like server.server), add parent to sys.path
211
+ parent = str(cwd.parent)
212
+ if parent not in sys.path:
213
+ sys.path.insert(0, parent)
214
+ else:
215
+ # For simple module imports, add current directory
216
+ cwd_str = str(cwd)
217
+ if cwd_str not in sys.path:
218
+ sys.path.insert(0, cwd_str)
219
+
220
+ # Import the module
221
+ try:
222
+ module = importlib.import_module(module_name)
223
+ except Exception as e:
224
+ hud_console.error(f"Failed to import module '{module_name}'")
225
+ hud_console.info(f"Error: {e}")
226
+ hud_console.info("")
227
+ hud_console.info("[bold cyan]Troubleshooting:[/bold cyan]")
228
+ hud_console.info(" • Verify module exists and is importable")
229
+ hud_console.info(" • Check for __init__.py in module directory")
230
+ hud_console.info(" • Check for import errors in the module")
231
+ if verbose:
232
+ import traceback
233
+
234
+ hud_console.info("")
235
+ hud_console.info("[bold cyan]Full traceback:[/bold cyan]")
236
+ hud_console.info(traceback.format_exc())
237
+ sys.exit(1)
238
+
239
+ # Look for the specified attribute
240
+ if verbose:
241
+ hud_console.info(f"Module attributes: {dir(module)}")
242
+ module_dict = module.__dict__ if hasattr(module, "__dict__") else {}
243
+ hud_console.info(f"Module __dict__ keys: {list(module_dict.keys())}")
244
+
245
+ mcp_server = None
246
+
247
+ # Try different ways to access the attribute
248
+ if hasattr(module, attr_name):
249
+ mcp_server = getattr(module, attr_name)
250
+ elif hasattr(module, "__dict__") and attr_name in module.__dict__:
251
+ mcp_server = module.__dict__[attr_name]
252
+
253
+ # If default 'mcp' not found, try 'env' as fallback
254
+ if mcp_server is None and attr_name == "mcp":
255
+ for fallback in ["env", "environment", "server"]:
256
+ if hasattr(module, fallback):
257
+ mcp_server = getattr(module, fallback)
258
+ if verbose:
259
+ hud_console.info(f"Found '{fallback}' instead of 'mcp'")
260
+ break
261
+
262
+ if mcp_server is None:
263
+ hud_console.error(f"Module '{module_name}' does not have '{attr_name}' defined")
264
+ hud_console.info("")
265
+ available = [k for k in dir(module) if not k.startswith("_")]
266
+ hud_console.info(f"Available in module: {available}")
267
+ hud_console.info("")
268
+ hud_console.info("[bold cyan]Expected structure:[/bold cyan]")
269
+ hud_console.info(" from hud.environment import Environment")
270
+ hud_console.info(" env = Environment('my-env') # or mcp = ...")
271
+ raise AttributeError(f"Module '{module_name}' must define 'mcp', 'env', or 'environment'")
272
+
273
+ # Only show full header on first run, brief message on reload
274
+ if is_reload:
275
+ hud_console.info(f"{hud_console.sym.SUCCESS} Reloaded")
276
+ # Run server without showing full UI
277
+ else:
278
+ # Show full header on first run
279
+ hud_console.info("")
280
+ hud_console.header("HUD Development Server")
281
+
282
+ # Show server info only on first run
283
+ if not is_reload:
284
+ # Try dynamic trace first for HTTP mode (only if --new flag is set)
285
+ live_trace_url: str | None = None
286
+ if transport == "http" and new_trace:
287
+ try:
288
+ local_mcp_config: dict[str, dict[str, Any]] = {
289
+ "hud": {
290
+ "url": f"http://localhost:{port}/mcp",
291
+ "headers": {},
292
+ }
293
+ }
294
+
295
+ from hud.cli.flows.dev import create_dynamic_trace
296
+
297
+ _, live_trace_url = await create_dynamic_trace(
298
+ mcp_config=local_mcp_config,
299
+ build_status=False,
300
+ environment_name=mcp_server.name or "mcp-server",
301
+ )
302
+ except SystemExit:
303
+ raise # Let API key requirement exits through
304
+ except Exception: # noqa: S110
305
+ pass
306
+
307
+ # Show UI using shared flow logic
308
+ if transport == "http" and live_trace_url:
309
+ # Minimal UI with live trace
310
+ from hud.cli.flows.dev import generate_cursor_deeplink, show_dev_ui
311
+
312
+ server_name = mcp_server.name or "mcp-server"
313
+ cursor_deeplink = generate_cursor_deeplink(server_name, port)
314
+
315
+ show_dev_ui(
316
+ live_trace_url=live_trace_url,
317
+ server_name=server_name,
318
+ port=port,
319
+ cursor_deeplink=cursor_deeplink,
320
+ is_docker=False,
321
+ )
128
322
  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")
323
+ # Full UI for HTTP without trace, or stdio mode
324
+ show_dev_server_info(
325
+ server_name=mcp_server.name or "mcp-server",
326
+ port=port,
327
+ transport=transport,
328
+ inspector=inspector,
329
+ interactive=interactive,
330
+ env_dir=Path.cwd().parent if (Path.cwd().parent / "environment").exists() else None,
331
+ )
132
332
 
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
136
- )
137
- has_env_from_file = bool(loaded_env_vars)
138
- if has_env_from_args or has_env_from_file:
333
+ # Check if there's an environment backend and remind user to start it (first run only)
334
+ if not is_reload:
335
+ cwd = Path.cwd()
336
+ env_dir = cwd.parent / "environment"
337
+ if env_dir.exists() and (env_dir / "server.py").exists():
139
338
  hud_console.info("")
140
- hud_console.info("Docker command with environment variables:")
141
- hud_console.info(" ".join(docker_cmd))
339
+ hud_console.info(
340
+ f"{hud_console.sym.FLOW} Don't forget to start the environment "
341
+ "backend in another terminal:"
342
+ )
343
+ hud_console.info(" cd environment && uv run python uvicorn server:app --reload")
344
+
345
+ # Launch inspector if requested (first run only)
346
+ if inspector and transport == "http":
347
+ await launch_inspector(port)
348
+
349
+ # Launch interactive mode if requested (first run only)
350
+ if interactive and transport == "http":
351
+ launch_interactive_thread(port, verbose)
352
+
353
+ hud_console.info("")
354
+
355
+ # Configure server options
356
+ run_kwargs = {
357
+ "transport": transport,
358
+ "show_banner": False,
359
+ }
360
+
361
+ if transport == "http":
362
+ run_kwargs["port"] = port
363
+ run_kwargs["path"] = "/mcp"
364
+ run_kwargs["host"] = "0.0.0.0" # noqa: S104
365
+ run_kwargs["log_level"] = "INFO" if verbose else "ERROR"
366
+
367
+ # Run the server
368
+ await mcp_server.run_async(**run_kwargs)
369
+
370
+
371
+ async def launch_inspector(port: int) -> None:
372
+ """Launch MCP Inspector in background."""
373
+ await asyncio.sleep(2)
142
374
 
143
- # Create the HTTP proxy server using config
144
375
  try:
145
- proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
376
+ import platform
377
+ import urllib.parse
378
+
379
+ server_url = f"http://localhost:{port}/mcp"
380
+ encoded_url = urllib.parse.quote(server_url)
381
+ inspector_url = f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
382
+
383
+ hud_console.section_title("MCP Inspector")
384
+ hud_console.link(inspector_url)
385
+
386
+ env = os.environ.copy()
387
+ env["DANGEROUSLY_OMIT_AUTH"] = "true"
388
+ env["MCP_AUTO_OPEN_ENABLED"] = "true"
389
+
390
+ cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
391
+
392
+ if platform.system() == "Windows":
393
+ subprocess.Popen( # noqa: S602, ASYNC220
394
+ cmd,
395
+ env=env,
396
+ shell=True,
397
+ stdout=subprocess.DEVNULL,
398
+ stderr=subprocess.DEVNULL,
399
+ )
400
+ else:
401
+ subprocess.Popen( # noqa: S603, ASYNC220
402
+ cmd,
403
+ env=env,
404
+ stdout=subprocess.DEVNULL,
405
+ stderr=subprocess.DEVNULL,
406
+ )
407
+
146
408
  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
409
+ hud_console.error(f"Failed to launch inspector: {e}")
152
410
 
153
- return proxy
154
411
 
412
+ def launch_interactive_thread(port: int, verbose: bool) -> None:
413
+ """Launch interactive testing mode in separate thread."""
414
+ import time
155
415
 
156
- async def start_mcp_proxy(
157
- directory: str | Path,
158
- image_name: str,
416
+ def run_interactive() -> None:
417
+ time.sleep(2)
418
+
419
+ try:
420
+ hud_console.section_title("Interactive Mode")
421
+ hud_console.info("Starting interactive testing mode...")
422
+
423
+ from .utils.interactive import run_interactive_mode
424
+
425
+ server_url = f"http://localhost:{port}/mcp"
426
+
427
+ loop = asyncio.new_event_loop()
428
+ asyncio.set_event_loop(loop)
429
+ try:
430
+ loop.run_until_complete(run_interactive_mode(server_url, verbose))
431
+ finally:
432
+ loop.close()
433
+
434
+ except Exception as e:
435
+ if verbose:
436
+ hud_console.error(f"Interactive mode error: {e}")
437
+
438
+ interactive_thread = threading.Thread(target=run_interactive, daemon=True)
439
+ interactive_thread.start()
440
+
441
+
442
+ def run_with_reload(
443
+ module_name: str,
444
+ watch_paths: list[str],
159
445
  transport: str,
160
446
  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,
447
+ verbose: bool,
448
+ inspector: bool,
449
+ interactive: bool,
450
+ new_trace: bool = False,
168
451
  ) -> None:
169
- """Start the MCP development proxy server."""
170
- # Suppress FastMCP's verbose output FIRST
171
- import asyncio
172
- import logging
173
- import os
452
+ """Run module with file watching and auto-reload."""
453
+ try:
454
+ import watchfiles
455
+ except ImportError:
456
+ hud_console.error("watchfiles required. Install: pip install watchfiles")
457
+ sys.exit(1)
458
+
459
+ # Resolve watch paths
460
+ resolved_paths = []
461
+ for path_str in watch_paths:
462
+ path = Path(path_str).resolve()
463
+ if path.is_file():
464
+ resolved_paths.append(str(path.parent))
465
+ else:
466
+ resolved_paths.append(str(path))
467
+
468
+ if verbose:
469
+ hud_console.info(f"Watching: {', '.join(resolved_paths)}")
470
+
174
471
  import signal
175
- import sys
176
472
 
177
- from .utils.logging import find_free_port
473
+ process = None
474
+ stop_event = threading.Event()
475
+ is_first_run = True
178
476
 
179
- # Always disable the banner - we have our own output
180
- os.environ["FASTMCP_DISABLE_BANNER"] = "1"
477
+ def handle_signal(signum: int, frame: Any) -> None:
478
+ if process:
479
+ process.terminate()
480
+ sys.exit(0)
181
481
 
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
482
+ signal.signal(signal.SIGTERM, handle_signal)
483
+ signal.signal(signal.SIGINT, handle_signal)
267
484
 
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
485
+ while True:
486
+ cmd = [sys.executable, "-m", "hud", "dev", module_name, f"--port={port}"]
277
487
 
278
- if actual_port != port and verbose:
279
- hud_console.warning(f"Port {port} in use, using port {actual_port} instead")
488
+ if transport == "stdio":
489
+ cmd.append("--stdio")
280
490
 
281
- # Launch MCP Inspector if requested
282
- if inspector:
283
- server_url = f"http://localhost:{actual_port}/mcp"
491
+ if verbose:
492
+ cmd.append("--verbose")
284
493
 
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)
494
+ if new_trace and is_first_run:
495
+ cmd.append("--new")
290
496
 
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
497
+ if verbose:
498
+ hud_console.info(f"Starting: {' '.join(cmd)}")
333
499
 
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"
500
+ # Mark as reload after first run to suppress logs
501
+ env = {**os.environ, "_HUD_DEV_CHILD": "1"}
502
+ if not is_first_run:
503
+ env["_HUD_DEV_RELOAD"] = "1"
340
504
 
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
505
+ process = subprocess.Popen( # noqa: S603
506
+ cmd, env=env
507
+ )
345
508
 
346
- # Wait for server to be ready
347
- time.sleep(3)
509
+ is_first_run = False
348
510
 
511
+ try:
512
+ stop_event = threading.Event()
513
+
514
+ def _wait_and_set(
515
+ stop_event: threading.Event, process: subprocess.Popen[bytes]
516
+ ) -> None:
517
+ try:
518
+ if process is not None:
519
+ process.wait()
520
+ finally:
521
+ stop_event.set()
522
+
523
+ threading.Thread(target=_wait_and_set, args=(stop_event, process), daemon=True).start()
524
+
525
+ for changes in watchfiles.watch(*resolved_paths, stop_event=stop_event):
526
+ relevant_changes = [
527
+ (change_type, path)
528
+ for change_type, path in changes
529
+ if any(path.endswith(ext) for ext in [".py", ".json", ".toml", ".yaml"])
530
+ and "__pycache__" not in path
531
+ and not Path(path).name.startswith(".")
532
+ ]
533
+
534
+ if relevant_changes:
535
+ hud_console.flow("File changes detected, reloading...")
536
+ if verbose:
537
+ for change_type, path in relevant_changes:
538
+ hud_console.info(f" {change_type}: {path}")
539
+
540
+ if process is not None:
541
+ process.terminate()
349
542
  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()
543
+ if process is not None:
544
+ process.wait(timeout=5)
545
+ except subprocess.TimeoutExpired:
546
+ if process is not None:
547
+ process.kill()
548
+ process.wait()
407
549
 
408
- # If container doesn't exist, wait and retry
409
- if container_name not in stdout.decode():
410
- await asyncio.sleep(1)
411
- continue
550
+ import time
412
551
 
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
552
+ time.sleep(0.1)
553
+ break
417
554
 
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
- )
555
+ except KeyboardInterrupt:
556
+ if process:
557
+ process.terminate()
558
+ process.wait()
559
+ break
428
560
 
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
561
 
459
- log_hud_console.warning(f"Traceback: {traceback.format_exc()}") # noqa: G004
460
- await asyncio.sleep(1)
562
+ def run_docker_dev_server(
563
+ port: int,
564
+ verbose: bool,
565
+ inspector: bool,
566
+ interactive: bool,
567
+ docker_args: list[str],
568
+ watch_paths: list[str] | None = None,
569
+ new_trace: bool = False,
570
+ ) -> None:
571
+ """Run MCP server in Docker with volume mounts, expose via local HTTP proxy.
572
+
573
+ Args:
574
+ port: HTTP port to expose
575
+ verbose: Show detailed logs
576
+ inspector: Launch MCP Inspector
577
+ interactive: Launch interactive testing mode
578
+ docker_args: Extra Docker run arguments
579
+ watch_paths: Folders/files to mount for hot-reload (e.g., ["tools", "env.py"]).
580
+ If None, no hot-reload mounts are added.
581
+ new_trace: Create a new dev trace on hud.ai
582
+ """
583
+ import atexit
584
+ import signal
585
+
586
+ import typer
587
+ import yaml
461
588
 
462
- # Import contextlib here so it's available in the finally block
463
- import contextlib
589
+ from hud.server import MCPServer
464
590
 
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
- )
591
+ # Ensure Docker CLI and daemon are available before proceeding
592
+ from .utils.docker import require_docker_running
470
593
 
471
- # Set up signal handlers for graceful shutdown
472
- shutdown_event = asyncio.Event()
594
+ require_docker_running()
473
595
 
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()
596
+ cwd = Path.cwd()
478
597
 
479
- # Register signal handlers - SIGINT is available on all platforms
480
- signal.signal(signal.SIGINT, signal_handler)
598
+ # Container name will be set later and used for cleanup
599
+ container_name: str | None = None
600
+ cleanup_done = False
481
601
 
482
- # SIGTERM is not available on Windows
483
- if hasattr(signal, "SIGTERM"):
484
- signal.signal(signal.SIGTERM, signal_handler)
602
+ def cleanup_container() -> None:
603
+ """Clean up Docker container on exit."""
604
+ nonlocal cleanup_done
605
+ if cleanup_done or not container_name:
606
+ return
485
607
 
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
608
+ cleanup_done = True
609
+ hud_console.debug(f"Cleaning up container: {container_name}")
528
610
 
611
+ # Check if container is still running
529
612
  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,
613
+ result = subprocess.run( # noqa: S603
614
+ ["docker", "ps", "-q", "-f", f"name={container_name}"], # noqa: S607
615
+ stdout=subprocess.PIPE,
616
+ stderr=subprocess.DEVNULL,
617
+ text=True,
618
+ timeout=5,
540
619
  )
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,
620
+ if not result.stdout.strip():
621
+ # Container is not running, just try to remove it
622
+ subprocess.run( # noqa: S603
623
+ ["docker", "rm", "-f", container_name], # noqa: S607
624
+ stdout=subprocess.DEVNULL,
625
+ stderr=subprocess.DEVNULL,
626
+ timeout=5,
553
627
  )
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}")
559
-
560
- 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())
628
+ return
629
+ except Exception: # noqa: S110
630
+ pass
565
631
 
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
632
+ try:
633
+ # First try to stop gracefully
634
+ subprocess.run( # noqa: S603
635
+ ["docker", "stop", container_name], # noqa: S607
636
+ stdout=subprocess.DEVNULL,
637
+ stderr=subprocess.DEVNULL,
638
+ timeout=10,
570
639
  )
640
+ hud_console.debug(f"Container {container_name} stopped successfully")
641
+ except subprocess.TimeoutExpired:
642
+ # Force kill if stop times out
643
+ hud_console.debug(f"Container {container_name} stop timeout, forcing kill")
644
+ with contextlib.suppress(Exception):
645
+ subprocess.run( # noqa: S603
646
+ ["docker", "kill", container_name], # noqa: S607
647
+ stdout=subprocess.DEVNULL,
648
+ stderr=subprocess.DEVNULL,
649
+ timeout=5,
650
+ )
651
+
652
+ # Set up signal handlers for cleanup
653
+ def signal_handler(signum: int, frame: Any) -> None:
654
+ cleanup_container()
655
+ sys.exit(0)
656
+
657
+ signal.signal(signal.SIGTERM, signal_handler)
658
+ if sys.platform != "win32":
659
+ signal.signal(signal.SIGHUP, signal_handler)
660
+
661
+ # Find environment directory (current or parent with hud.lock.yaml)
662
+ env_dir = cwd
663
+ lock_path = env_dir / "hud.lock.yaml"
664
+
665
+ if not lock_path.exists():
666
+ # Try parent directory
667
+ if (cwd.parent / "hud.lock.yaml").exists():
668
+ env_dir = cwd.parent
669
+ lock_path = env_dir / "hud.lock.yaml"
571
670
  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
- )
671
+ hud_console.error("No hud.lock.yaml found")
672
+ hud_console.info("Run 'hud build' first to create an image")
673
+ raise typer.Exit(1)
674
+
675
+ # Load lock file to get image name
676
+ try:
677
+ with open(lock_path) as f:
678
+ lock_data = yaml.safe_load(f)
679
+
680
+ # Get image from new or legacy format
681
+ images = lock_data.get("images", {})
682
+ image_name = images.get("local") or lock_data.get("image")
683
+
684
+ if not image_name:
685
+ hud_console.error("No image reference found in hud.lock.yaml")
686
+ raise typer.Exit(1)
687
+
688
+ # Strip digest if present
689
+ if "@" in image_name:
690
+ image_name = image_name.split("@")[0]
691
+
692
+ # Extract debugging ports from lock file
693
+ debugging_ports = lock_data.get("environment", {}).get("debuggingPorts", [])
694
+ telemetry = lock_data.get("environment", {}).get("telemetry", {})
695
+
696
+ except Exception as e:
697
+ hud_console.error(f"Failed to read lock file: {e}")
698
+ raise typer.Exit(1) from e
699
+
700
+ # Generate unique container name
701
+ pid = str(os.getpid())[-6:]
702
+ base_name = image_name.replace(":", "-").replace("/", "-")
703
+ container_name = f"{base_name}-dev-{pid}"
704
+
705
+ # Register cleanup function with atexit
706
+ atexit.register(cleanup_container)
707
+
708
+ # Build docker run command with volume mounts and folder-mode envs
709
+ from .utils.docker import create_docker_run_command
710
+
711
+ base_args = [
712
+ "--rm", # Automatically remove container when it stops
713
+ "--name",
714
+ container_name,
715
+ "-e",
716
+ "PYTHONPATH=/app",
717
+ "-e",
718
+ "PYTHONUNBUFFERED=1",
719
+ "-e",
720
+ "HUD_DEV=1",
721
+ ]
722
+
723
+ # Add volume mounts for watch paths (hot-reload)
724
+ if watch_paths:
725
+ hud_console.info(f"Hot-reload enabled for: {', '.join(watch_paths)}")
726
+ for path in watch_paths:
727
+ # Resolve the local path
728
+ local_path = env_dir.absolute() / path
729
+ if local_path.exists():
730
+ # Mount to /app/<path> in container
731
+ container_path = f"/app/{path}"
732
+ base_args.extend(["-v", f"{local_path}:{container_path}:rw"])
588
733
  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,
734
+ hud_console.warning(f"Watch path not found: {path}")
735
+ else:
736
+ hud_console.info("No --watch paths specified, running without hot-reload")
737
+ hud_console.dim_info("Tip", "Use -w to enable hot-reload (e.g., -w tools -w env.py)")
738
+
739
+ # Add debugging port mappings if available
740
+ if debugging_ports:
741
+ hud_console.info(f"Exposing debugging ports: {', '.join(map(str, debugging_ports))}")
742
+ for port_num in debugging_ports:
743
+ base_args.extend(["-p", f"{port_num}:{port_num}"])
744
+ combined_args = [*base_args, *docker_args] if docker_args else base_args
745
+ docker_cmd = create_docker_run_command(
746
+ image_name,
747
+ docker_args=combined_args,
748
+ env_dir=env_dir,
749
+ )
750
+
751
+ # Create MCP config pointing to the Docker container's stdio
752
+ mcp_config = {
753
+ "docker": {
754
+ "command": docker_cmd[0],
755
+ "args": docker_cmd[1:],
756
+ }
757
+ }
758
+
759
+ # Attempt to create dynamic trace early (before any UI) if --new flag is set
760
+ import asyncio as _asy
761
+
762
+ from hud.cli.flows.dev import create_dynamic_trace, generate_cursor_deeplink, show_dev_ui
763
+
764
+ live_trace_url: str | None = None
765
+ if new_trace:
766
+ try:
767
+ local_mcp_config: dict[str, dict[str, Any]] = {
768
+ "hud": {
769
+ "url": f"http://localhost:{port}/mcp",
770
+ "headers": {},
771
+ }
772
+ }
773
+ _, live_trace_url = _asy.run(
774
+ create_dynamic_trace(
775
+ mcp_config=local_mcp_config,
776
+ build_status=True,
777
+ environment_name=image_name,
596
778
  )
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}")
779
+ )
780
+ except SystemExit:
781
+ raise # Let API key requirement exits through
782
+ except Exception: # noqa: S110
783
+ pass
784
+
785
+ # Show appropriate UI
786
+ if live_trace_url:
787
+ # Minimal UI with live trace
788
+ cursor_deeplink = generate_cursor_deeplink(image_name, port)
789
+ show_dev_ui(
790
+ live_trace_url=live_trace_url,
791
+ server_name=image_name,
792
+ port=port,
793
+ cursor_deeplink=cursor_deeplink,
794
+ is_docker=True,
795
+ )
796
+ else:
797
+ # Full UI
798
+ hud_console.header("HUD Development Mode (Docker)")
799
+ if verbose:
800
+ hud_console.section_title("Docker Command")
801
+ hud_console.info(" ".join(docker_cmd))
802
+ show_dev_server_info(
803
+ server_name=image_name,
804
+ port=port,
805
+ transport="http",
806
+ inspector=inspector,
807
+ interactive=interactive,
808
+ env_dir=env_dir,
809
+ docker_mode=True,
810
+ telemetry=telemetry,
811
+ )
812
+ hud_console.dim_info(
813
+ "",
814
+ "Container restarts on file changes in watched folders (-w), "
815
+ "rebuild with 'hud dev' if changing other files",
816
+ )
602
817
  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...")
610
818
 
611
- # Stop the container before showing next steps
612
- await stop_container()
819
+ # Suppress logs unless verbose
820
+ if not verbose:
821
+ logging.getLogger("fastmcp").setLevel(logging.ERROR)
822
+ logging.getLogger("mcp").setLevel(logging.ERROR)
823
+ logging.getLogger("uvicorn").setLevel(logging.ERROR)
824
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
613
825
 
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
- 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)
826
+ # Create and run proxy with HUD helpers
827
+ async def run_proxy() -> None:
828
+ from fastmcp import FastMCP
829
+ from fastmcp.server.proxy import ProxyClient
648
830
 
649
- # Always try to stop container on exit
650
- await stop_container()
831
+ # Create ProxyClient without custom log handler since we capture Docker logs directly
832
+ proxy_client = ProxyClient(mcp_config, name="HUD Docker Dev Proxy")
651
833
 
834
+ # Extract container name from docker args and store for logs endpoint
835
+ docker_cmd = mcp_config["docker"]["args"]
836
+ container_name = None
837
+ for i, arg in enumerate(docker_cmd):
838
+ if arg == "--name" and i + 1 < len(docker_cmd):
839
+ container_name = docker_cmd[i + 1]
840
+ break
652
841
 
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
842
+ if container_name:
843
+ # Store container name for logs endpoint to use
844
+ os.environ["_HUD_DEV_DOCKER_CONTAINER"] = container_name
845
+ hud_console.debug(f"Docker container: {container_name}")
685
846
 
686
- # No external dependencies needed for hot-reload anymore!
847
+ # Store the docker mcp_config for the eval endpoint
848
+ import json
687
849
 
688
- # Resolve image name
689
- resolved_image, source = get_image_name(directory, image)
850
+ os.environ["_HUD_DEV_DOCKER_MCP_CONFIG"] = json.dumps(mcp_config)
690
851
 
691
- # Update pyproject.toml with auto-generated name if needed
692
- if source == "auto":
693
- update_pyproject_toml(directory, resolved_image)
852
+ # Create FastMCP proxy using the ProxyClient
853
+ fastmcp_proxy = FastMCP.as_proxy(proxy_client)
694
854
 
695
- # Build if requested
696
- if build or no_cache:
697
- build_and_update(directory, resolved_image, no_cache)
855
+ # Wrap in MCPServer to get /docs and REST wrappers
856
+ proxy = MCPServer(name="HUD Docker Dev Proxy")
698
857
 
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
858
+ # Enable logs endpoint on HTTP server
859
+ os.environ["_HUD_DEV_LOGS_PROVIDER"] = "enabled"
705
860
 
706
- # Generate server name from image
707
- server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
861
+ # Import all tools from the FastMCP proxy
862
+ await proxy.import_server(fastmcp_proxy)
708
863
 
709
- # For HTTP transport, find available port first
710
- actual_port = port
711
- if transport == "http":
712
- from .utils.logging import find_free_port
864
+ # Launch inspector if requested
865
+ if inspector:
866
+ await launch_inspector(port)
867
+
868
+ # Launch interactive mode if requested
869
+ if interactive:
870
+ launch_interactive_thread(port, verbose)
871
+
872
+ # Run proxy with HTTP transport
873
+ await proxy.run_async(
874
+ transport="http",
875
+ host="0.0.0.0", # noqa: S104
876
+ port=port,
877
+ path="/mcp",
878
+ log_level="error" if not verbose else "info",
879
+ show_banner=False,
880
+ )
881
+
882
+ try:
883
+ asyncio.run(run_proxy())
884
+ except KeyboardInterrupt:
885
+ hud_console.info("\n\nStopping...")
886
+ cleanup_container()
887
+ raise typer.Exit(0) from None
888
+ except Exception:
889
+ # Ensure cleanup happens on any exception
890
+ cleanup_container()
891
+ raise
892
+ finally:
893
+ # Final cleanup attempt
894
+ cleanup_container()
895
+
896
+
897
+ def run_mcp_dev_server(
898
+ module: str | None,
899
+ stdio: bool,
900
+ port: int,
901
+ verbose: bool,
902
+ inspector: bool,
903
+ interactive: bool,
904
+ watch: list[str] | None,
905
+ docker: bool = False,
906
+ docker_args: list[str] | None = None,
907
+ new_trace: bool = False,
908
+ ) -> None:
909
+ """Run MCP development server with hot-reload."""
910
+ docker_args = docker_args or []
911
+ cwd = Path.cwd()
912
+
913
+ # Find an available port if not using stdio transport
914
+ if not stdio:
915
+ from hud.cli.utils.logging import find_free_port
713
916
 
714
917
  actual_port = find_free_port(port)
715
918
  if actual_port is None:
716
919
  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
920
+ raise typer.Exit(1)
731
921
 
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()
922
+ if actual_port != port:
923
+ hud_console.info(f"Port {port} is in use, using port {actual_port} instead")
735
924
 
736
- # Generate deeplink
737
- deeplink = (
738
- f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
739
- )
925
+ port = actual_port
740
926
 
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}")
927
+ # Auto-detect Docker mode if Dockerfile present and no module specified
928
+ if not docker and module is None and should_use_docker_mode(cwd):
929
+ hud_console.note("Detected Dockerfile - using Docker mode")
930
+ hud_console.dim_info("Tip", "Use 'hud dev --help' to see all options")
931
+ hud_console.info("")
932
+ run_docker_dev_server(port, verbose, inspector, interactive, docker_args, watch, new_trace)
933
+ return
755
934
 
756
- hud_console.progress_message(
757
- f"❗ If any issues arise, run `hud debug {resolved_image}` to debug the container"
758
- )
935
+ # Route to Docker mode if explicitly requested
936
+ if docker:
937
+ run_docker_dev_server(port, verbose, inspector, interactive, docker_args, watch, new_trace)
938
+ return
759
939
 
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
940
+ transport = "stdio" if stdio else "http"
779
941
 
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)
942
+ # Auto-detect module if not provided
943
+ if module is None:
944
+ module, extra_path = auto_detect_module()
945
+ if module is None:
946
+ hud_console.error("Could not auto-detect module in current directory")
947
+ hud_console.info("")
948
+ hud_console.info("[bold cyan]Expected:[/bold cyan]")
949
+ hud_console.info(" • __init__.py file in current directory")
950
+ hud_console.info(" Module must define 'mcp' or 'env' variable")
951
+ hud_console.info("")
952
+ hud_console.info("[bold cyan]Examples:[/bold cyan]")
953
+ hud_console.info(" hud dev controller")
954
+ hud_console.info(" cd controller && hud dev")
955
+ hud_console.info(" hud dev --docker # For Docker-based environments")
956
+ hud_console.info("")
957
+ import sys
802
958
 
803
- hud_console.info("") # Empty line
959
+ sys.exit(1)
804
960
 
805
- 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
- )
961
+ if verbose:
962
+ hud_console.info(f"Auto-detected: {module}")
963
+ if extra_path:
964
+ hud_console.info(f"Adding to sys.path: {extra_path}")
965
+
966
+ # Add extra path to sys.path if needed (for package imports)
967
+ if extra_path:
968
+ import sys
969
+
970
+ sys.path.insert(0, str(extra_path))
971
+ else:
972
+ extra_path = None
973
+
974
+ # Determine watch paths
975
+ watch_paths = watch if watch else ["."]
976
+
977
+ # Check if child process
978
+ is_child = os.environ.get("_HUD_DEV_CHILD") == "1"
979
+
980
+ if is_child:
981
+ asyncio.run(run_mcp_module(module, transport, port, verbose, False, False, new_trace))
982
+ else:
983
+ run_with_reload(
984
+ module, watch_paths, transport, port, verbose, inspector, interactive, new_trace
820
985
  )
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}")
826
- hud_console.info("")
827
- hud_console.info("This will help identify connection issues or initialization failures.")
828
- raise