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