"""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 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
logger = logging.getLogger(__name__)
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.
8. The tool returns JSON with the rendered component metadata. Do not rely on the exact JSON shape in your response; just confirm success to the user.
"""
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}"
# 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": payload,
}
},
},
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()