"""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 tool validates the payload against the registered component schema,
then returns a result with ``metadata.ui_component`` that the agent stores on
the ``role="tool"`` message for this call. The webclient then renders the
named component with the supplied data inside the same assistant message as
the rest of the turn.
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 json
import logging
from typing import Any
import structlog
from mcp.server import FastMCP
from .components import ComponentRegistry, discover_components
logger = logging.getLogger(__name__)
_registry = discover_components()
def _build_instructions(registry: ComponentRegistry) -> str:
return f"""\
Internal Navi UI server. Use render_component to show structured UI in the webclient.
Tool: render_component(component_name, payload, session_id)
- component_name: one of the supported component identifiers below.
- payload: JSON object matching the component schema.
- session_id: DO NOT pass manually. It is injected automatically by the agent.
{registry.instructions()}
"""
mcp = FastMCP(
"navi_ui",
host="127.0.0.1",
port=8001,
streamable_http_path="/mcp",
instructions=_build_instructions(_registry),
)
async def _get_registry() -> ComponentRegistry:
"""Return the global component registry.
Kept async for future hot-reload support without changing the tool
signature.
"""
return _registry
@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. "card_grid").
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 validation 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)"
registry = await _get_registry()
component_cls = registry.get(component_name)
if component_cls is None:
supported = ", ".join(registry.list_names()) or "none"
return (
f"Error: unknown component {component_name!r}. "
f"Supported components: {supported}."
)
ok, error, validated = component_cls.validate(payload)
if not ok:
return f"Error: {error}"
# Return a structured JSON payload. The agent stores the metadata on the
# role="tool" message for this call, and the webclient renders the component
# from that metadata. This keeps the component inside the same assistant
# turn as the rest of the tools and the final text response.
return json.dumps(
{
"output": f"Component {component_name!r} rendered for session {session_id}",
"metadata": {
"ui_component": {
"component": component_name,
"payload": validated,
}
},
},
ensure_ascii=False,
)
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()