"""Interactive TUI for MCP server discovery and installation.
A navigable terminal interface built with Rich. Browse categories,
search servers, view details, generate configs, and install -- all
with numbered menus instead of free-form commands.
Usage:
haive-mcp self-query
python -m haive.mcp self-query
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from rich.console import Console
from rich.panel import Panel
from rich.prompt import Confirm, IntPrompt, Prompt
from rich.table import Table
from rich.text import Text
from haive.mcp.self_query import MCPSelfQuery
console = Console()
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _make_server_table(
servers: list[dict[str, Any]],
title: str = "Servers",
show_index: bool = True,
) -> Table:
"""Build a Rich table of servers."""
table = Table(title=title, show_lines=False, pad_edge=False, expand=True)
if show_index:
table.add_column("#", style="bold cyan", width=4, justify="right")
table.add_column("Name", style="bold white", ratio=3)
table.add_column("Category", style="dim", ratio=1)
table.add_column("Install Command", style="green", ratio=3)
for i, s in enumerate(servers, 1):
name = s.get("name", "?")
cat = s.get("category", "")
cmd = s.get("install_command", "")
row = [name, cat, cmd or "[dim]--[/dim]"]
if show_index:
row.insert(0, str(i))
table.add_row(*row)
return table
def _make_detail_panel(detail: dict[str, Any]) -> Panel:
"""Build a Rich panel with server detail."""
lines: list[str] = []
if detail.get("description"):
lines.append(f"[italic]{detail['description'][:200]}[/italic]")
lines.append("")
fields = [
("Category", detail.get("category")),
("Repository", detail.get("repository_url")),
("Install", detail.get("install_command")),
("Stars", detail.get("stars")),
("Languages", ", ".join(detail.get("languages", [])) or None),
("License", detail.get("license")),
]
for label, value in fields:
if value:
lines.append(f"[bold]{label}:[/bold] {value}")
setup = detail.get("setup_info", {})
if setup.get("installation"):
lines.append("")
lines.append("[bold]Install steps:[/bold]")
for step in setup["installation"][:5]:
lines.append(f" [green]$ {step}[/green]")
if setup.get("capabilities"):
lines.append("")
lines.append(f"[bold]Capabilities:[/bold] {', '.join(setup['capabilities'][:8])}")
return Panel(
"\n".join(lines) if lines else "[dim]No details available[/dim]",
title=f"[bold cyan]{detail.get('name', '?')}[/bold cyan]",
border_style="cyan",
padding=(1, 2),
)
# ------------------------------------------------------------------
# Screens
# ------------------------------------------------------------------
def _show_main_menu(sq: MCPSelfQuery) -> str:
"""Show the main menu and return the user's choice."""
console.print()
console.print(
Panel(
f"[bold]haive-mcp[/bold] ยท [cyan]{sq.server_count}[/cyan] MCP servers",
border_style="bright_blue",
padding=(0, 2),
)
)
console.print()
console.print(" [bold cyan]1[/bold cyan] Search servers")
console.print(" [bold cyan]2[/bold cyan] Browse categories")
console.print(" [bold cyan]3[/bold cyan] Install a server")
console.print(" [bold cyan]4[/bold cyan] Generate config")
console.print(" [bold cyan]q[/bold cyan] Quit")
console.print()
return Prompt.ask("Choose", choices=["1", "2", "3", "4", "q"], default="1")
def _search_screen(sq: MCPSelfQuery) -> list[dict[str, Any]]:
"""Search screen -- returns selected results."""
query = Prompt.ask("\n[bold]Search[/bold]")
if not query.strip():
return []
results = sq.search(query, limit=15)
if not results:
console.print(f"[dim]No servers found for '{query}'[/dim]")
return []
# Enrich with install commands
enriched = []
for r in results:
detail = sq.get_server_detail(r["name"])
enriched.append({
**r,
"install_command": (detail or {}).get("install_command", ""),
})
console.print()
console.print(_make_server_table(enriched, title=f"Results for '{query}'"))
return enriched
def _pick_server(servers: list[dict[str, Any]], sq: MCPSelfQuery) -> dict[str, Any] | None:
"""Let user pick a server from a list, then show detail."""
if not servers:
return None
console.print()
try:
idx = IntPrompt.ask(
"Select server # [dim](0 to go back)[/dim]",
default=0,
)
except (KeyboardInterrupt, EOFError):
return None
if idx < 1 or idx > len(servers):
return None
server = servers[idx - 1]
detail = sq.get_server_detail(server["name"])
if detail:
console.print()
console.print(_make_detail_panel(detail))
return detail
def _categories_screen(sq: MCPSelfQuery) -> list[dict[str, Any]]:
"""Browse categories and pick one."""
cats = sq.get_categories()
cat_list = list(cats.items())
table = Table(title="Categories", show_lines=False, expand=True)
table.add_column("#", style="bold cyan", width=4, justify="right")
table.add_column("Category", style="bold white", ratio=2)
table.add_column("Servers", style="dim", ratio=1, justify="right")
for i, (cat, count) in enumerate(cat_list, 1):
table.add_row(str(i), cat, str(count))
console.print()
console.print(table)
console.print()
try:
idx = IntPrompt.ask(
"Select category # [dim](0 to go back)[/dim]",
default=0,
)
except (KeyboardInterrupt, EOFError):
return []
if idx < 1 or idx > len(cat_list):
return []
cat_name = cat_list[idx - 1][0]
results = sq.loader.search_servers_by_category(cat_name)
enriched = []
for r in results[:20]:
name = r.get("name", "?")
detail = sq.get_server_detail(name)
enriched.append({
**r,
"install_command": (detail or {}).get("install_command", ""),
})
console.print()
console.print(_make_server_table(enriched, title=f"Category: {cat_name}"))
return enriched
def _install_screen(sq: MCPSelfQuery) -> None:
"""Full install flow: search โ pick โ approve โ connect."""
query = Prompt.ask("\n[bold]Search to install[/bold]")
if not query.strip():
return
results = sq.search(query, limit=10)
if not results:
console.print(f"[dim]No servers found for '{query}'[/dim]")
return
enriched = []
for r in results:
detail = sq.get_server_detail(r["name"])
enriched.append({
**r,
"install_command": (detail or {}).get("install_command", ""),
})
console.print()
console.print(_make_server_table(enriched, title=f"Install: '{query}'"))
console.print()
try:
idx = IntPrompt.ask(
"Select server to install # [dim](0 to cancel)[/dim]",
default=0,
)
except (KeyboardInterrupt, EOFError):
return
if idx < 1 or idx > len(enriched):
return
server = enriched[idx - 1]
# Plan
from haive.mcp.installer_service import MCPInstallerService
async def _do_install():
svc = MCPInstallerService(require_approval=False)
plan = await svc.plan_install(server["name"])
if plan is None:
console.print("[red]Could not create install plan.[/red]")
return
console.print()
console.print(Panel(
f"[bold]Server:[/bold] {plan.server_name}\n"
f"[bold]Command:[/bold] [green]{plan.install_command}[/green]\n"
f"[bold]Method:[/bold] {plan.method.value} ({plan.confidence:.0%} confidence)"
+ (f"\n[bold]Repo:[/bold] {plan.repository_url}" if plan.repository_url else ""),
title="[bold yellow]Install Plan[/bold yellow]",
border_style="yellow",
padding=(1, 2),
))
console.print()
if not Confirm.ask("Proceed with installation?", default=True):
console.print("[dim]Cancelled.[/dim]")
return
with console.status("[bold green]Connecting..."):
result = await svc.install(plan)
if result.success:
console.print(f"\n[bold green]Success![/bold green] {result.message}")
if result.tools_discovered:
tool_table = Table(title="Discovered Tools", show_lines=False)
tool_table.add_column("Tool", style="green")
for t in result.tools_discovered:
tool_table.add_row(t)
console.print()
console.print(tool_table)
else:
console.print(f"\n[bold red]Failed:[/bold red] {result.message}")
# Offer config export
console.print()
if Confirm.ask("Export config?", default=True):
_show_configs(svc, plan.server_name)
asyncio.run(_do_install())
def _config_screen(sq: MCPSelfQuery) -> None:
"""Generate configs for a server."""
query = Prompt.ask("\n[bold]Server name or search[/bold]")
if not query.strip():
return
# Try direct match first, then search
config = sq.loader.generate_server_config(query)
server_name = query
if config is None:
results = sq.search(query, limit=5)
if not results:
console.print(f"[dim]No servers found for '{query}'[/dim]")
return
enriched = []
for r in results:
detail = sq.get_server_detail(r["name"])
enriched.append({**r, "install_command": (detail or {}).get("install_command", "")})
console.print()
console.print(_make_server_table(enriched, title="Select server"))
console.print()
try:
idx = IntPrompt.ask("Select # [dim](0 to cancel)[/dim]", default=0)
except (KeyboardInterrupt, EOFError):
return
if idx < 1 or idx > len(enriched):
return
server_name = enriched[idx - 1]["name"]
config = sq.loader.generate_server_config(server_name)
if config is None:
console.print("[dim]Could not generate config.[/dim]")
return
from haive.mcp.installer_service import MCPInstallerService
svc = MCPInstallerService(require_approval=False)
_show_configs(svc, server_name)
def _show_configs(svc: Any, server_name: str) -> None:
"""Display configs in all formats."""
lc = svc.generate_langchain_config(server_name)
claude = svc.generate_claude_desktop_config(server_name)
if lc:
console.print(Panel(
json.dumps(lc, indent=2),
title="[bold]langchain-mcp-adapters[/bold]",
border_style="green",
))
if claude:
console.print(Panel(
json.dumps(claude, indent=2),
title="[bold]Claude Desktop (mcp.json)[/bold]",
border_style="blue",
))
# ------------------------------------------------------------------
# Main loop
# ------------------------------------------------------------------
[docs]
def run_tui():
"""Run the navigable TUI."""
sq = MCPSelfQuery()
while True:
try:
choice = _show_main_menu(sq)
except (KeyboardInterrupt, EOFError):
console.print("\n[dim]Bye![/dim]")
break
if choice == "q":
console.print("[dim]Bye![/dim]")
break
elif choice == "1":
servers = _search_screen(sq)
if servers:
detail = _pick_server(servers, sq)
if detail and detail.get("install_command"):
console.print()
if Confirm.ask("Install this server?", default=False):
_install_from_detail(sq, detail)
elif choice == "2":
servers = _categories_screen(sq)
if servers:
detail = _pick_server(servers, sq)
if detail and detail.get("install_command"):
console.print()
if Confirm.ask("Install this server?", default=False):
_install_from_detail(sq, detail)
elif choice == "3":
_install_screen(sq)
elif choice == "4":
_config_screen(sq)
def _install_from_detail(sq: MCPSelfQuery, detail: dict[str, Any]) -> None:
"""Install a server from its detail dict."""
from haive.mcp.installer_service import MCPInstallerService
async def _go():
svc = MCPInstallerService(require_approval=False)
plan = await svc.plan_install(detail["name"])
if plan is None:
console.print("[red]Could not plan install.[/red]")
return
console.print(f"\n [green]{plan.install_command}[/green]")
console.print()
if not Confirm.ask("Proceed?", default=True):
return
with console.status("[bold green]Connecting..."):
result = await svc.install(plan)
if result.success:
console.print(f"[bold green]Success![/bold green] {result.message}")
if result.tools_discovered:
for t in result.tools_discovered[:10]:
console.print(f" [green]ยท[/green] {t}")
else:
console.print(f"[bold red]Failed:[/bold red] {result.message}")
asyncio.run(_go())