hud-python 0.4.1__py3-none-any.whl โ†’ 0.4.2__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 +250 -250
  6. hud/agents/misc/__init__.py +7 -7
  7. hud/agents/misc/response_agent.py +80 -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 +427 -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 +281 -281
  25. hud/cli/interactive.py +353 -353
  26. hud/cli/mcp_server.py +789 -756
  27. hud/cli/pull.py +336 -336
  28. hud/cli/push.py +370 -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 +379 -379
  44. hud/clients/fastmcp.py +202 -222
  45. hud/clients/mcp_use.py +278 -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 +322 -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 +168 -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.2.dist-info}/METADATA +10 -8
  125. hud_python-0.4.2.dist-info/RECORD +131 -0
  126. {hud_python-0.4.1.dist-info โ†’ hud_python-0.4.2.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.2.dist-info}/WHEEL +0 -0
  130. {hud_python-0.4.1.dist-info โ†’ hud_python-0.4.2.dist-info}/entry_points.txt +0 -0
hud/cli/mcp_server.py CHANGED
@@ -1,756 +1,789 @@
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 .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
+ try:
177
+ proxy = FastMCP.as_proxy(config, name=f"HUD Dev Proxy - {image_name}")
178
+ except Exception as e:
179
+ from hud.utils.design import HUDDesign
180
+
181
+ design = HUDDesign(stderr=True)
182
+ design.error(f"Failed to create proxy server: {e}")
183
+ design.info("")
184
+ design.info("๐Ÿ’ก Tip: Run the following command to debug the container:")
185
+ design.info(f" hud debug {image_name}")
186
+ raise
187
+
188
+ return proxy
189
+
190
+
191
+ async def start_mcp_proxy(
192
+ directory: str | Path,
193
+ image_name: str,
194
+ transport: str,
195
+ port: int,
196
+ no_reload: bool = False,
197
+ verbose: bool = False,
198
+ inspector: bool = False,
199
+ no_logs: bool = False,
200
+ interactive: bool = False,
201
+ docker_args: list[str] | None = None,
202
+ ) -> None:
203
+ """Start the MCP development proxy server."""
204
+ # Suppress FastMCP's verbose output FIRST
205
+ import asyncio
206
+ import logging
207
+ import os
208
+ import subprocess
209
+ import sys
210
+
211
+ from .utils import find_free_port
212
+
213
+ # Always disable the banner - we have our own output
214
+ os.environ["FASTMCP_DISABLE_BANNER"] = "1"
215
+
216
+ # Configure logging BEFORE creating proxy
217
+ if not verbose:
218
+ # Create a filter to block the specific "Starting MCP server" message
219
+ class _BlockStartingMCPFilter(logging.Filter):
220
+ def filter(self, record: logging.LogRecord) -> bool:
221
+ return "Starting MCP server" not in record.getMessage()
222
+
223
+ # Set environment variable for FastMCP logging
224
+ os.environ["FASTMCP_LOG_LEVEL"] = "ERROR"
225
+ os.environ["LOG_LEVEL"] = "ERROR"
226
+ os.environ["UVICORN_LOG_LEVEL"] = "ERROR"
227
+ # Suppress uvicorn's annoying shutdown messages
228
+ os.environ["UVICORN_ACCESS_LOG"] = "0"
229
+
230
+ # Configure logging to suppress INFO
231
+ logging.basicConfig(level=logging.ERROR, force=True)
232
+
233
+ # Set root logger to ERROR to suppress all INFO messages
234
+ root_logger = logging.getLogger()
235
+ root_logger.setLevel(logging.ERROR)
236
+
237
+ # Add filter to all handlers
238
+ block_filter = _BlockStartingMCPFilter()
239
+ for handler in root_logger.handlers:
240
+ handler.addFilter(block_filter)
241
+
242
+ # Also specifically suppress these loggers
243
+ for logger_name in [
244
+ "fastmcp",
245
+ "fastmcp.server",
246
+ "fastmcp.server.server",
247
+ "FastMCP",
248
+ "FastMCP.fastmcp.server.server",
249
+ "mcp",
250
+ "mcp.server",
251
+ "mcp.server.lowlevel",
252
+ "mcp.server.lowlevel.server",
253
+ "uvicorn",
254
+ "uvicorn.access",
255
+ "uvicorn.error",
256
+ "hud.server",
257
+ "hud.server.server",
258
+ ]:
259
+ logger = logging.getLogger(logger_name)
260
+ logger.setLevel(logging.ERROR)
261
+ # Add filter to this logger too
262
+ logger.addFilter(block_filter)
263
+
264
+ # Suppress deprecation warnings
265
+ import warnings
266
+
267
+ warnings.filterwarnings("ignore", category=DeprecationWarning)
268
+
269
+ # CRITICAL: For stdio transport, ALL output must go to stderr
270
+ if transport == "stdio":
271
+ # Configure root logger to use stderr
272
+ root_logger = logging.getLogger()
273
+ root_logger.handlers.clear()
274
+ stderr_handler = logging.StreamHandler(sys.stderr)
275
+ root_logger.addHandler(stderr_handler)
276
+
277
+ # Now check for src directory
278
+ src_path = Path(directory) / "src"
279
+ if not src_path.exists():
280
+ click.echo(f"โŒ Source directory not found: {src_path}", err=(transport == "stdio"))
281
+ raise click.Abort
282
+
283
+ # Extract container name from the proxy configuration
284
+ container_name = f"{image_name.replace(':', '-').replace('/', '-')}"
285
+
286
+ # Remove any existing container with the same name (silently)
287
+ # Note: The proxy creates containers on-demand when clients connect
288
+ try:
289
+ subprocess.run( # noqa: S603, ASYNC221
290
+ ["docker", "rm", "-f", container_name], # noqa: S607
291
+ stdout=subprocess.DEVNULL,
292
+ stderr=subprocess.DEVNULL,
293
+ check=False, # Don't raise error if container doesn't exist
294
+ )
295
+ except Exception:
296
+ click.echo(f"Failed to remove existing container {container_name}", err=True)
297
+
298
+ if transport == "stdio":
299
+ if verbose:
300
+ click.echo("๐Ÿ”Œ Starting stdio proxy (each connection gets its own container)", err=True)
301
+ else:
302
+ # Find available port for HTTP
303
+ actual_port = find_free_port(port)
304
+ if actual_port is None:
305
+ click.echo(f"โŒ No available ports found starting from {port}")
306
+ raise click.Abort
307
+
308
+ if actual_port != port and verbose:
309
+ click.echo(f"โš ๏ธ Port {port} in use, using port {actual_port} instead")
310
+
311
+ # Launch MCP Inspector if requested
312
+ if inspector:
313
+ server_url = f"http://localhost:{actual_port}/mcp"
314
+
315
+ # Function to launch inspector in background
316
+ async def launch_inspector() -> None:
317
+ """Launch MCP Inspector and capture its output to extract the URL."""
318
+ # Wait for server to be ready
319
+ await asyncio.sleep(3)
320
+
321
+ try:
322
+ import platform
323
+ import urllib.parse
324
+
325
+ # Build the direct URL with query params to auto-connect
326
+ encoded_url = urllib.parse.quote(server_url)
327
+ inspector_url = (
328
+ f"http://localhost:6274/?transport=streamable-http&serverUrl={encoded_url}"
329
+ )
330
+
331
+ # Print inspector info cleanly
332
+ from hud.utils.design import HUDDesign
333
+
334
+ inspector_design = HUDDesign(stderr=(transport == "stdio"))
335
+ inspector_design.section_title("MCP Inspector")
336
+ inspector_design.link(inspector_url)
337
+
338
+ # Set environment to disable auth (for development only)
339
+ env = os.environ.copy()
340
+ env["DANGEROUSLY_OMIT_AUTH"] = "true"
341
+ env["MCP_AUTO_OPEN_ENABLED"] = "true"
342
+
343
+ # Launch inspector
344
+ cmd = ["npx", "--yes", "@modelcontextprotocol/inspector"]
345
+
346
+ # Run in background, suppressing output to avoid log interference
347
+ if platform.system() == "Windows":
348
+ subprocess.Popen( # noqa: S602, ASYNC220
349
+ cmd,
350
+ env=env,
351
+ shell=True,
352
+ stdout=subprocess.DEVNULL,
353
+ stderr=subprocess.DEVNULL,
354
+ )
355
+ else:
356
+ subprocess.Popen( # noqa: S603, ASYNC220
357
+ cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
358
+ )
359
+
360
+ except (FileNotFoundError, Exception):
361
+ # Silently fail - inspector is optional
362
+ click.echo("Failed to launch inspector", err=True)
363
+
364
+ # Launch inspector asynchronously so it doesn't block
365
+ asyncio.create_task(launch_inspector())
366
+
367
+ # Launch interactive mode if requested
368
+ if interactive:
369
+ if transport != "http":
370
+ from hud.utils.design import HUDDesign
371
+
372
+ interactive_design = HUDDesign(stderr=True)
373
+ interactive_design.warning("Interactive mode only works with HTTP transport")
374
+ else:
375
+ server_url = f"http://localhost:{actual_port}/mcp"
376
+
377
+ # Function to launch interactive mode in a separate thread
378
+ def launch_interactive_thread() -> None:
379
+ """Launch interactive testing mode in a separate thread."""
380
+ import time
381
+
382
+ # Wait for server to be ready
383
+ time.sleep(3)
384
+
385
+ try:
386
+ from hud.utils.design import HUDDesign
387
+
388
+ interactive_design = HUDDesign(stderr=(transport == "stdio"))
389
+ interactive_design.section_title("Interactive Mode")
390
+ interactive_design.info("Starting interactive testing mode...")
391
+ interactive_design.info("Press Ctrl+C in the interactive session to exit")
392
+
393
+ # Import and run interactive mode in a new event loop
394
+ from .interactive import run_interactive_mode
395
+
396
+ # Create a new event loop for the thread
397
+ loop = asyncio.new_event_loop()
398
+ asyncio.set_event_loop(loop)
399
+ try:
400
+ loop.run_until_complete(run_interactive_mode(server_url, verbose))
401
+ finally:
402
+ loop.close()
403
+
404
+ except Exception as e:
405
+ # Log error but don't crash the server
406
+ if verbose:
407
+ click.echo(f"Interactive mode error: {e}", err=True)
408
+
409
+ # Launch interactive mode in a separate thread
410
+ import threading
411
+
412
+ interactive_thread = threading.Thread(target=launch_interactive_thread, daemon=True)
413
+ interactive_thread.start()
414
+
415
+ # Function to stream Docker logs
416
+ async def stream_docker_logs() -> None:
417
+ """Stream Docker container logs asynchronously."""
418
+ # Import design system for consistent output
419
+ from hud.utils.design import HUDDesign
420
+
421
+ log_design = HUDDesign(stderr=(transport == "stdio"))
422
+
423
+ # Always show waiting message
424
+ log_design.info("") # Empty line for spacing
425
+ log_design.progress_message("โณ Waiting for first client connection to start container...")
426
+
427
+ # Keep trying to stream logs - container is created on demand
428
+ has_shown_started = False
429
+ while True:
430
+ # Check if container exists first (silently)
431
+ check_result = await asyncio.create_subprocess_exec(
432
+ "docker",
433
+ "ps",
434
+ "--format",
435
+ "{{.Names}}",
436
+ "--filter",
437
+ f"name={container_name}",
438
+ stdout=asyncio.subprocess.PIPE,
439
+ stderr=asyncio.subprocess.DEVNULL,
440
+ )
441
+ stdout, _ = await check_result.communicate()
442
+
443
+ # If container doesn't exist, wait and retry
444
+ if container_name not in stdout.decode():
445
+ await asyncio.sleep(1)
446
+ continue
447
+
448
+ # Container exists! Show success if first time
449
+ if not has_shown_started:
450
+ log_design.success("Container started! Streaming logs...")
451
+ has_shown_started = True
452
+
453
+ # Now stream the logs
454
+ try:
455
+ process = await asyncio.create_subprocess_exec(
456
+ "docker",
457
+ "logs",
458
+ "-f",
459
+ container_name,
460
+ stdout=asyncio.subprocess.PIPE,
461
+ stderr=asyncio.subprocess.STDOUT, # Combine streams for simplicity
462
+ )
463
+
464
+ if process.stdout:
465
+ async for line in process.stdout:
466
+ decoded_line = line.decode().rstrip()
467
+ if not decoded_line: # Skip empty lines
468
+ continue
469
+
470
+ # Skip docker daemon errors (these happen when container is removed)
471
+ if "Error response from daemon" in decoded_line:
472
+ continue
473
+
474
+ # Show all logs with gold formatting like hud debug
475
+ # Format all logs in gold/dim style like hud debug's stderr
476
+ log_design.console.print(
477
+ f"[rgb(192,150,12)]โ– [/rgb(192,150,12)] {decoded_line}", highlight=False
478
+ )
479
+
480
+ # Process ended - container might have been removed
481
+ await process.wait()
482
+
483
+ # Check if container still exists
484
+ await asyncio.sleep(1)
485
+ continue # Loop back to check if container exists
486
+
487
+ except Exception:
488
+ # Some unexpected error
489
+ if verbose:
490
+ log_design.warning("Failed to stream logs")
491
+ await asyncio.sleep(1)
492
+
493
+ # CRITICAL: Create proxy AFTER all logging setup to prevent it from resetting logging config
494
+ # This is important because FastMCP might initialize loggers during creation
495
+ proxy = create_proxy_server(
496
+ directory, image_name, no_reload, verbose, docker_args or [], interactive
497
+ )
498
+
499
+ # One more attempt to suppress the FastMCP server log
500
+ if not verbose:
501
+ # Re-apply the filter in case new handlers were created
502
+ class BlockStartingMCPFilter(logging.Filter):
503
+ def filter(self, record: logging.LogRecord) -> bool:
504
+ return "Starting MCP server" not in record.getMessage()
505
+
506
+ block_filter = BlockStartingMCPFilter()
507
+
508
+ # Apply to all loggers again - comprehensive list
509
+ for logger_name in [
510
+ "", # root logger
511
+ "fastmcp",
512
+ "fastmcp.server",
513
+ "fastmcp.server.server",
514
+ "FastMCP",
515
+ "FastMCP.fastmcp.server.server",
516
+ "mcp",
517
+ "mcp.server",
518
+ "mcp.server.lowlevel",
519
+ "mcp.server.lowlevel.server",
520
+ "uvicorn",
521
+ "uvicorn.access",
522
+ "uvicorn.error",
523
+ "hud.server",
524
+ "hud.server.server",
525
+ ]:
526
+ logger = logging.getLogger(logger_name)
527
+ logger.setLevel(logging.ERROR)
528
+ logger.addFilter(block_filter)
529
+ for handler in logger.handlers:
530
+ handler.addFilter(block_filter)
531
+
532
+ try:
533
+ # Start Docker logs streaming if enabled
534
+ log_task = None
535
+ if not no_logs:
536
+ log_task = asyncio.create_task(stream_docker_logs())
537
+
538
+ if transport == "stdio":
539
+ # Run with stdio transport
540
+ await proxy.run_async(
541
+ transport="stdio", log_level="ERROR" if not verbose else "INFO", show_banner=False
542
+ )
543
+ else:
544
+ # Run with HTTP transport
545
+ # Temporarily redirect stderr to suppress uvicorn shutdown messages
546
+ import contextlib
547
+ import io
548
+
549
+ if not verbose:
550
+ # Create a dummy file to swallow unwanted stderr output
551
+ with contextlib.redirect_stderr(io.StringIO()):
552
+ await proxy.run_async(
553
+ transport="http",
554
+ host="0.0.0.0", # noqa: S104
555
+ port=actual_port,
556
+ path="/mcp", # Serve at /mcp endpoint
557
+ log_level="ERROR",
558
+ show_banner=False,
559
+ )
560
+ else:
561
+ await proxy.run_async(
562
+ transport="http",
563
+ host="0.0.0.0", # noqa: S104
564
+ port=actual_port,
565
+ path="/mcp", # Serve at /mcp endpoint
566
+ log_level="INFO",
567
+ show_banner=False,
568
+ )
569
+ except (ConnectionError, OSError) as e:
570
+ from hud.utils.design import HUDDesign
571
+
572
+ error_design = HUDDesign(stderr=True)
573
+ error_design.error(f"Failed to connect to Docker container: {e}")
574
+ error_design.info("")
575
+ error_design.info("๐Ÿ’ก Tip: Run the following command to debug the container:")
576
+ error_design.info(f" hud debug {image_name}")
577
+ error_design.info("")
578
+ error_design.info("Common issues:")
579
+ error_design.info(" โ€ข Container failed to start or crashed immediately")
580
+ error_design.info(" โ€ข Server initialization failed")
581
+ error_design.info(" โ€ข Port binding conflicts")
582
+ raise
583
+ except KeyboardInterrupt:
584
+ from hud.utils.design import HUDDesign
585
+
586
+ shutdown_design = HUDDesign(stderr=(transport == "stdio"))
587
+ shutdown_design.info("\n๐Ÿ‘‹ Shutting down...")
588
+
589
+ # Show next steps tutorial
590
+ if not interactive: # Only show if not in interactive mode
591
+ shutdown_design.section_title("Next Steps")
592
+ shutdown_design.info("๐Ÿ—๏ธ Ready to test with real agents? Run:")
593
+ shutdown_design.info(f" [cyan]hud build {directory}[/cyan]")
594
+ shutdown_design.info("")
595
+ shutdown_design.info("This will:")
596
+ shutdown_design.info(" 1. Build your environment image")
597
+ shutdown_design.info(" 2. Generate a hud.lock.yaml file")
598
+ shutdown_design.info(" 3. Prepare it for testing with agents")
599
+ shutdown_design.info("")
600
+ shutdown_design.info("Then you can:")
601
+ shutdown_design.info(" โ€ข Test locally: [cyan]hud run <image>[/cyan]")
602
+ shutdown_design.info(
603
+ " โ€ข Push to registry: [cyan]hud push --image <registry/name>[/cyan]"
604
+ )
605
+ except Exception as e:
606
+ # Suppress the graceful shutdown error and other FastMCP/uvicorn internal errors
607
+ error_msg = str(e)
608
+ if not any(
609
+ x in error_msg
610
+ for x in [
611
+ "timeout graceful shutdown exceeded",
612
+ "Cancel 0 running task(s)",
613
+ "Application shutdown complete",
614
+ ]
615
+ ):
616
+ from hud.utils.design import HUDDesign
617
+
618
+ shutdown_design = HUDDesign(stderr=(transport == "stdio"))
619
+ shutdown_design.error(f"Unexpected error: {e}")
620
+ finally:
621
+ # Cancel log streaming task if it exists
622
+ if log_task and not log_task.done():
623
+ log_task.cancel()
624
+ try:
625
+ await log_task
626
+ except asyncio.CancelledError:
627
+ click.echo("Log streaming task cancelled", err=True)
628
+
629
+
630
+ def run_mcp_dev_server(
631
+ directory: str = ".",
632
+ image: str | None = None,
633
+ build: bool = False,
634
+ no_cache: bool = False,
635
+ transport: str = "http",
636
+ port: int = 8765,
637
+ no_reload: bool = False,
638
+ verbose: bool = False,
639
+ inspector: bool = False,
640
+ no_logs: bool = False,
641
+ interactive: bool = False,
642
+ docker_args: list[str] | None = None,
643
+ ) -> None:
644
+ """Run MCP development server with hot-reload.
645
+
646
+ This command starts a development proxy that:
647
+ - Auto-detects or builds Docker images
648
+ - Mounts local source code for hot-reload
649
+ - Exposes an HTTP endpoint for MCP clients
650
+
651
+ Examples:
652
+ hud dev . # Auto-detect image from directory
653
+ hud dev . --build # Build image first
654
+ hud dev . --image custom:tag # Use specific image
655
+ hud dev . --no-cache # Force clean rebuild
656
+ """
657
+ from hud.utils.design import HUDDesign
658
+
659
+ design = HUDDesign(stderr=(transport == "stdio"))
660
+
661
+ # Ensure directory exists
662
+ if not Path(directory).exists():
663
+ design.error(f"Directory not found: {directory}")
664
+ raise click.Abort
665
+
666
+ # No external dependencies needed for hot-reload anymore!
667
+
668
+ # Resolve image name
669
+ resolved_image, source = get_image_name(directory, image)
670
+
671
+ # Update pyproject.toml with auto-generated name if needed
672
+ if source == "auto":
673
+ update_pyproject_toml(directory, resolved_image)
674
+
675
+ # Build if requested
676
+ if build or no_cache:
677
+ build_and_update(directory, resolved_image, no_cache)
678
+
679
+ # Check if image exists
680
+ if not image_exists(resolved_image) and not build:
681
+ if click.confirm(f"Image {resolved_image} not found. Build it now?"):
682
+ build_and_update(directory, resolved_image)
683
+ else:
684
+ raise click.Abort
685
+
686
+ # Generate server name from image
687
+ server_name = resolved_image.split(":")[0] if ":" in resolved_image else resolved_image
688
+
689
+ # For HTTP transport, find available port first
690
+ actual_port = port
691
+ if transport == "http":
692
+ from .utils import find_free_port
693
+
694
+ actual_port = find_free_port(port)
695
+ if actual_port is None:
696
+ design.error(f"No available ports found starting from {port}")
697
+ raise click.Abort
698
+ if actual_port != port and verbose:
699
+ design.warning(f"Port {port} in use, using port {actual_port}")
700
+
701
+ # Create config
702
+ if transport == "stdio":
703
+ server_config = {"command": "hud", "args": ["dev", directory, "--transport", "stdio"]}
704
+ else:
705
+ server_config = {"url": f"http://localhost:{actual_port}/mcp"}
706
+
707
+ # For the deeplink, we only need the server config
708
+ server_config_json = json.dumps(server_config, indent=2)
709
+ config_base64 = base64.b64encode(server_config_json.encode()).decode()
710
+
711
+ # Generate deeplink
712
+ deeplink = (
713
+ f"cursor://anysphere.cursor-deeplink/mcp/install?name={server_name}&config={config_base64}"
714
+ )
715
+
716
+ # Show header with gold border
717
+ design.info("") # Empty line before header
718
+ design.header("HUD Development Server")
719
+
720
+ # Always show the Docker image being used as the first thing after header
721
+ design.section_title("Docker Image")
722
+ if source == "cache":
723
+ design.info(f"๐Ÿ“ฆ {resolved_image}")
724
+ elif source == "auto":
725
+ design.info(f"๐Ÿ”ง {resolved_image} (auto-generated)")
726
+ elif source == "override":
727
+ design.info(f"๐ŸŽฏ {resolved_image} (specified)")
728
+ else:
729
+ design.info(f"๐Ÿณ {resolved_image}")
730
+
731
+ design.progress_message(f"โ— If any issues arise, run `hud debug {resolved_image}` to debug the container")
732
+
733
+ # Show hints about inspector and interactive mode
734
+ if transport == "http":
735
+ if not inspector and not interactive:
736
+ design.progress_message("๐Ÿ’ก Run with --inspector to launch MCP Inspector")
737
+ design.progress_message("๐Ÿงช Run with --interactive for interactive testing mode")
738
+ elif not inspector:
739
+ design.progress_message("๐Ÿ’ก Run with --inspector to launch MCP Inspector")
740
+ elif not interactive:
741
+ design.progress_message("๐Ÿงช Run with --interactive for interactive testing mode")
742
+
743
+ # Disable logs and hot-reload if interactive mode is enabled
744
+ if interactive:
745
+ if not no_logs:
746
+ design.warning("Docker logs disabled in interactive mode for better UI experience")
747
+ no_logs = True
748
+ if not no_reload:
749
+ design.warning("Hot-reload disabled in interactive mode to prevent output interference")
750
+ no_reload = True
751
+
752
+ # Show configuration as JSON (just the server config, not wrapped)
753
+ full_config = {}
754
+ full_config[server_name] = server_config
755
+
756
+ design.section_title("MCP Configuration (add this to any agent/client)")
757
+ design.json_config(json.dumps(full_config, indent=2))
758
+
759
+ # Show connection info
760
+ design.section_title(
761
+ "Connect to Cursor (be careful with multiple windows as that may interfere with the proxy)"
762
+ )
763
+ design.link(deeplink)
764
+ design.info("") # Empty line
765
+
766
+ # Start the proxy (pass original port, start_mcp_proxy will find actual port again)
767
+ try:
768
+ asyncio.run(
769
+ start_mcp_proxy(
770
+ directory,
771
+ resolved_image,
772
+ transport,
773
+ port,
774
+ no_reload,
775
+ verbose,
776
+ inspector,
777
+ no_logs,
778
+ interactive,
779
+ docker_args or [],
780
+ )
781
+ )
782
+ except Exception as e:
783
+ design.error(f"Failed to start MCP server: {e}")
784
+ design.info("")
785
+ design.info("๐Ÿ’ก Tip: Run the following command to debug the container:")
786
+ design.info(f" hud debug {resolved_image}")
787
+ design.info("")
788
+ design.info("This will help identify connection issues or initialization failures.")
789
+ raise