Skip to content

Commit 5b71e34

Browse files
authored
fix: hud build and hud analyze distinguish between http mode and stdio (#370)
* support http mode for mcp analyze * misc: more robust coverage for docker command * nit: ruff * misc: clean up shell prefix * misc: bump version number * fix: ruff * ruff * fix: add timeout for http mode client aenter
1 parent a2512ae commit 5b71e34

9 files changed

Lines changed: 596 additions & 97 deletions

File tree

hud/cli/analyze.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,22 @@ async def analyze_environment_from_mcp_config(
439439
await _analyze_with_config(mcp_config, output_format, verbose)
440440

441441

442+
def _prepare_mcp_config(mcp_config: dict[str, Any]) -> dict[str, Any]:
443+
"""Inject ``auth: None`` into URL-based server entries.
444+
445+
FastMCPClient attempts OAuth discovery on servers that expose a ``url``
446+
field. For local / dev servers this causes hangs or connection errors.
447+
Setting ``auth`` to ``None`` disables the discovery probe.
448+
"""
449+
patched: dict[str, Any] = {}
450+
for key, value in mcp_config.items():
451+
if isinstance(value, dict) and "url" in value and "auth" not in value:
452+
patched[key] = {**value, "auth": None}
453+
else:
454+
patched[key] = value
455+
return patched
456+
457+
442458
async def _analyze_with_config(
443459
mcp_config: dict[str, Any], output_format: str, verbose: bool
444460
) -> None:
@@ -455,7 +471,8 @@ async def _analyze_with_config(
455471

456472
from hud.cli.utils.mcp import analyze_environment as mcp_analyze
457473

458-
client = FastMCPClient(transport=mcp_config)
474+
config = _prepare_mcp_config(mcp_config)
475+
client = FastMCPClient(transport=config)
459476

460477
try:
461478
await client.__aenter__()

hud/cli/build.py

Lines changed: 153 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -461,107 +461,119 @@ def collect_runtime_metadata(image: str, *, verbose: bool = False) -> dict[str,
461461
async def analyze_mcp_environment(
462462
image: str, verbose: bool = False, env_vars: dict[str, str] | None = None
463463
) -> dict[str, Any]:
464-
"""Analyze an MCP environment to extract metadata."""
464+
"""Analyze an MCP environment to extract metadata.
465+
466+
Supports both stdio (default) and HTTP transport. The transport is
467+
auto-detected from the image's CMD directive.
468+
"""
469+
from fastmcp import Client as FastMCPClient
470+
471+
from hud.cli.utils.docker import (
472+
DEFAULT_HTTP_PORT,
473+
build_env_flags,
474+
detect_transport,
475+
stop_container,
476+
)
477+
from hud.cli.utils.mcp import analyze_environment
478+
465479
hud_console = HUDConsole()
466480
env_vars = env_vars or {}
481+
transport_mode, container_port = detect_transport(image)
482+
is_http = transport_mode == "http"
483+
container_name: str | None = None
484+
initialized = False
485+
client: Any = None
467486

468-
# Build Docker command to run the image, injecting any provided env vars
469-
from hud.cli.utils.docker import build_env_flags
487+
try:
488+
# --- transport-specific setup ---
489+
if is_http:
490+
from hud.cli.utils.logging import find_free_port
491+
from hud.cli.utils.mcp import wait_for_http_server
492+
493+
port = container_port or DEFAULT_HTTP_PORT
494+
host_port = find_free_port(port)
495+
if host_port is None:
496+
from hud.shared.exceptions import HudException
497+
498+
raise HudException(f"No free port found starting from {port}")
499+
500+
container_name = f"hud-build-analyze-{os.getpid()}"
501+
docker_cmd = [
502+
"docker",
503+
"run",
504+
"-d",
505+
"--rm",
506+
"--name",
507+
container_name,
508+
"-p",
509+
f"{host_port}:{port}",
510+
*build_env_flags(env_vars),
511+
image,
512+
]
513+
hud_console.dim_info("Command:", " ".join(docker_cmd))
514+
hud_console.info(f"HTTP transport detected — mapping port {host_port}:{port}")
470515

471-
docker_cmd = ["docker", "run", "--rm", "-i", *build_env_flags(env_vars), image]
516+
try:
517+
proc = await asyncio.to_thread(
518+
subprocess.run,
519+
docker_cmd,
520+
capture_output=True,
521+
text=True,
522+
check=True,
523+
timeout=30,
524+
)
525+
except subprocess.CalledProcessError as e:
526+
from hud.shared.exceptions import HudException
472527

473-
# Show full docker command being used for analysis
474-
hud_console.dim_info("Command:", " ".join(docker_cmd))
528+
hud_console.error(f"Failed to start container: {e.stderr.strip()}")
529+
raise HudException("Failed to start Docker container for HTTP analysis") from e
475530

476-
# Create MCP config consistently with analyze helpers
477-
from hud.cli.analyze import parse_docker_command
531+
if verbose:
532+
hud_console.info(f"Container started: {proc.stdout.strip()[:12]}")
478533

479-
mcp_config = parse_docker_command(docker_cmd)
480-
# Extract server name for display (first key in mcp_config)
481-
server_name = next(iter(mcp_config.keys()), None)
534+
server_url = f"http://localhost:{host_port}/mcp"
535+
if verbose:
536+
hud_console.info(f"Waiting for server at {server_url} ...")
482537

483-
# Initialize client and measure timing
484-
from fastmcp import Client as FastMCPClient
538+
mcp_config: dict[str, Any] = {"hud": {"url": server_url, "auth": None}}
539+
server_name = "hud"
540+
else:
541+
docker_cmd = ["docker", "run", "--rm", "-i", *build_env_flags(env_vars), image]
542+
hud_console.dim_info("Command:", " ".join(docker_cmd))
485543

486-
from hud.cli.utils.mcp import analyze_environment
544+
from hud.cli.analyze import parse_docker_command
487545

488-
start_time = time.time()
489-
client = FastMCPClient(transport=mcp_config)
490-
initialized = False
546+
mcp_config = parse_docker_command(docker_cmd)
547+
server_name = next(iter(mcp_config.keys()), None)
548+
549+
# --- shared: connect, analyze, build result ---
550+
start_time = time.time()
551+
client = FastMCPClient(transport=mcp_config)
491552

492-
try:
493553
if verbose:
494554
hud_console.info("Initializing MCP client...")
495555

496-
# Add timeout to fail fast instead of hanging (60 seconds)
497-
await asyncio.wait_for(client.__aenter__(), timeout=60.0)
556+
if is_http:
557+
await wait_for_http_server( # type: ignore[possibly-undefined]
558+
server_url, timeout_seconds=60.0
559+
)
560+
await asyncio.wait_for(client.__aenter__(), timeout=60.0)
561+
else:
562+
await asyncio.wait_for(client.__aenter__(), timeout=60.0)
563+
498564
initialized = True
499565
initialize_ms = int((time.time() - start_time) * 1000)
500566

501-
# Delegate to standard analysis helper
502567
full_analysis = await analyze_environment(client, verbose, server_name=server_name)
503-
504-
# Normalize and enrich with internalTools if a hub map is present
505-
tools_list = full_analysis.get("tools", [])
506-
hub_map = full_analysis.get("hub_tools", {}) or full_analysis.get("hubTools", {})
507-
508-
normalized_tools: list[dict[str, Any]] = []
509-
internal_total = 0
510-
for t in tools_list:
511-
# Extract core fields (support object or dict forms)
512-
if hasattr(t, "name"):
513-
name = getattr(t, "name", None)
514-
description = getattr(t, "description", None)
515-
input_schema = getattr(t, "inputSchema", None)
516-
existing_internal = getattr(t, "internalTools", None)
517-
else:
518-
name = t.get("name")
519-
description = t.get("description")
520-
# accept either inputSchema or input_schema
521-
input_schema = t.get("inputSchema") or t.get("input_schema")
522-
# accept either internalTools or internal_tools
523-
existing_internal = t.get("internalTools") or t.get("internal_tools")
524-
525-
tool_entry: dict[str, Any] = {"name": name}
526-
if description:
527-
tool_entry["description"] = description
528-
if input_schema:
529-
tool_entry["inputSchema"] = input_schema
530-
531-
# Merge internal tools: preserve any existing declaration and add hub_map[name]
532-
merged_internal: list[str] = []
533-
if isinstance(existing_internal, list):
534-
merged_internal.extend([str(x) for x in existing_internal])
535-
if isinstance(hub_map, dict) and name in hub_map and isinstance(hub_map[name], list):
536-
merged_internal.extend([str(x) for x in hub_map[name]])
537-
if merged_internal:
538-
# Deduplicate while preserving order
539-
merged_internal = list(dict.fromkeys(merged_internal))
540-
tool_entry["internalTools"] = merged_internal
541-
internal_total += len(merged_internal)
542-
543-
normalized_tools.append(tool_entry)
544-
545-
result = {
546-
"initializeMs": initialize_ms,
547-
"toolCount": len(tools_list),
548-
"internalToolCount": internal_total,
549-
"tools": normalized_tools,
550-
"success": True,
551-
}
552-
if hub_map:
553-
result["hub_tools"] = hub_map
554-
# Include prompts and resources from analysis
555-
if full_analysis.get("prompts"):
556-
result["prompts"] = full_analysis["prompts"]
557-
if full_analysis.get("resources"):
558-
result["resources"] = full_analysis["resources"]
559-
if "scenarios" in full_analysis:
560-
result["scenarios"] = full_analysis["scenarios"]
561-
return result
568+
return _build_analysis_result(full_analysis, initialize_ms)
562569
except TimeoutError:
563570
from hud.shared.exceptions import HudException
564571

572+
if is_http:
573+
hud_console.error("MCP server did not become ready/initialize within 60 seconds")
574+
if container_name:
575+
hud_console.info("Check container logs: docker logs " + container_name)
576+
raise HudException("MCP server HTTP readiness timeout") from None
565577
hud_console.error("MCP server initialization timed out after 60 seconds")
566578
hud_console.info(
567579
"The server likely crashed during startup - check stderr logs with 'hud debug'"
@@ -570,16 +582,70 @@ async def analyze_mcp_environment(
570582
except Exception as e:
571583
from hud.shared.exceptions import HudException
572584

573-
# Convert to HudException for better error messages and hints
585+
if isinstance(e, HudException):
586+
raise
574587
raise HudException from e
575588
finally:
576-
# Only shutdown if we successfully initialized
577-
if initialized and client.is_connected():
578-
try:
589+
if initialized and client is not None:
590+
with contextlib.suppress(Exception):
579591
await client.close()
580-
except Exception:
581-
# Ignore shutdown errors
582-
hud_console.warning("Failed to shutdown MCP client")
592+
if container_name:
593+
stop_container(container_name)
594+
595+
596+
def _build_analysis_result(full_analysis: dict[str, Any], initialize_ms: int) -> dict[str, Any]:
597+
"""Normalize the raw analysis dict into the build-lock result format."""
598+
tools_list = full_analysis.get("tools", [])
599+
hub_map = full_analysis.get("hub_tools", {}) or full_analysis.get("hubTools", {})
600+
601+
normalized_tools: list[dict[str, Any]] = []
602+
internal_total = 0
603+
for t in tools_list:
604+
if hasattr(t, "name"):
605+
name = getattr(t, "name", None)
606+
description = getattr(t, "description", None)
607+
input_schema = getattr(t, "inputSchema", None)
608+
existing_internal = getattr(t, "internalTools", None)
609+
else:
610+
name = t.get("name")
611+
description = t.get("description")
612+
input_schema = t.get("inputSchema") or t.get("input_schema")
613+
existing_internal = t.get("internalTools") or t.get("internal_tools")
614+
615+
tool_entry: dict[str, Any] = {"name": name}
616+
if description:
617+
tool_entry["description"] = description
618+
if input_schema:
619+
tool_entry["inputSchema"] = input_schema
620+
621+
merged_internal: list[str] = []
622+
if isinstance(existing_internal, list):
623+
merged_internal.extend([str(x) for x in existing_internal])
624+
if isinstance(hub_map, dict) and name in hub_map and isinstance(hub_map[name], list):
625+
merged_internal.extend([str(x) for x in hub_map[name]])
626+
if merged_internal:
627+
merged_internal = list(dict.fromkeys(merged_internal))
628+
tool_entry["internalTools"] = merged_internal
629+
internal_total += len(merged_internal)
630+
631+
normalized_tools.append(tool_entry)
632+
633+
result: dict[str, Any] = {
634+
"initializeMs": initialize_ms,
635+
"toolCount": len(tools_list),
636+
"internalToolCount": internal_total,
637+
"tools": normalized_tools,
638+
"success": True,
639+
}
640+
if hub_map:
641+
result["hub_tools"] = hub_map
642+
if full_analysis.get("prompts"):
643+
result["prompts"] = full_analysis["prompts"]
644+
if full_analysis.get("resources"):
645+
result["resources"] = full_analysis["resources"]
646+
if "scenarios" in full_analysis:
647+
result["scenarios"] = full_analysis["scenarios"]
648+
return result
583649

584650

585651
def build_docker_image(

hud/cli/tests/test_analyze_module.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import pytest
77

88
from hud.cli.analyze import (
9+
_prepare_mcp_config,
910
analyze_environment,
1011
analyze_environment_from_config,
1112
analyze_environment_from_mcp_config,
@@ -125,3 +126,42 @@ async def test_analyze_environment_from_mcp_config(MockClient, mock_mcp_analyze)
125126
mcp_config = {"local": {"command": "docker", "args": ["run", "img"]}}
126127
await analyze_environment_from_mcp_config(mcp_config, output_format="json", verbose=False)
127128
assert client.__aenter__.awaited and client.close.awaited
129+
130+
131+
@patch("hud.cli.utils.mcp.analyze_environment")
132+
@patch("fastmcp.Client")
133+
async def test_analyze_environment_from_mcp_config_http(MockClient, mock_mcp_analyze):
134+
"""HTTP transport (hud dev) should inject auth=None to skip OAuth discovery."""
135+
client = MagicMock()
136+
client.__aenter__ = AsyncMock(return_value=client)
137+
client.is_connected = MagicMock(return_value=True)
138+
client.close = AsyncMock()
139+
MockClient.return_value = client
140+
mock_mcp_analyze.return_value = {"tools": [], "resources": []}
141+
142+
mcp_config = {"hud": {"url": "http://localhost:8000/mcp"}}
143+
await analyze_environment_from_mcp_config(mcp_config, output_format="json", verbose=False)
144+
assert client.__aenter__.awaited and client.close.awaited
145+
# Verify that _prepare_mcp_config injected auth=None
146+
call_kwargs = MockClient.call_args
147+
transport_arg = call_kwargs.kwargs.get("transport") or call_kwargs.args[0]
148+
assert transport_arg["hud"]["auth"] is None
149+
150+
151+
def test_prepare_mcp_config_injects_auth_for_url():
152+
"""URL-based entries get auth=None; stdio entries are left alone."""
153+
cfg = {
154+
"hud": {"url": "http://localhost:8000/mcp"},
155+
"local": {"command": "docker", "args": ["run", "img"]},
156+
}
157+
result = _prepare_mcp_config(cfg)
158+
assert result["hud"]["auth"] is None
159+
assert result["hud"]["url"] == "http://localhost:8000/mcp"
160+
assert "auth" not in result["local"]
161+
162+
163+
def test_prepare_mcp_config_preserves_explicit_auth():
164+
"""If auth is already set, don't overwrite it."""
165+
cfg = {"hud": {"url": "http://localhost:8000/mcp", "auth": "bearer-token"}}
166+
result = _prepare_mcp_config(cfg)
167+
assert result["hud"]["auth"] == "bearer-token"

0 commit comments

Comments
 (0)