"""Internal MCP server that lets Navi push UI components to the webclient.
The server exposes a single tool, ``render_component``. When Navi calls it,
the payload is forwarded over the active WebSocket for the session as a
``ui_component`` event. The webclient receives the event and renders the
named component with the supplied data.
This server is started by ``navi.main:lifespan`` on a dedicated port
(``NAVI_UI_MCP_PORT``, default 8001) before the main container is built so
that :class:`navi.mcp.McpManager` can connect to it during startup like any
other MCP server.
"""
from __future__ import annotations
import asyncio
import logging
from typing import Any
import structlog
from mcp.server import FastMCP
logger = logging.getLogger(__name__)
# Shared holder: the lifespan task sets the orchestrator once the main
# FastAPI container is ready. The tool waits (with a timeout) so that early
# MCP health-check pings or tool calls do not crash before startup completes.
_orchestrator: Any | None = None
_orchestrator_ready = asyncio.Event()
_ORCHESTRATOR_TIMEOUT = 10.0
# Component schema documentation embedded into server instructions.
# This is separate from the webclient implementation; both must agree on the
# same contract, but the server only validates the envelope.
SERVER_INSTRUCTIONS = """\
Internal Navi UI server. Use render_component to show structured UI in the webclient.
Tool: render_component(component_name, payload, session_id)
- component_name: only "card_grid" is supported.
- payload: JSON object. See schema below.
- session_id: DO NOT pass manually. It is injected automatically by the agent.
## card_grid payload schema
{
"title": "Optional section heading",
"cards": [
{
"id": "required-string-id",
"title": "Required card title",
"subtitle": "Optional secondary line",
"image": "https://optional-direct-image-url",
"meta": [
{"label": "Label", "value": "Value"}
],
"description": "Optional short one-line summary",
"details": [
{"label": "Detail label", "value": "Detail value"}
],
"actions": [
{"label": "Button label", "url": "https://..."}
]
}
]
}
Hard rules:
1. component_name must be "card_grid".
2. payload.cards is required and must be a non-empty list.
3. Every card must have id (string) and title (string).
4. Limit to 4 cards in one call. If there are more, say there are more and ask if the user wants them.
5. image must be a direct URL, never a local path.
6. Put the full information that should appear after a card click into details.
7. After calling render_component, still provide a short text summary and offer next steps.
"""
mcp = FastMCP(
"navi_ui",
host="127.0.0.1",
port=8001,
streamable_http_path="/mcp",
instructions=SERVER_INSTRUCTIONS,
)
def _is_valid_card_grid(payload: dict[str, Any]) -> tuple[bool, str]:
"""Lightweight structural validation for the card_grid component.
Returns (ok, error_message). Does not validate URL reachability.
"""
cards = payload.get("cards")
if not isinstance(cards, list):
return False, "card_grid payload must contain a 'cards' list"
if len(cards) == 0:
return False, "card_grid 'cards' list must not be empty"
for idx, card in enumerate(cards):
if not isinstance(card, dict):
return False, f"card at index {idx} must be an object"
if not isinstance(card.get("id"), str) or not card["id"]:
return False, f"card at index {idx} must have a non-empty string 'id'"
if not isinstance(card.get("title"), str) or not card["title"]:
return False, f"card at index {idx} must have a non-empty string 'title'"
return True, ""
@mcp.tool()
async def render_component(
component_name: str,
payload: dict[str, Any],
session_id: str | None = None,
) -> str:
"""Render a UI component in the webclient for the given session.
Args:
component_name: Identifier of the registered UI component (e.g. "table").
payload: JSON-serializable data object consumed by that component.
session_id: Navi session id. Injected automatically by the agent context.
Returns:
A short confirmation string, or an error message if forwarding failed.
"""
if not component_name or not isinstance(component_name, str):
return "Error: component_name must be a non-empty string"
if not isinstance(payload, dict):
return "Error: payload must be a JSON object (dict)"
if not session_id:
return "Error: session_id is required (it is normally injected by the agent)"
if component_name == "card_grid":
ok, error = _is_valid_card_grid(payload)
if not ok:
return f"Error: {error}"
try:
await asyncio.wait_for(
_orchestrator_ready.wait(),
timeout=_ORCHESTRATOR_TIMEOUT,
)
except asyncio.TimeoutError:
return "Error: UI server is not ready (orchestrator unavailable)"
orchestrator = _orchestrator
if orchestrator is None:
return "Error: UI server orchestrator reference is missing"
try:
await orchestrator._notify_session(
session_id,
{
"type": "ui_component",
"component": component_name,
"payload": payload,
},
)
except Exception as exc:
logger.warning("render_component failed for session %s: %s", session_id, exc)
return f"Error: failed to send component to session: {exc}"
return f"Component {component_name!r} rendered for session {session_id}"
def set_orchestrator(orchestrator: Any) -> None:
"""Wire the active orchestrator so the tool can reach WebSocket clients."""
global _orchestrator
_orchestrator = orchestrator
_orchestrator_ready.set()
logger.info("navi_ui orchestrator wired")
def clear_orchestrator() -> None:
"""Clear the orchestrator reference, e.g. during graceful shutdown."""
global _orchestrator
_orchestrator = None
_orchestrator_ready.clear()
async def start_ui_server(host: str, port: int) -> None:
"""Start the StreamableHTTP MCP server and run forever.
This coroutine is intended to be scheduled as a background task from the
main application lifespan. It only returns when the server is stopped.
"""
log = structlog.get_logger()
log.info("navi_ui_mcp_server_starting", host=host, port=port)
# FastMCP copies constructor args into its settings object, so update them
# explicitly to honour runtime configuration.
mcp.settings.host = host
mcp.settings.port = port
await mcp.run_streamable_http_async()