Skip to content

MCP-UI Resources#

MCP-UI lets an MCP server return a renderable UI — raw HTML, an iframe URL, or a Remote-DOM script — from a tool result instead of plain text. A UI-capable client (e.g. the JS @mcp-ui/client) renders it inline; a text-only client falls back to the embedded resource.

AG2 builds these resources and the interactive callbacks (UI Actions) in ag2.mcp_ui. They are returned from the serving side of ag2.mcp — an AG2 Agent exposed as an MCP server — so this page starts with the minimum you need to serve one, then focuses on the UI helpers.

This is the serving side of MCP

ag2.mcp.MCPServer exposes an agent as an MCP server. This is the inverse of consuming an MCP server as tools with MCPToolkit / MCPServerTool — see MCP Servers for that.

Installation#

ag2.mcp_ui ships as an optional extra (it is not pulled in by ag2[mcp]):

pip install "ag2[mcp-ui]"

Importing ag2.mcp_ui without it raises a clear install hint.

Serving a UI resource#

MCPServer wraps an agent as a single conversational ask tool. To return a UI resource you add a custom tool with the @mcp_tool decorator: the client sees it in tools/list, and a tools/call runs your handler directly — the agent is not invoked, so the result is deterministic. The handler returns the MCP content block(s) verbatim, which is exactly what raw_html(...) produces.

import asyncio
from typing import Any

from ag2 import Agent
from ag2.config import AnthropicConfig
from ag2.mcp import MCPServer, mcp_tool
from ag2.mcp_ui import raw_html

@mcp_tool
async def show_greeting(name: str = "world") -> Any:
    """Return an interactive greeting card."""
    return raw_html("ui://ag2/greeting", f"<h1>Hello, {name} 👋</h1>")

agent = Agent(name="assistant", config=AnthropicConfig(model="claude-haiku-4-5-20251001"))
server = MCPServer(agent, tools=[show_greeting])

if __name__ == "__main__":
    asyncio.run(server.run_stdio())

@mcp_tool (from ag2.mcp) derives the tool name from the function name, the description from its docstring, and the inputSchema from the typed signature — the same convention as ag2.tools.tool. The handler may be sync or async, and may return a plain string (wrapped in a text block) instead of content blocks. Point a UI-capable MCP client (or the MCP Inspector) at this stdio server, call show_greeting, and the HTML renders inline.

Optional title and annotations keyword arguments are advertised in tools/list; annotations takes mcp.types.ToolAnnotations behavior hints (readOnlyHint, destructiveHint, …) that hosts use to decide e.g. whether to confirm with the user before calling:

from typing import Any

from mcp.types import ToolAnnotations

from ag2.mcp import mcp_tool
from ag2.mcp_ui import external_url

@mcp_tool(annotations=ToolAnnotations(readOnlyHint=True))
async def show_docs() -> Any:
    """Return the AG2 docs embedded in an iframe."""
    return external_url("ui://ag2/docs", "https://docs.ag2.ai/")

Why a custom tool and not the ask tool

The agent's ask tool replies with model-generated text/images, so it cannot emit a deterministic UI blob on its own. Always return UI resources from your own @mcp_tool.

Accessing the request context#

Annotate a parameter (any name) with ag2.mcp.tools.MCPRequestContext to receive the live MCP request context — the session, client parameters, and lifespan state. The parameter is excluded from the advertised inputSchema, so clients never see it:

1
2
3
4
5
6
7
8
from ag2.mcp import mcp_tool
from ag2.mcp.tools import MCPRequestContext

@mcp_tool
async def whoami(ctx: MCPRequestContext) -> str:
    """Report the calling client."""
    params = ctx.session.client_params
    return f"client: {params.clientInfo.name if params else 'unknown'}"

UI resource types#

ag2.mcp_ui provides three builders, one per MCP-UI content type. Each returns an mcp.types.EmbeddedResource — a valid tools/call content block — and each uri must start with ui://.

Builder Content type Client renders it as
raw_html(uri, html) rawHtml an inline HTML string
external_url(uri, url) externalUrl url embedded in an iframe
remote_dom(uri, script) remoteDom a mounted Remote-DOM script
1
2
3
4
5
6
7
8
from ag2.mcp_ui import external_url, raw_html, remote_dom

# Inline HTML
raw_html("ui://ag2/greeting", "<h1>Hi</h1>")
# External site in an iframe
external_url("ui://ag2/docs", "https://docs.ag2.ai/")
# Remote-DOM script (framework="react" by default, or "webcomponents")
remote_dom("ui://ag2/widget", "root.appendChild(el)", framework="react")

Every builder accepts encoding="blob" to base64-encode the payload for transports or content that don't survive as inline text (the default is encoding="text"):

raw_html("ui://ag2/report", large_html, encoding="blob")

Rendering metadata#

Two optional keyword arguments attach metadata to the resource:

  • ui_metadata — client-side rendering hints. Keys are prefixed with mcpui.dev/ui- so the client recognizes them.
  • metadata — written verbatim into the resource _meta.
1
2
3
4
5
6
raw_html(
    "ui://ag2/chart",
    "<div id='chart'>Loading…</div>",
    ui_metadata={"preferred-frame-size": ["800px", "600px"]},
    metadata={"title": "Quarterly sales"},
)

UI Actions#

A rendered UI can carry interactive elements. When the user interacts, the element sends a message to the host with window.parent.postMessage, and the host's onUIAction handler reacts. ag2.mcp_ui builds those message payloads.

UI Actions are dispatched by the host, not your server

Unlike ag2.a2ui actions, these are not server-side handlers. The MCP-UI spec puts the host in control: a UI Action is dispatched client-side (onUIAction). A tool action just asks the host to call an ordinary MCP tool on your server — expose that tool the normal way; there is no server-side "action handler" to register.

These are authoring helpers: embed the built action into the UI with post_message(action), which returns an HTML-attribute-safe window.parent.postMessage(...) call (quotes and apostrophes in the payload are escaped, so they cannot break out of the attribute).

1
2
3
4
5
6
from ag2.mcp_ui import post_message, raw_html, tool_call

# A `tool` action: on click, the host calls tools/call add_to_cart(good_id="42").
action = tool_call("add_to_cart", {"good_id": "42"})
html = f'<button onclick="{post_message(action)}">Add to cart</button>'
resource = raw_html("ui://ag2/shop", html)

The five action types map one-to-one to the spec:

Builder Action What the host does
tool_call(name, params) tool call the MCP tool name(params) (tools/call)
prompt(text) prompt send text into the conversation as a follow-up
link(url) link open url
intent(name, params) intent emit a host-defined name intent with params
notify(message) notify receive message as a notification / log

Closing the loop with a tool action#

Because a tool action asks the host to call tools/call, you close the interaction loop by exposing the target as another @mcp_tool on the same server:

from typing import Any

from ag2.mcp import mcp_tool
from ag2.mcp_ui import post_message, raw_html, tool_call

@mcp_tool
async def show_greeting(name: str = "world") -> Any:
    """Return a greeting card with an 'Add to cart' button."""
    action = tool_call("add_to_cart", {"good_id": "42"})
    html = f'<h1>Hello, {name} 👋</h1><button onclick="{post_message(action)}">Add to cart</button>'
    return raw_html("ui://ag2/greeting", html)

@mcp_tool
async def add_to_cart(good_id: str) -> Any:
    """Target of the greeting card's UI Action; returns an updated card."""
    return raw_html("ui://ag2/cart", f"<b>Added item {good_id} to cart ✓</b>")

Wire both into MCPServer(agent, tools=[show_greeting, add_to_cart]). When the user clicks the button, the host turns the tool action into a tools/call add_to_cart back to your server, and the returned card replaces the UI.

Runnable example

A complete stdio server — greeting card, an iframe of the docs, and the add_to_cart loop — lives in examples/mcp/server_ui.py.