@@ -461,107 +461,119 @@ def collect_runtime_metadata(image: str, *, verbose: bool = False) -> dict[str,
461461async 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
585651def build_docker_image (
0 commit comments