hud-python 0.4.1__py3-none-any.whl โ†’ 0.4.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of hud-python might be problematic. Click here for more details.

Files changed (130) hide show
  1. hud/__init__.py +22 -22
  2. hud/agents/__init__.py +13 -15
  3. hud/agents/base.py +599 -599
  4. hud/agents/claude.py +373 -373
  5. hud/agents/langchain.py +261 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +82 -80
  8. hud/agents/openai.py +352 -352
  9. hud/agents/openai_chat_generic.py +154 -154
  10. hud/agents/tests/__init__.py +1 -1
  11. hud/agents/tests/test_base.py +742 -742
  12. hud/agents/tests/test_claude.py +324 -324
  13. hud/agents/tests/test_client.py +363 -363
  14. hud/agents/tests/test_openai.py +237 -237
  15. hud/cli/__init__.py +617 -617
  16. hud/cli/__main__.py +8 -8
  17. hud/cli/analyze.py +371 -371
  18. hud/cli/analyze_metadata.py +230 -230
  19. hud/cli/build.py +498 -427
  20. hud/cli/clone.py +185 -185
  21. hud/cli/cursor.py +92 -92
  22. hud/cli/debug.py +392 -392
  23. hud/cli/docker_utils.py +83 -83
  24. hud/cli/init.py +280 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +764 -756
  27. hud/cli/pull.py +330 -336
  28. hud/cli/push.py +404 -370
  29. hud/cli/remote_runner.py +311 -311
  30. hud/cli/runner.py +160 -160
  31. hud/cli/tests/__init__.py +3 -3
  32. hud/cli/tests/test_analyze.py +284 -284
  33. hud/cli/tests/test_cli_init.py +265 -265
  34. hud/cli/tests/test_cli_main.py +27 -27
  35. hud/cli/tests/test_clone.py +142 -142
  36. hud/cli/tests/test_cursor.py +253 -253
  37. hud/cli/tests/test_debug.py +453 -453
  38. hud/cli/tests/test_mcp_server.py +139 -139
  39. hud/cli/tests/test_utils.py +388 -388
  40. hud/cli/utils.py +263 -263
  41. hud/clients/README.md +143 -143
  42. hud/clients/__init__.py +16 -16
  43. hud/clients/base.py +378 -379
  44. hud/clients/fastmcp.py +222 -222
  45. hud/clients/mcp_use.py +298 -278
  46. hud/clients/tests/__init__.py +1 -1
  47. hud/clients/tests/test_client_integration.py +111 -111
  48. hud/clients/tests/test_fastmcp.py +342 -342
  49. hud/clients/tests/test_protocol.py +188 -188
  50. hud/clients/utils/__init__.py +1 -1
  51. hud/clients/utils/retry_transport.py +160 -160
  52. hud/datasets.py +327 -322
  53. hud/misc/__init__.py +1 -1
  54. hud/misc/claude_plays_pokemon.py +292 -292
  55. hud/otel/__init__.py +35 -35
  56. hud/otel/collector.py +142 -142
  57. hud/otel/config.py +164 -164
  58. hud/otel/context.py +536 -536
  59. hud/otel/exporters.py +366 -366
  60. hud/otel/instrumentation.py +97 -97
  61. hud/otel/processors.py +118 -118
  62. hud/otel/tests/__init__.py +1 -1
  63. hud/otel/tests/test_processors.py +197 -197
  64. hud/server/__init__.py +5 -5
  65. hud/server/context.py +114 -114
  66. hud/server/helper/__init__.py +5 -5
  67. hud/server/low_level.py +132 -132
  68. hud/server/server.py +170 -166
  69. hud/server/tests/__init__.py +3 -3
  70. hud/settings.py +73 -73
  71. hud/shared/__init__.py +5 -5
  72. hud/shared/exceptions.py +180 -180
  73. hud/shared/requests.py +264 -264
  74. hud/shared/tests/test_exceptions.py +157 -157
  75. hud/shared/tests/test_requests.py +275 -275
  76. hud/telemetry/__init__.py +25 -25
  77. hud/telemetry/instrument.py +379 -379
  78. hud/telemetry/job.py +309 -309
  79. hud/telemetry/replay.py +74 -74
  80. hud/telemetry/trace.py +83 -83
  81. hud/tools/__init__.py +33 -33
  82. hud/tools/base.py +365 -365
  83. hud/tools/bash.py +161 -161
  84. hud/tools/computer/__init__.py +15 -15
  85. hud/tools/computer/anthropic.py +437 -437
  86. hud/tools/computer/hud.py +376 -376
  87. hud/tools/computer/openai.py +295 -295
  88. hud/tools/computer/settings.py +82 -82
  89. hud/tools/edit.py +314 -314
  90. hud/tools/executors/__init__.py +30 -30
  91. hud/tools/executors/base.py +539 -539
  92. hud/tools/executors/pyautogui.py +621 -621
  93. hud/tools/executors/tests/__init__.py +1 -1
  94. hud/tools/executors/tests/test_base_executor.py +338 -338
  95. hud/tools/executors/tests/test_pyautogui_executor.py +165 -165
  96. hud/tools/executors/xdo.py +511 -511
  97. hud/tools/playwright.py +412 -412
  98. hud/tools/tests/__init__.py +3 -3
  99. hud/tools/tests/test_base.py +282 -282
  100. hud/tools/tests/test_bash.py +158 -158
  101. hud/tools/tests/test_bash_extended.py +197 -197
  102. hud/tools/tests/test_computer.py +425 -425
  103. hud/tools/tests/test_computer_actions.py +34 -34
  104. hud/tools/tests/test_edit.py +259 -259
  105. hud/tools/tests/test_init.py +27 -27
  106. hud/tools/tests/test_playwright_tool.py +183 -183
  107. hud/tools/tests/test_tools.py +145 -145
  108. hud/tools/tests/test_utils.py +156 -156
  109. hud/tools/types.py +72 -72
  110. hud/tools/utils.py +50 -50
  111. hud/types.py +136 -136
  112. hud/utils/__init__.py +10 -10
  113. hud/utils/async_utils.py +65 -65
  114. hud/utils/design.py +236 -168
  115. hud/utils/mcp.py +55 -55
  116. hud/utils/progress.py +149 -149
  117. hud/utils/telemetry.py +66 -66
  118. hud/utils/tests/test_async_utils.py +173 -173
  119. hud/utils/tests/test_init.py +17 -17
  120. hud/utils/tests/test_progress.py +261 -261
  121. hud/utils/tests/test_telemetry.py +82 -82
  122. hud/utils/tests/test_version.py +8 -8
  123. hud/version.py +7 -7
  124. {hud_python-0.4.1.dist-info โ†’ hud_python-0.4.3.dist-info}/METADATA +10 -8
  125. hud_python-0.4.3.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info โ†’ hud_python-0.4.3.dist-info}/licenses/LICENSE +21 -21
  127. hud/agents/art.py +0 -101
  128. hud_python-0.4.1.dist-info/RECORD +0 -132
  129. {hud_python-0.4.1.dist-info โ†’ hud_python-0.4.3.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info โ†’ hud_python-0.4.3.dist-info}/entry_points.txt +0 -0
hud/cli/mcp_server.py CHANGED
@@ -1,756 +1,764 @@
1
- """MCP Development Proxy - Hot-reload environments with MCP over HTTP."""
2
-
3
- from __future__ import annotations
4
-
5
- import asyncio
6
- import base64
7
- import json
8
- import subprocess
9
- from pathlib import Path
10
-
11
- import click
12
- import toml
13
- from fastmcp import FastMCP
14
-
15
- from .docker_utils import get_docker_cmd, image_exists, inject_supervisor
16
-
17
-
18
- def get_image_name(directory: str | Path, image_override: str | None = None) -> tuple[str, str]:
19
- """
20
- Resolve image name with source tracking.
21
-
22
- Returns:
23
- Tuple of (image_name, source) where source is "override", "cache", or "auto"
24
- """
25
- if image_override:
26
- return image_override, "override"
27
-
28
- # Check pyproject.toml
29
- pyproject_path = Path(directory) / "pyproject.toml"
30
- if pyproject_path.exists():
31
- try:
32
- with open(pyproject_path) as f:
33
- config = toml.load(f)
34
- if config.get("tool", {}).get("hud", {}).get("image"):
35
- return config["tool"]["hud"]["image"], "cache"
36
- except Exception:
37
- click.echo("Failed to load pyproject.toml", err=True)
38
-
39
- # Auto-generate with :dev tag
40
- dir_path = Path(directory).resolve() # Get absolute path first
41
- dir_name = dir_path.name
42
- if not dir_name or dir_name == ".":
43
- # If we're in root or have empty name, use parent directory
44
- dir_name = dir_path.parent.name
45
- clean_name = dir_name.replace("_", "-")
46
- return f"hud-{clean_name}:dev", "auto"
47
-
48
-
49
- def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
50
- """Update pyproject.toml with image name."""
51
- pyproject_path = Path(directory) / "pyproject.toml"
52
- if pyproject_path.exists():
53
- try:
54
- with open(pyproject_path) as f:
55
- config = toml.load(f)
56
-
57
- # Ensure [tool.hud] exists
58
- if "tool" not in config:
59
- config["tool"] = {}
60
- if "hud" not in config["tool"]:
61
- config["tool"]["hud"] = {}
62
-
63
- # Update image name
64
- config["tool"]["hud"]["image"] = image_name
65
-
66
- # Write back
67
- with open(pyproject_path, "w") as f:
68
- toml.dump(config, f)
69
-
70
- if not silent:
71
- click.echo(f"โœ… Updated pyproject.toml with image: {image_name}")
72
- except Exception as e:
73
- if not silent:
74
- click.echo(f"โš ๏ธ Could not update pyproject.toml: {e}")
75
-
76
-
77
- def build_and_update(directory: str | Path, image_name: str, no_cache: bool = False) -> None:
78
- """Build Docker image and update pyproject.toml."""
79
- from hud.utils.design import HUDDesign
80
-
81
- design = HUDDesign()
82
-
83
- build_cmd = ["docker", "build", "-t", image_name]
84
- if no_cache:
85
- build_cmd.append("--no-cache")
86
- build_cmd.append(str(directory))
87
-
88
- design.info(f"๐Ÿ”จ Building image: {image_name}{' (no cache)' if no_cache else ''}")
89
- design.info("") # Empty line before Docker output
90
-
91
- # Just run Docker build directly - it has its own nice live display
92
- result = subprocess.run(build_cmd) # noqa: S603
93
-
94
- if result.returncode == 0:
95
- design.info("") # Empty line after Docker output
96
- design.success(f"Build successful! Image: {image_name}")
97
- # Update pyproject.toml (silently since we already showed success)
98
- update_pyproject_toml(directory, image_name, silent=True)
99
- else:
100
- design.error("Build failed!")
101
- raise click.Abort
102
-
103
-
104
- def create_proxy_server(
105
- directory: str | Path,
106
- image_name: str,
107
- no_reload: bool = False,
108
- verbose: bool = False,
109
- docker_args: list[str] | None = None,
110
- interactive: bool = False,
111
- ) -> FastMCP:
112
- """Create an HTTP proxy server that forwards to Docker container with hot-reload."""
113
- src_path = Path(directory) / "src"
114
-
115
- # Get the original CMD from the image
116
- original_cmd = get_docker_cmd(image_name)
117
- if not original_cmd:
118
- click.echo(f"โš ๏ธ Could not extract CMD from {image_name}, using default")
119
- original_cmd = ["python", "-m", "hud_controller.server"]
120
-
121
- # Generate container name from image
122
- container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
123
-
124
- # Build the docker run command
125
- docker_cmd = [
126
- "docker",
127
- "run",
128
- "--rm",
129
- "-i",
130
- "--name",
131
- container_name,
132
- "-v",
133
- f"{src_path.absolute()}:/app/src:rw",
134
- "-e",
135
- "PYTHONPATH=/app/src",
136
- ]
137
-
138
- # Add user-provided Docker arguments
139
- if docker_args:
140
- docker_cmd.extend(docker_args)
141
-
142
- # Disable hot-reload if interactive mode is enabled
143
- if interactive:
144
- no_reload = True
145
-
146
- if not no_reload:
147
- # Inject our supervisor into the CMD
148
- modified_cmd = inject_supervisor(original_cmd)
149
- docker_cmd.extend(["--entrypoint", modified_cmd[0]])
150
- docker_cmd.append(image_name)
151
- docker_cmd.extend(modified_cmd[1:])
152
- else:
153
- # No reload - use original CMD
154
- docker_cmd.append(image_name)
155
-
156
- # Create configuration following MCPConfig schema
157
- config = {
158
- "mcpServers": {
159
- "default": {
160
- "command": docker_cmd[0],
161
- "args": docker_cmd[1:] if len(docker_cmd) > 1 else [],
162
- # transport defaults to stdio
163
- }
164
- }
165
- }
166
-
167
- # Debug output - only if verbose
168
- if verbose:
169
- if not no_reload:
170
- click.echo("๐Ÿ“ Watching: /app/src for changes", err=True)
171
- else:
172
- click.echo("๐Ÿ”ง Container will run without hot-reload", err=True)
173
- click.echo(f"๐Ÿ“Š docker logs -f {container_name}", err=True)
174
-
175
- # Create the HTTP proxy server using config
176
- proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
177
-
178
- return proxy
179
-
180
-
181
- async def start_mcp_proxy(
182
- directory: str | Path,
183
- image_name: str,
184
- transport: str,
185
- port: int,
186
- no_reload: bool = False,
187
- verbose: bool = False,
188
- inspector: bool = False,
189
- no_logs: bool = False,
190
- interactive: bool = False,
191
- docker_args: list[str] | None = None,
192
- ) -> None:
193
- """Start the MCP development proxy server."""
194
- # Suppress FastMCP's verbose output FIRST
195
- import asyncio
196
- import logging
197
- import os
198
- import subprocess
199
- import sys
200
-
201
- from .utils import find_free_port
202
-
203
- # Always disable the banner - we have our own output
204
- os.environ["FASTMCP_DISABLE_BANNER"] = "1"
205
-
206
- # Configure logging BEFORE creating proxy
207
- if not verbose:
208
- # Create a filter to block the specific "Starting MCP server" message
209
- class _BlockStartingMCPFilter(logging.Filter):
210
- def filter(self, record: logging.LogRecord) -> bool:
211
- return "Starting MCP server" not in record.getMessage()
212
-
213
- # Set environment variable for FastMCP logging
214
- os.environ["FASTMCP_LOG_LEVEL"] = "ERROR"
215
- os.environ["LOG_LEVEL"] = "ERROR"
216
- os.environ["UVICORN_LOG_LEVEL"] = "ERROR"
217
- # Suppress uvicorn's annoying shutdown messages
218
- os.environ["UVICORN_ACCESS_LOG"] = "0"
219
-
220
- # Configure logging to suppress INFO
221
- logging.basicConfig(level=logging.ERROR, force=True)
222
-
223
- # Set root logger to ERROR to suppress all INFO messages
224
- root_logger = logging.getLogger()
225
- root_logger.setLevel(logging.ERROR)
226
-
227
- # Add filter to all handlers
228
- block_filter = _BlockStartingMCPFilter()
229
- for handler in root_logger.handlers:
230
- handler.addFilter(block_filter)
231
-
232
- # Also specifically suppress these loggers
233
- for logger_name in [
234
- "fastmcp",
235
- "fastmcp.server",
236
- "fastmcp.server.server",
237
- "FastMCP",
238
- "FastMCP.fastmcp.server.server",
239
- "mcp",
240
- "mcp.server",
241
- "mcp.server.lowlevel",
242
- "mcp.server.lowlevel.server",
243
- "uvicorn",
244
- "uvicorn.access",
245
- "uvicorn.error",
246
- "hud.server",
247
- "hud.server.server",
248
- ]:
249
- logger = logging.getLogger(logger_name)
250
- logger.setLevel(logging.ERROR)
251
- # Add filter to this logger too
252
- logger.addFilter(block_filter)
253
-
254
- # Suppress deprecation warnings
255
- import warnings
256
-
257
- warnings.filterwarnings("ignore", category=DeprecationWarning)
258
-
259
- # CRITICAL: For stdio transport, ALL output must go to stderr
260
- if transport == "stdio":
261
- # Configure root logger to use stderr
262
- root_logger = logging.getLogger()
263
- root_logger.handlers.clear()
264
- stderr_handler = logging.StreamHandler(sys.stderr)
265
- root_logger.addHandler(stderr_handler)
266
-
267
- # Now check for src directory
268
- src_path = Path(directory) / "src"
269
- if not src_path.exists():
270
- click.echo(f"โŒ Source directory not found: {src_path}", err=(transport == "stdio"))
271
- raise click.Abort
272
-
273
- # Extract container name from the proxy configuration
274
- container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
275
-
276
- # Remove any existing container with the same name (silently)
277
- # Note: The proxy creates containers on-demand when clients connect
278
- try:
279
- subprocess.run( # noqa: S603, ASYNC221
280
- ["docker", "rm", "-f", container_name], # noqa: S607
281
- stdout=subprocess.DEVNULL,
282
- stderr=subprocess.DEVNULL,
283
- check=False, # Don't raise error if container doesn't exist
284
- )
285
- except Exception:
286
- click.echo(f"Failed to remove existing container {container_name}", err=True)
287
-
288
- if transport == "stdio":
289
- if verbose:
290
- click.echo("๐Ÿ”Œ Starting stdio proxy (each connection gets its own container)", err=True)
291
- else:
292
- # Find available port for HTTP
293
- actual_port = find_free_port(port)
294
- if actual_port is None:
295
- click.echo(f"โŒ No available ports found starting from {port}")
296
- raise click.Abort
297
-
298
- if actual_port != port and verbose:
299
- click.echo(f"โš ๏ธ Port {port} in use, using port {actual_port} instead")
300
-
301
- # Launch MCP Inspector if requested
302
- if inspector:
303
- server_url = f"http://localhost:{actual_port}/mcp"
304
-
305
- # Function to launch inspector in background
306
- async def launch_inspector() -> None:
307
- """Launch MCP Inspector and capture its output to extract the URL."""
308
- # Wait for server to be ready
309
- await asyncio.sleep(3)
310
-
311
- try:
312
- import platform
313
- import urllib.parse
314
-
315
- # Build the direct URL with query params to auto-connect
316
- encoded_url = urllib.parse.quote(server_url)
317
- inspector_url = (
318
- f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
319
- )
320
-
321
- # Print inspector info cleanly
322
- from hud.utils.design import HUDDesign
323
-
324
- inspector_design = HUDDesign(stderr=(transport == "stdio"))
325
- inspector_design.section_title("MCP Inspector")
326
- inspector_design.link(inspector_url)
327
-
328
- # Set environment to disable auth (for development only)
329
- env = os.environ.copy()
330
- env["DANGEROUSLY_OMIT_AUTH"] = "true"
331
- env["MCP_AUTO_OPEN_ENABLED"] = "true"
332
-
333
- # Launch inspector
334
- cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
335
-
336
- # Run in background, suppressing output to avoid log interference
337
- if platform.system() == "Windows":
338
- subprocess.Popen( # noqa: S602, ASYNC220
339
- cmd,
340
- env=env,
341
- shell=True,
342
- stdout=subprocess.DEVNULL,
343
- stderr=subprocess.DEVNULL,
344
- )
345
- else:
346
- subprocess.Popen( # noqa: S603, ASYNC220
347
- cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
348
- )
349
-
350
- except (FileNotFoundError, Exception):
351
- # Silently fail - inspector is optional
352
- click.echo("Failed to launch inspector", err=True)
353
-
354
- # Launch inspector asynchronously so it doesn't block
355
- result = asyncio.create_task(launch_inspector())
356
- if result.exception():
357
- click.echo("Failed to launch inspector", err=True)
358
-
359
- # Launch interactive mode if requested
360
- if interactive:
361
- if transport != "http":
362
- from hud.utils.design import HUDDesign
363
-
364
- interactive_design = HUDDesign(stderr=True)
365
- interactive_design.warning("Interactive mode only works with HTTP transport")
366
- else:
367
- server_url = f"http://localhost:{actual_port}/mcp"
368
-
369
- # Function to launch interactive mode in a separate thread
370
- def launch_interactive_thread() -> None:
371
- """Launch interactive testing mode in a separate thread."""
372
- import time
373
-
374
- # Wait for server to be ready
375
- time.sleep(3)
376
-
377
- try:
378
- from hud.utils.design import HUDDesign
379
-
380
- interactive_design = HUDDesign(stderr=(transport == "stdio"))
381
- interactive_design.section_title("Interactive Mode")
382
- interactive_design.info("Starting interactive testing mode...")
383
- interactive_design.info("Press Ctrl+C in the interactive session to exit")
384
-
385
- # Import and run interactive mode in a new event loop
386
- from .interactive import run_interactive_mode
387
-
388
- # Create a new event loop for the thread
389
- loop = asyncio.new_event_loop()
390
- asyncio.set_event_loop(loop)
391
- try:
392
- loop.run_until_complete(run_interactive_mode(server_url, verbose))
393
- finally:
394
- loop.close()
395
-
396
- except Exception as e:
397
- # Log error but don't crash the server
398
- if verbose:
399
- click.echo(f"Interactive mode error: {e}", err=True)
400
-
401
- # Launch interactive mode in a separate thread
402
- import threading
403
-
404
- interactive_thread = threading.Thread(target=launch_interactive_thread, daemon=True)
405
- interactive_thread.start()
406
-
407
- # Function to stream Docker logs
408
- async def stream_docker_logs() -> None:
409
- """Stream Docker container logs asynchronously."""
410
- # Import design system for consistent output
411
- from hud.utils.design import HUDDesign
412
-
413
- log_design = HUDDesign(stderr=(transport == "stdio"))
414
-
415
- # Always show waiting message
416
- log_design.info("") # Empty line for spacing
417
- log_design.progress_message("โณ Waiting for first client connection to start container...")
418
-
419
- # Keep trying to stream logs - container is created on demand
420
- has_shown_started = False
421
- while True:
422
- # Check if container exists first (silently)
423
- check_result = await asyncio.create_subprocess_exec(
424
- "docker",
425
- "ps",
426
- "--format",
427
- "{{.Names}}",
428
- "--filter",
429
- f"name={container_name}",
430
- stdout=asyncio.subprocess.PIPE,
431
- stderr=asyncio.subprocess.DEVNULL,
432
- )
433
- stdout, _ = await check_result.communicate()
434
-
435
- # If container doesn't exist, wait and retry
436
- if container_name not in stdout.decode():
437
- await asyncio.sleep(1)
438
- continue
439
-
440
- # Container exists! Show success if first time
441
- if not has_shown_started:
442
- log_design.success("Container started! Streaming logs...")
443
- has_shown_started = True
444
-
445
- # Now stream the logs
446
- try:
447
- process = await asyncio.create_subprocess_exec(
448
- "docker",
449
- "logs",
450
- "-f",
451
- container_name,
452
- stdout=asyncio.subprocess.PIPE,
453
- stderr=asyncio.subprocess.STDOUT, # Combine streams for simplicity
454
- )
455
-
456
- if process.stdout:
457
- async for line in process.stdout:
458
- decoded_line = line.decode().rstrip()
459
- if not decoded_line: # Skip empty lines
460
- continue
461
-
462
- # Skip docker daemon errors (these happen when container is removed)
463
- if "Error response from daemon" in decoded_line:
464
- continue
465
-
466
- # Show all logs with gold formatting like hud debug
467
- # Format all logs in gold/dim style like hud debug's stderr
468
- log_design.console.print(
469
- f"[rgb(192,150,12)]โ– [/rgb(192,150,12)] {decoded_line}", highlight=False
470
- )
471
-
472
- # Process ended - container might have been removed
473
- await process.wait()
474
-
475
- # Check if container still exists
476
- await asyncio.sleep(1)
477
- continue # Loop back to check if container exists
478
-
479
- except Exception:
480
- # Some unexpected error
481
- if verbose:
482
- log_design.warning("Failed to stream logs")
483
- await asyncio.sleep(1)
484
-
485
- # CRITICAL: Create proxy AFTER all logging setup to prevent it from resetting logging config
486
- # This is important because FastMCP might initialize loggers during creation
487
- proxy = create_proxy_server(
488
- directory, image_name, no_reload, verbose, docker_args or [], interactive
489
- )
490
-
491
- # One more attempt to suppress the FastMCP server log
492
- if not verbose:
493
- # Re-apply the filter in case new handlers were created
494
- class BlockStartingMCPFilter(logging.Filter):
495
- def filter(self, record: logging.LogRecord) -> bool:
496
- return "Starting MCP server" not in record.getMessage()
497
-
498
- block_filter = BlockStartingMCPFilter()
499
-
500
- # Apply to all loggers again - comprehensive list
501
- for logger_name in [
502
- "", # root logger
503
- "fastmcp",
504
- "fastmcp.server",
505
- "fastmcp.server.server",
506
- "FastMCP",
507
- "FastMCP.fastmcp.server.server",
508
- "mcp",
509
- "mcp.server",
510
- "mcp.server.lowlevel",
511
- "mcp.server.lowlevel.server",
512
- "uvicorn",
513
- "uvicorn.access",
514
- "uvicorn.error",
515
- "hud.server",
516
- "hud.server.server",
517
- ]:
518
- logger = logging.getLogger(logger_name)
519
- logger.setLevel(logging.ERROR)
520
- logger.addFilter(block_filter)
521
- for handler in logger.handlers:
522
- handler.addFilter(block_filter)
523
-
524
- try:
525
- # Start Docker logs streaming if enabled
526
- log_task = None
527
- if not no_logs:
528
- log_task = asyncio.create_task(stream_docker_logs())
529
-
530
- if transport == "stdio":
531
- # Run with stdio transport
532
- await proxy.run_async(
533
- transport="stdio", log_level="ERROR" if not verbose else "INFO", show_banner=False
534
- )
535
- else:
536
- # Run with HTTP transport
537
- # Temporarily redirect stderr to suppress uvicorn shutdown messages
538
- import contextlib
539
- import io
540
-
541
- if not verbose:
542
- # Create a dummy file to swallow unwanted stderr output
543
- with contextlib.redirect_stderr(io.StringIO()):
544
- await proxy.run_async(
545
- transport="http",
546
- host="0.0.0.0", # noqa: S104
547
- port=actual_port,
548
- path="/mcp", # Serve at /mcp endpoint
549
- log_level="ERROR",
550
- show_banner=False,
551
- )
552
- else:
553
- await proxy.run_async(
554
- transport="http",
555
- host="0.0.0.0", # noqa: S104
556
- port=actual_port,
557
- path="/mcp", # Serve at /mcp endpoint
558
- log_level="INFO",
559
- show_banner=False,
560
- )
561
- except KeyboardInterrupt:
562
- from hud.utils.design import HUDDesign
563
-
564
- shutdown_design = HUDDesign(stderr=(transport == "stdio"))
565
- shutdown_design.info("\n๐Ÿ‘‹ Shutting down...")
566
-
567
- # Show next steps tutorial
568
- if not interactive: # Only show if not in interactive mode
569
- shutdown_design.section_title("Next Steps")
570
- shutdown_design.info("๐Ÿ—๏ธ Ready to test with real agents? Run:")
571
- shutdown_design.info(f" [cyan]hud build {directory}[/cyan]")
572
- shutdown_design.info("")
573
- shutdown_design.info("This will:")
574
- shutdown_design.info(" 1. Build your environment image")
575
- shutdown_design.info(" 2. Generate a hud.lock.yaml file")
576
- shutdown_design.info(" 3. Prepare it for testing with agents")
577
- shutdown_design.info("")
578
- shutdown_design.info("Then you can:")
579
- shutdown_design.info(" โ€ข Test locally: [cyan]hud run <image>[/cyan]")
580
- shutdown_design.info(
581
- " โ€ข Push to registry: [cyan]hud push --image <registry/name>[/cyan]"
582
- )
583
- except Exception as e:
584
- # Suppress the graceful shutdown error and other FastMCP/uvicorn internal errors
585
- error_msg = str(e)
586
- if not any(
587
- x in error_msg
588
- for x in [
589
- "timeout graceful shutdown exceeded",
590
- "Cancel 0 running task(s)",
591
- "Application shutdown complete",
592
- ]
593
- ):
594
- from hud.utils.design import HUDDesign
595
-
596
- shutdown_design = HUDDesign(stderr=(transport == "stdio"))
597
- shutdown_design.error(f"Unexpected error: {e}")
598
- finally:
599
- # Cancel log streaming task if it exists
600
- if log_task and not log_task.done():
601
- log_task.cancel()
602
- try:
603
- await log_task
604
- except asyncio.CancelledError:
605
- click.echo("Log streaming task cancelled", err=True)
606
-
607
-
608
- def run_mcp_dev_server(
609
- directory: str = ".",
610
- image: str | None = None,
611
- build: bool = False,
612
- no_cache: bool = False,
613
- transport: str = "http",
614
- port: int = 8765,
615
- no_reload: bool = False,
616
- verbose: bool = False,
617
- inspector: bool = False,
618
- no_logs: bool = False,
619
- interactive: bool = False,
620
- docker_args: list[str] | None = None,
621
- ) -> None:
622
- """Run MCP development server with hot-reload.
623
-
624
- This command starts a development proxy that:
625
- - Auto-detects or builds Docker images
626
- - Mounts local source code for hot-reload
627
- - Exposes an HTTP endpoint for MCP clients
628
-
629
- Examples:
630
- hud dev . # Auto-detect image from directory
631
- hud dev . --build # Build image first
632
- hud dev . --image custom:tag # Use specific image
633
- hud dev . --no-cache # Force clean rebuild
634
- """
635
- from hud.utils.design import HUDDesign
636
-
637
- design = HUDDesign(stderr=(transport == "stdio"))
638
-
639
- # Ensure directory exists
640
- if not Path(directory).exists():
641
- design.error(f"Directory not found: {directory}")
642
- raise click.Abort
643
-
644
- # No external dependencies needed for hot-reload anymore!
645
-
646
- # Resolve image name
647
- resolved_image, source = get_image_name(directory, image)
648
-
649
- # Update pyproject.toml with auto-generated name if needed
650
- if source == "auto":
651
- update_pyproject_toml(directory, resolved_image)
652
-
653
- # Build if requested
654
- if build or no_cache:
655
- build_and_update(directory, resolved_image, no_cache)
656
-
657
- # Check if image exists
658
- if not image_exists(resolved_image) and not build:
659
- if click.confirm(f"Image {resolved_image} not found. Build it now?"):
660
- build_and_update(directory, resolved_image)
661
- else:
662
- raise click.Abort
663
-
664
- # Generate server name from image
665
- server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
666
-
667
- # For HTTP transport, find available port first
668
- actual_port = port
669
- if transport == "http":
670
- from .utils import find_free_port
671
-
672
- actual_port = find_free_port(port)
673
- if actual_port is None:
674
- design.error(f"No available ports found starting from {port}")
675
- raise click.Abort
676
- if actual_port != port and verbose:
677
- design.warning(f"Port {port} in use, using port {actual_port}")
678
-
679
- # Create config
680
- if transport == "stdio":
681
- server_config = {"command": "hud", "args": ["dev", directory, "--transport", "stdio"]}
682
- else:
683
- server_config = {"url": f"http://localhost:{actual_port}/mcp"}
684
-
685
- # For the deeplink, we only need the server config
686
- server_config_json = json.dumps(server_config, indent=2)
687
- config_base64 = base64.b64encode(server_config_json.encode()).decode()
688
-
689
- # Generate deeplink
690
- deeplink = (
691
- f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
692
- )
693
-
694
- # Show header with gold border
695
- design.info("") # Empty line before header
696
- design.header("HUD Development Server")
697
-
698
- # Always show the Docker image being used as the first thing after header
699
- design.section_title("Docker Image")
700
- if source == "cache":
701
- design.info(f"๐Ÿ“ฆ {resolved_image}")
702
- elif source == "auto":
703
- design.info(f"๐Ÿ”ง {resolved_image} (auto-generated)")
704
- elif source == "override":
705
- design.info(f"๐ŸŽฏ {resolved_image} (specified)")
706
- else:
707
- design.info(f"๐Ÿณ {resolved_image}")
708
-
709
- # Show hints about inspector and interactive mode
710
- if transport == "http":
711
- if not inspector and not interactive:
712
- design.progress_message("๐Ÿ’ก Run with --inspector to launch MCP Inspector")
713
- design.progress_message("๐Ÿงช Run with --interactive for interactive testing mode")
714
- elif not inspector:
715
- design.progress_message("๐Ÿ’ก Run with --inspector to launch MCP Inspector")
716
- elif not interactive:
717
- design.progress_message("๐Ÿงช Run with --interactive for interactive testing mode")
718
-
719
- # Disable logs and hot-reload if interactive mode is enabled
720
- if interactive:
721
- if not no_logs:
722
- design.warning("Docker logs disabled in interactive mode for better UI experience")
723
- no_logs = True
724
- if not no_reload:
725
- design.warning("Hot-reload disabled in interactive mode to prevent output interference")
726
- no_reload = True
727
-
728
- # Show configuration as JSON (just the server config, not wrapped)
729
- full_config = {}
730
- full_config[server_name] = server_config
731
-
732
- design.section_title("MCP Configuration (add this to any agent/client)")
733
- design.json_config(json.dumps(full_config, indent=2))
734
-
735
- # Show connection info
736
- design.section_title(
737
- "Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
738
- )
739
- design.link(deeplink)
740
- design.info("") # Empty line
741
-
742
- # Start the proxy (pass original port, start_mcp_proxy will find actual port again)
743
- asyncio.run(
744
- start_mcp_proxy(
745
- directory,
746
- resolved_image,
747
- transport,
748
- port,
749
- no_reload,
750
- verbose,
751
- inspector,
752
- no_logs,
753
- interactive,
754
- docker_args or [],
755
- )
756
- )
1
+ """MCP Development Proxy - Hot-reload environments with MCP over HTTP."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import base64
7
+ import json
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ import click
12
+ import toml
13
+ from fastmcp import FastMCP
14
+
15
+ from hud.utils.design import HUDDesign
16
+ from .docker_utils import get_docker_cmd, image_exists, inject_supervisor
17
+
18
+ # Global design instance
19
+ design = HUDDesign()
20
+
21
+
22
+ def get_image_name(directory: str | Path, image_override: str | None = None) -> tuple[str, str]:
23
+ """
24
+ Resolve image name with source tracking.
25
+
26
+ Returns:
27
+ Tuple of (image_name, source) where source is "override", "cache", or "auto"
28
+ """
29
+ if image_override:
30
+ return image_override, "override"
31
+
32
+ # Check pyproject.toml
33
+ pyproject_path = Path(directory) / "pyproject.toml"
34
+ if pyproject_path.exists():
35
+ try:
36
+ with open(pyproject_path) as f:
37
+ config = toml.load(f)
38
+ if config.get("tool", {}).get("hud", {}).get("image"):
39
+ return config["tool"]["hud"]["image"], "cache"
40
+ except Exception:
41
+ pass # Silent failure, will use auto-generated name
42
+
43
+ # Auto-generate with :dev tag
44
+ dir_path = Path(directory).resolve() # Get absolute path first
45
+ dir_name = dir_path.name
46
+ if not dir_name or dir_name == ".":
47
+ # If we're in root or have empty name, use parent directory
48
+ dir_name = dir_path.parent.name
49
+ clean_name = dir_name.replace("_", "-")
50
+ return f"hud-{clean_name}:dev", "auto"
51
+
52
+
53
+ def update_pyproject_toml(directory: str | Path, image_name: str, silent: bool = False) -> None:
54
+ """Update pyproject.toml with image name."""
55
+ pyproject_path = Path(directory) / "pyproject.toml"
56
+ if pyproject_path.exists():
57
+ try:
58
+ with open(pyproject_path) as f:
59
+ config = toml.load(f)
60
+
61
+ # Ensure [tool.hud] exists
62
+ if "tool" not in config:
63
+ config["tool"] = {}
64
+ if "hud" not in config["tool"]:
65
+ config["tool"]["hud"] = {}
66
+
67
+ # Update image name
68
+ config["tool"]["hud"]["image"] = image_name
69
+
70
+ # Write back
71
+ with open(pyproject_path, "w") as f:
72
+ toml.dump(config, f)
73
+
74
+ if not silent:
75
+ design.success(f"Updated pyproject.toml with image: {image_name}")
76
+ except Exception as e:
77
+ if not silent:
78
+ design.warning(f"Could not update pyproject.toml: {e}")
79
+
80
+
81
+ def build_and_update(directory: str | Path, image_name: str, no_cache: bool = False) -> None:
82
+ """Build Docker image and update pyproject.toml."""
83
+
84
+ build_cmd = ["docker", "build", "-t", image_name]
85
+ if no_cache:
86
+ build_cmd.append("--no-cache")
87
+ build_cmd.append(str(directory))
88
+
89
+ design.info(f"๐Ÿ”จ Building image: {image_name}{' (no cache)' if no_cache else ''}")
90
+ design.info("") # Empty line before Docker output
91
+
92
+ # Just run Docker build directly - it has its own nice live display
93
+ result = subprocess.run(build_cmd) # noqa: S603
94
+
95
+ if result.returncode == 0:
96
+ design.info("") # Empty line after Docker output
97
+ design.success(f"Build successful! Image: {image_name}")
98
+ # Update pyproject.toml (silently since we already showed success)
99
+ update_pyproject_toml(directory, image_name, silent=True)
100
+ else:
101
+ design.error("Build failed!")
102
+ raise click.Abort
103
+
104
+
105
+ def create_proxy_server(
106
+ directory: str | Path,
107
+ image_name: str,
108
+ no_reload: bool = False,
109
+ verbose: bool = False,
110
+ docker_args: list[str] | None = None,
111
+ interactive: bool = False,
112
+ ) -> FastMCP:
113
+ """Create an HTTP proxy server that forwards to Docker container with hot-reload."""
114
+ src_path = Path(directory) / "src"
115
+
116
+ # Get the original CMD from the image
117
+ original_cmd = get_docker_cmd(image_name)
118
+ if not original_cmd:
119
+ design.warning(f"Could not extract CMD from {image_name}, using default")
120
+ original_cmd = ["python", "-m", "hud_controller.server"]
121
+
122
+ # Generate container name from image
123
+ container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
124
+
125
+ # Build the docker run command
126
+ docker_cmd = [
127
+ "docker",
128
+ "run",
129
+ "--rm",
130
+ "-i",
131
+ "--name",
132
+ container_name,
133
+ "-v",
134
+ f"{src_path.absolute()}:/app/src:rw",
135
+ "-e",
136
+ "PYTHONPATH=/app/src",
137
+ ]
138
+
139
+ # Add user-provided Docker arguments
140
+ if docker_args:
141
+ docker_cmd.extend(docker_args)
142
+
143
+ # Disable hot-reload if interactive mode is enabled
144
+ if interactive:
145
+ no_reload = True
146
+
147
+ if not no_reload:
148
+ # Inject our supervisor into the CMD
149
+ modified_cmd = inject_supervisor(original_cmd)
150
+ docker_cmd.extend(["--entrypoint", modified_cmd[0]])
151
+ docker_cmd.append(image_name)
152
+ docker_cmd.extend(modified_cmd[1:])
153
+ else:
154
+ # No reload - use original CMD
155
+ docker_cmd.append(image_name)
156
+
157
+ # Create configuration following MCPConfig schema
158
+ config = {
159
+ "mcpServers": {
160
+ "default": {
161
+ "command": docker_cmd[0],
162
+ "args": docker_cmd[1:] if len(docker_cmd) > 1 else [],
163
+ # transport defaults to stdio
164
+ }
165
+ }
166
+ }
167
+
168
+ # Debug output - only if verbose
169
+ if verbose:
170
+ if not no_reload:
171
+ design.info("Watching: /app/src for changes")
172
+ else:
173
+ design.info("Container will run without hot-reload")
174
+ design.command_example(f"docker logs -f {container_name}", "View container logs")
175
+
176
+ # Create the HTTP proxy server using config
177
+ try:
178
+ proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
179
+ except Exception as e:
180
+ design.error(f"Failed to create proxy server: {e}")
181
+ design.info("")
182
+ design.info("๐Ÿ’ก Tip: Run the following command to debug the container:")
183
+ design.info(f" hud debug {image_name}")
184
+ raise
185
+
186
+ return proxy
187
+
188
+
189
+ async def start_mcp_proxy(
190
+ directory: str | Path,
191
+ image_name: str,
192
+ transport: str,
193
+ port: int,
194
+ no_reload: bool = False,
195
+ verbose: bool = False,
196
+ inspector: bool = False,
197
+ no_logs: bool = False,
198
+ interactive: bool = False,
199
+ docker_args: list[str] | None = None,
200
+ ) -> None:
201
+ """Start the MCP development proxy server."""
202
+ # Suppress FastMCP's verbose output FIRST
203
+ import asyncio
204
+ import logging
205
+ import os
206
+ import subprocess
207
+ import sys
208
+
209
+ from .utils import find_free_port
210
+
211
+ # Always disable the banner - we have our own output
212
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
213
+
214
+ # Configure logging BEFORE creating proxy
215
+ if not verbose:
216
+ # Create a filter to block the specific "Starting MCP server" message
217
+ class _BlockStartingMCPFilter(logging.Filter):
218
+ def filter(self, record: logging.LogRecord) -> bool:
219
+ return "Starting MCP server" not in record.getMessage()
220
+
221
+ # Set environment variable for FastMCP logging
222
+ os.environ["FASTMCP_LOG_LEVEL"] = "ERROR"
223
+ os.environ["LOG_LEVEL"] = "ERROR"
224
+ os.environ["UVICORN_LOG_LEVEL"] = "ERROR"
225
+ # Suppress uvicorn's annoying shutdown messages
226
+ os.environ["UVICORN_ACCESS_LOG"] = "0"
227
+
228
+ # Configure logging to suppress INFO
229
+ logging.basicConfig(level=logging.ERROR, force=True)
230
+
231
+ # Set root logger to ERROR to suppress all INFO messages
232
+ root_logger = logging.getLogger()
233
+ root_logger.setLevel(logging.ERROR)
234
+
235
+ # Add filter to all handlers
236
+ block_filter = _BlockStartingMCPFilter()
237
+ for handler in root_logger.handlers:
238
+ handler.addFilter(block_filter)
239
+
240
+ # Also specifically suppress these loggers
241
+ for logger_name in [
242
+ "fastmcp",
243
+ "fastmcp.server",
244
+ "fastmcp.server.server",
245
+ "FastMCP",
246
+ "FastMCP.fastmcp.server.server",
247
+ "mcp",
248
+ "mcp.server",
249
+ "mcp.server.lowlevel",
250
+ "mcp.server.lowlevel.server",
251
+ "uvicorn",
252
+ "uvicorn.access",
253
+ "uvicorn.error",
254
+ "hud.server",
255
+ "hud.server.server",
256
+ ]:
257
+ logger = logging.getLogger(logger_name)
258
+ logger.setLevel(logging.ERROR)
259
+ # Add filter to this logger too
260
+ logger.addFilter(block_filter)
261
+
262
+ # Suppress deprecation warnings
263
+ import warnings
264
+
265
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
266
+
267
+ # CRITICAL: For stdio transport, ALL output must go to stderr
268
+ if transport == "stdio":
269
+ # Configure root logger to use stderr
270
+ root_logger = logging.getLogger()
271
+ root_logger.handlers.clear()
272
+ stderr_handler = logging.StreamHandler(sys.stderr)
273
+ root_logger.addHandler(stderr_handler)
274
+
275
+ # Now check for src directory
276
+ src_path = Path(directory) / "src"
277
+ if not src_path.exists():
278
+ design.error(f"Source directory not found: {src_path}")
279
+ raise click.Abort
280
+
281
+ # Extract container name from the proxy configuration
282
+ container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
283
+
284
+ # Remove any existing container with the same name (silently)
285
+ # Note: The proxy creates containers on-demand when clients connect
286
+ try:
287
+ subprocess.run( # noqa: S603, ASYNC221
288
+ ["docker", "rm", "-f", container_name], # noqa: S607
289
+ stdout=subprocess.DEVNULL,
290
+ stderr=subprocess.DEVNULL,
291
+ check=False, # Don't raise error if container doesn't exist
292
+ )
293
+ except Exception:
294
+ pass # Silent failure, container might not exist
295
+
296
+ if transport == "stdio":
297
+ if verbose:
298
+ design.info("Starting stdio proxy (each connection gets its own container)")
299
+ else:
300
+ # Find available port for HTTP
301
+ actual_port = find_free_port(port)
302
+ if actual_port is None:
303
+ design.error(f"No available ports found starting from {port}")
304
+ raise click.Abort
305
+
306
+ if actual_port != port and verbose:
307
+ design.warning(f"Port {port} in use, using port {actual_port} instead")
308
+
309
+ # Launch MCP Inspector if requested
310
+ if inspector:
311
+ server_url = f"http://localhost:{actual_port}/mcp"
312
+
313
+ # Function to launch inspector in background
314
+ async def launch_inspector() -> None:
315
+ """Launch MCP Inspector and capture its output to extract the URL."""
316
+ # Wait for server to be ready
317
+ await asyncio.sleep(3)
318
+
319
+ try:
320
+ import platform
321
+ import urllib.parse
322
+
323
+ # Build the direct URL with query params to auto-connect
324
+ encoded_url = urllib.parse.quote(server_url)
325
+ inspector_url = (
326
+ f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
327
+ )
328
+
329
+ # Print inspector info cleanly
330
+ design.section_title("MCP Inspector")
331
+ design.link(inspector_url)
332
+
333
+ # Set environment to disable auth (for development only)
334
+ env = os.environ.copy()
335
+ env["DANGEROUSLY_OMIT_AUTH"] = "true"
336
+ env["MCP_AUTO_OPEN_ENABLED"] = "true"
337
+
338
+ # Launch inspector
339
+ cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
340
+
341
+ # Run in background, suppressing output to avoid log interference
342
+ if platform.system() == "Windows":
343
+ subprocess.Popen( # noqa: S602, ASYNC220
344
+ cmd,
345
+ env=env,
346
+ shell=True,
347
+ stdout=subprocess.DEVNULL,
348
+ stderr=subprocess.DEVNULL,
349
+ )
350
+ else:
351
+ subprocess.Popen( # noqa: S603, ASYNC220
352
+ cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
353
+ )
354
+
355
+ except (FileNotFoundError, Exception):
356
+ # Silently fail - inspector is optional
357
+ design.error("Failed to launch inspector")
358
+
359
+ # Launch inspector asynchronously so it doesn't block
360
+ asyncio.create_task(launch_inspector())
361
+
362
+ # Launch interactive mode if requested
363
+ if interactive:
364
+ if transport != "http":
365
+ from hud.utils.design import HUDDesign
366
+
367
+ design.warning("Interactive mode only works with HTTP transport")
368
+ else:
369
+ server_url = f"http://localhost:{actual_port}/mcp"
370
+
371
+ # Function to launch interactive mode in a separate thread
372
+ def launch_interactive_thread() -> None:
373
+ """Launch interactive testing mode in a separate thread."""
374
+ import time
375
+
376
+ # Wait for server to be ready
377
+ time.sleep(3)
378
+
379
+ try:
380
+ design.section_title("Interactive Mode")
381
+ design.info("Starting interactive testing mode...")
382
+ design.info("Press Ctrl+C in the interactive session to exit")
383
+
384
+ # Import and run interactive mode in a new event loop
385
+ from .interactive import run_interactive_mode
386
+
387
+ # Create a new event loop for the thread
388
+ loop = asyncio.new_event_loop()
389
+ asyncio.set_event_loop(loop)
390
+ try:
391
+ loop.run_until_complete(run_interactive_mode(server_url, verbose))
392
+ finally:
393
+ loop.close()
394
+
395
+ except Exception as e:
396
+ # Log error but don't crash the server
397
+ if verbose:
398
+ design.error(f"Interactive mode error: {e}")
399
+
400
+ # Launch interactive mode in a separate thread
401
+ import threading
402
+
403
+ interactive_thread = threading.Thread(target=launch_interactive_thread, daemon=True)
404
+ interactive_thread.start()
405
+
406
+ # Function to stream Docker logs
407
+ async def stream_docker_logs() -> None:
408
+ """Stream Docker container logs asynchronously."""
409
+ log_design = design
410
+
411
+ # Always show waiting message
412
+ log_design.info("") # Empty line for spacing
413
+ log_design.progress_message("โณ Waiting for first client connection to start container...")
414
+
415
+ # Keep trying to stream logs - container is created on demand
416
+ has_shown_started = False
417
+ while True:
418
+ # Check if container exists first (silently)
419
+ check_result = await asyncio.create_subprocess_exec(
420
+ "docker",
421
+ "ps",
422
+ "--format",
423
+ "{{.Names}}",
424
+ "--filter",
425
+ f"name={container_name}",
426
+ stdout=asyncio.subprocess.PIPE,
427
+ stderr=asyncio.subprocess.DEVNULL,
428
+ )
429
+ stdout, _ = await check_result.communicate()
430
+
431
+ # If container doesn't exist, wait and retry
432
+ if container_name not in stdout.decode():
433
+ await asyncio.sleep(1)
434
+ continue
435
+
436
+ # Container exists! Show success if first time
437
+ if not has_shown_started:
438
+ log_design.success("Container started! Streaming logs...")
439
+ has_shown_started = True
440
+
441
+ # Now stream the logs
442
+ try:
443
+ process = await asyncio.create_subprocess_exec(
444
+ "docker",
445
+ "logs",
446
+ "-f",
447
+ container_name,
448
+ stdout=asyncio.subprocess.PIPE,
449
+ stderr=asyncio.subprocess.STDOUT, # Combine streams for simplicity
450
+ )
451
+
452
+ if process.stdout:
453
+ async for line in process.stdout:
454
+ decoded_line = line.decode().rstrip()
455
+ if not decoded_line: # Skip empty lines
456
+ continue
457
+
458
+ # Skip docker daemon errors (these happen when container is removed)
459
+ if "Error response from daemon" in decoded_line:
460
+ continue
461
+
462
+ # Show all logs with gold formatting like hud debug
463
+ # Format all logs in gold/dim style like hud debug's stderr
464
+ log_design.console.print(
465
+ f"[rgb(192,150,12)]โ– [/rgb(192,150,12)] {decoded_line}", highlight=False
466
+ )
467
+
468
+ # Process ended - container might have been removed
469
+ await process.wait()
470
+
471
+ # Check if container still exists
472
+ await asyncio.sleep(1)
473
+ continue # Loop back to check if container exists
474
+
475
+ except Exception:
476
+ # Some unexpected error
477
+ if verbose:
478
+ log_design.warning("Failed to stream logs")
479
+ await asyncio.sleep(1)
480
+
481
+ # CRITICAL: Create proxy AFTER all logging setup to prevent it from resetting logging config
482
+ # This is important because FastMCP might initialize loggers during creation
483
+ proxy = create_proxy_server(
484
+ directory, image_name, no_reload, verbose, docker_args or [], interactive
485
+ )
486
+
487
+ # One more attempt to suppress the FastMCP server log
488
+ if not verbose:
489
+ # Re-apply the filter in case new handlers were created
490
+ class BlockStartingMCPFilter(logging.Filter):
491
+ def filter(self, record: logging.LogRecord) -> bool:
492
+ return "Starting MCP server" not in record.getMessage()
493
+
494
+ block_filter = BlockStartingMCPFilter()
495
+
496
+ # Apply to all loggers again - comprehensive list
497
+ for logger_name in [
498
+ "", # root logger
499
+ "fastmcp",
500
+ "fastmcp.server",
501
+ "fastmcp.server.server",
502
+ "FastMCP",
503
+ "FastMCP.fastmcp.server.server",
504
+ "mcp",
505
+ "mcp.server",
506
+ "mcp.server.lowlevel",
507
+ "mcp.server.lowlevel.server",
508
+ "uvicorn",
509
+ "uvicorn.access",
510
+ "uvicorn.error",
511
+ "hud.server",
512
+ "hud.server.server",
513
+ ]:
514
+ logger = logging.getLogger(logger_name)
515
+ logger.setLevel(logging.ERROR)
516
+ logger.addFilter(block_filter)
517
+ for handler in logger.handlers:
518
+ handler.addFilter(block_filter)
519
+
520
+ try:
521
+ # Start Docker logs streaming if enabled
522
+ log_task = None
523
+ if not no_logs:
524
+ log_task = asyncio.create_task(stream_docker_logs())
525
+
526
+ if transport == "stdio":
527
+ # Run with stdio transport
528
+ await proxy.run_async(
529
+ transport="stdio", log_level="ERROR" if not verbose else "INFO", show_banner=False
530
+ )
531
+ else:
532
+ # Run with HTTP transport
533
+ # Temporarily redirect stderr to suppress uvicorn shutdown messages
534
+ import contextlib
535
+ import io
536
+
537
+ if not verbose:
538
+ # Create a dummy file to swallow unwanted stderr output
539
+ with contextlib.redirect_stderr(io.StringIO()):
540
+ await proxy.run_async(
541
+ transport="http",
542
+ host="0.0.0.0", # noqa: S104
543
+ port=actual_port,
544
+ path="/mcp", # Serve at /mcp endpoint
545
+ log_level="ERROR",
546
+ show_banner=False,
547
+ )
548
+ else:
549
+ await proxy.run_async(
550
+ transport="http",
551
+ host="0.0.0.0", # noqa: S104
552
+ port=actual_port,
553
+ path="/mcp", # Serve at /mcp endpoint
554
+ log_level="INFO",
555
+ show_banner=False,
556
+ )
557
+ except (ConnectionError, OSError) as e:
558
+ design.error(f"Failed to connect to Docker container: {e}")
559
+ design.info("")
560
+ design.info("๐Ÿ’ก Tip: Run the following command to debug the container:")
561
+ design.info(f" hud debug {image_name}")
562
+ design.info("")
563
+ design.info("Common issues:")
564
+ design.info(" โ€ข Container failed to start or crashed immediately")
565
+ design.info(" โ€ข Server initialization failed")
566
+ design.info(" โ€ข Port binding conflicts")
567
+ raise
568
+ except KeyboardInterrupt:
569
+ design.info("\n๐Ÿ‘‹ Shutting down...")
570
+
571
+ # Show next steps tutorial
572
+ if not interactive: # Only show if not in interactive mode
573
+ design.section_title("Next Steps")
574
+ design.info("๐Ÿ—๏ธ Ready to test with real agents? Run:")
575
+ design.info(f" [cyan]hud build {directory}[/cyan]")
576
+ design.info("")
577
+ design.info("This will:")
578
+ design.info(" 1. Build your environment image")
579
+ design.info(" 2. Generate a hud.lock.yaml file")
580
+ design.info(" 3. Prepare it for testing with agents")
581
+ design.info("")
582
+ design.info("Then you can:")
583
+ design.info(" โ€ข Test locally: [cyan]hud run <image>[/cyan]")
584
+ design.info(
585
+ " โ€ข Push to registry: [cyan]hud push --image <registry/name>[/cyan]"
586
+ )
587
+ except Exception as e:
588
+ # Suppress the graceful shutdown error and other FastMCP/uvicorn internal errors
589
+ error_msg = str(e)
590
+ if not any(
591
+ x in error_msg
592
+ for x in [
593
+ "timeout graceful shutdown exceeded",
594
+ "Cancel 0 running task(s)",
595
+ "Application shutdown complete",
596
+ ]
597
+ ):
598
+ design.error(f"Unexpected error: {e}")
599
+ finally:
600
+ # Cancel log streaming task if it exists
601
+ if log_task and not log_task.done():
602
+ log_task.cancel()
603
+ try:
604
+ await log_task
605
+ except asyncio.CancelledError:
606
+ pass # Log streaming cancelled, normal shutdown
607
+
608
+
609
+ def run_mcp_dev_server(
610
+ directory: str = ".",
611
+ image: str | None = None,
612
+ build: bool = False,
613
+ no_cache: bool = False,
614
+ transport: str = "http",
615
+ port: int = 8765,
616
+ no_reload: bool = False,
617
+ verbose: bool = False,
618
+ inspector: bool = False,
619
+ no_logs: bool = False,
620
+ interactive: bool = False,
621
+ docker_args: list[str] | None = None,
622
+ ) -> None:
623
+ """Run MCP development server with hot-reload.
624
+
625
+ This command starts a development proxy that:
626
+ - Auto-detects or builds Docker images
627
+ - Mounts local source code for hot-reload
628
+ - Exposes an HTTP endpoint for MCP clients
629
+
630
+ Examples:
631
+ hud dev . # Auto-detect image from directory
632
+ hud dev . --build # Build image first
633
+ hud dev . --image custom:tag # Use specific image
634
+ hud dev . --no-cache # Force clean rebuild
635
+ """
636
+ # Ensure directory exists
637
+ if not Path(directory).exists():
638
+ design.error(f"Directory not found: {directory}")
639
+ raise click.Abort
640
+
641
+ # No external dependencies needed for hot-reload anymore!
642
+
643
+ # Resolve image name
644
+ resolved_image, source = get_image_name(directory, image)
645
+
646
+ # Update pyproject.toml with auto-generated name if needed
647
+ if source == "auto":
648
+ update_pyproject_toml(directory, resolved_image)
649
+
650
+ # Build if requested
651
+ if build or no_cache:
652
+ build_and_update(directory, resolved_image, no_cache)
653
+
654
+ # Check if image exists
655
+ if not image_exists(resolved_image) and not build:
656
+ if click.confirm(f"Image {resolved_image} not found. Build it now?"):
657
+ build_and_update(directory, resolved_image)
658
+ else:
659
+ raise click.Abort
660
+
661
+ # Generate server name from image
662
+ server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
663
+
664
+ # For HTTP transport, find available port first
665
+ actual_port = port
666
+ if transport == "http":
667
+ from .utils import find_free_port
668
+
669
+ actual_port = find_free_port(port)
670
+ if actual_port is None:
671
+ design.error(f"No available ports found starting from {port}")
672
+ raise click.Abort
673
+ if actual_port != port and verbose:
674
+ design.warning(f"Port {port} in use, using port {actual_port}")
675
+
676
+ # Create config
677
+ if transport == "stdio":
678
+ server_config = {"command": "hud", "args": ["dev", directory, "--transport", "stdio"]}
679
+ else:
680
+ server_config = {"url": f"http://localhost:{actual_port}/mcp"}
681
+
682
+ # For the deeplink, we only need the server config
683
+ server_config_json = json.dumps(server_config, indent=2)
684
+ config_base64 = base64.b64encode(server_config_json.encode()).decode()
685
+
686
+ # Generate deeplink
687
+ deeplink = (
688
+ f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
689
+ )
690
+
691
+ # Show header with gold border
692
+ design.info("") # Empty line before header
693
+ design.header("HUD Development Server")
694
+
695
+ # Always show the Docker image being used as the first thing after header
696
+ design.section_title("Docker Image")
697
+ if source == "cache":
698
+ design.info(f"๐Ÿ“ฆ {resolved_image}")
699
+ elif source == "auto":
700
+ design.info(f"๐Ÿ”ง {resolved_image} (auto-generated)")
701
+ elif source == "override":
702
+ design.info(f"๐ŸŽฏ {resolved_image} (specified)")
703
+ else:
704
+ design.info(f"๐Ÿณ {resolved_image}")
705
+
706
+ design.progress_message(f"โ— If any issues arise, run `hud debug {resolved_image}` to debug the container")
707
+
708
+ # Show hints about inspector and interactive mode
709
+ if transport == "http":
710
+ if not inspector and not interactive:
711
+ design.progress_message("๐Ÿ’ก Run with --inspector to launch MCP Inspector")
712
+ design.progress_message("๐Ÿงช Run with --interactive for interactive testing mode")
713
+ elif not inspector:
714
+ design.progress_message("๐Ÿ’ก Run with --inspector to launch MCP Inspector")
715
+ elif not interactive:
716
+ design.progress_message("๐Ÿงช Run with --interactive for interactive testing mode")
717
+
718
+ # Disable logs and hot-reload if interactive mode is enabled
719
+ if interactive:
720
+ if not no_logs:
721
+ design.warning("Docker logs disabled in interactive mode for better UI experience")
722
+ no_logs = True
723
+ if not no_reload:
724
+ design.warning("Hot-reload disabled in interactive mode to prevent output interference")
725
+ no_reload = True
726
+
727
+ # Show configuration as JSON (just the server config, not wrapped)
728
+ full_config = {}
729
+ full_config[server_name] = server_config
730
+
731
+ design.section_title("MCP Configuration (add this to any agent/client)")
732
+ design.json_config(json.dumps(full_config, indent=2))
733
+
734
+ # Show connection info
735
+ design.section_title(
736
+ "Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
737
+ )
738
+ design.link(deeplink)
739
+ design.info("") # Empty line
740
+
741
+ # Start the proxy (pass original port, start_mcp_proxy will find actual port again)
742
+ try:
743
+ asyncio.run(
744
+ start_mcp_proxy(
745
+ directory,
746
+ resolved_image,
747
+ transport,
748
+ port,
749
+ no_reload,
750
+ verbose,
751
+ inspector,
752
+ no_logs,
753
+ interactive,
754
+ docker_args or [],
755
+ )
756
+ )
757
+ except Exception as e:
758
+ d.error(f"Failed to start MCP server: {e}")
759
+ d.info("")
760
+ d.info("๐Ÿ’ก Tip: Run the following command to debug the container:")
761
+ d.info(f" hud debug {resolved_image}")
762
+ d.info("")
763
+ d.info("This will help identify connection issues or initialization failures.")
764
+ raise