Skip to content

MCP Apps

An MCP App is a tool with a face: alongside its data, the tool points at an HTML document the host renders as an interactive surface.

Two parts, always two parts:

  1. A tool that does the work and returns data, like any other tool.
  2. A ui:// resource containing the HTML the host shows for it.

The tool carries a _meta.ui.resourceUri reference to the resource. The host fetches it with resources/read, renders it in a sandboxed iframe, and pushes the tool's result into that iframe via postMessage. Your server never sends or receives any ui/* messages: that traffic is between the host and the iframe. You serve a tool and an HTML document; the host does the theater.

The SDK ships this as the built-in Apps extension (io.modelcontextprotocol/ui). If Extensions are new to you, skim that page first. One minute, then come back.

A clock with a face

server.py
from mcp import Client
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context

CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
  window.addEventListener("message", (event) => {
    const text = event.data?.result?.content?.[0]?.text;
    if (text) document.getElementById("now").textContent = text;
  });
</script>
"""

apps = Apps()


@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
    now = "2026-06-26T12:00:00Z"
    if not client_supports_apps(ctx):
        return f"The time is {now}."
    return now


apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")

mcp = MCPServer("clock", extensions=[apps])


async def main() -> None:
    async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
        result = await client.call_tool("get_time", {})
        print(result.content)
        # [TextContent(text='2026-06-26T12:00:00Z')]

Four moves:

  • Apps(): one instance holds your UI-bound tools and their resources.
  • @apps.tool(resource_uri="ui://clock/app.html"): a regular tool, plus the _meta.ui.resourceUri stamp. Everything @mcp.tool() accepts (name, title, description, ...) passes through.
  • apps.add_html_resource("ui://clock/app.html", CLOCK_HTML): the matching resource, served as text/html;profile=mcp-app. That exact MIME type is what tells a host "this is an app, render it".
  • MCPServer("clock", extensions=[apps]): opt in. The server now advertises io.modelcontextprotocol/ui under capabilities.extensions.

The HTML itself listens for the host's postMessage and shows the result. For real apps, use the official @modelcontextprotocol/ext-apps browser SDK inside your HTML. It gives you ontoolresult, callServerTool, getHostContext, and onhostcontextchanged instead of raw message events.

Graceful degradation

Not every client renders apps. The spec is blunt about what that means for you:

Tools MUST return a meaningful content array even when UI is available.

The model reads content; the iframe is for humans. A UI-capable host still feeds the text result to the model, and a text-only client gets only that. So the canonical pattern is one tool, two answers. Look at get_time again:

server.py
from mcp import Client
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context

CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
  window.addEventListener("message", (event) => {
    const text = event.data?.result?.content?.[0]?.text;
    if (text) document.getElementById("now").textContent = text;
  });
</script>
"""

apps = Apps()


@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
    now = "2026-06-26T12:00:00Z"
    if not client_supports_apps(ctx):
        return f"The time is {now}."
    return now


apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")

mcp = MCPServer("clock", extensions=[apps])


async def main() -> None:
    async with Client(mcp, extensions={EXTENSION_ID: {"mimeTypes": [APP_MIME_TYPE]}}) as client:
        result = await client.call_tool("get_time", {})
        print(result.content)
        # [TextContent(text='2026-06-26T12:00:00Z')]

client_supports_apps(ctx) is True only when the client declared the io.modelcontextprotocol/ui extension and listed text/html;profile=mcp-app in its mimeTypes settings. The field is required, so a client that omits it does not count. That is exactly what main() in the same file declares: the client half of the negotiation, and the rich answer comes back.

Warning

Never return a placeholder like "[Rendered UI]" as the only content. If the fallback text is useless, the tool is useless to every text-only client and to the model itself. Write the sentence.

Locking the iframe down

The resource side carries the security metadata: what the iframe may load, which browser permissions it wants, how it would like to be framed:

server.py
from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
from mcp.server.mcpserver import MCPServer

DASHBOARD_HTML = "<!doctype html><title>Dashboard</title><canvas id='chart'></canvas>"

apps = Apps()


@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
def refresh_dashboard() -> str:
    """Refresh the dashboard data."""
    return "refreshed"


apps.add_html_resource(
    "ui://dashboard/app.html",
    DASHBOARD_HTML,
    title="Dashboard",
    csp=ResourceCsp(connect_domains=["https://api.example.com"]),
    permissions=ResourcePermissions(clipboard_write={}),
    domain="dashboard.example.com",
    prefers_border=True,
)

mcp = MCPServer("dashboard", extensions=[apps])

csp and permissions are requests to the host, not server behaviour. The host builds the iframe's Content-Security-Policy and Permissions-Policy from them, and it may refuse. Feature-detect in your JS rather than assuming a grant.

ResourceCsp, field by field (Python name, wire key, what the host does with it):

Python Wire (_meta.ui.csp) Controls
connect_domains connectDomains connect-src: where fetch/XHR may go
resource_domains resourceDomains img-src, style-src, ...: static assets
frame_domains frameDomains frame-src: nested iframes
base_uri_domains baseUriDomains base-uri: what <base> may point at

ResourcePermissions: each field requests a browser permission for the iframe.

Python Wire (_meta.ui.permissions)
camera camera
microphone microphone
geolocation geolocation
clipboard_write clipboardWrite

Note

CSP and permissions live on the resource, never on the tool. The spec's tool metadata has no slot for them, and hosts ignore them there. The SDK makes the mistake unrepresentable: @apps.tool() simply has no csp parameter.

Visibility

visibility=["app"] on a tool says "this exists for the iframe, not the model":

  • "model": the model may call it.
  • "app": the iframe may call it (via callServerTool).
  • Omitted: both, which is the default.

Filtering is the host's job. Your server lists app-only tools in tools/list like any other; the host hides them from the model. Don't filter server-side.

The rules the SDK enforces

All of these fail at startup, not in production:

  • A resource_uri or resource URI that isn't ui://... is a ValueError at decoration/registration time.
  • A tool bound to a URI with no matching registered resource is a ValueError when MCPServer(extensions=[apps]) consumes the extension. A tool advertising HTML that 404s on resources/read is a misconfiguration, so it refuses to construct.
  • meta={"ui": ...} on @apps.tool() is a ValueError. The decorator owns _meta["ui"]; say it with resource_uri= and visibility=. Other meta= keys merge fine alongside.

Neither the TypeScript ext-apps SDK nor FastMCP catches any of these today; we'd rather you find out before a host does.

Beyond inline HTML

add_html_resource covers the common case: a string of HTML. For anything else, HTML on disk or generated content, build the resource yourself and hand it over:

server.py
from pathlib import Path

from mcp.server.apps import Apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FileResource

REPORT_HTML = Path(__file__).parent / "report.html"

apps = Apps()


@apps.tool(resource_uri="ui://report/app.html")
def refresh_report() -> str:
    """Refresh the report data."""
    return "report refreshed"


apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))

mcp = MCPServer("report", extensions=[apps])

add_resource fills in the text/html;profile=mcp-app MIME type when the resource doesn't set one explicitly, and rejects an explicit mismatch: a ui:// resource under any other MIME type is one no host will render.

Tip

Targeting a pre-GA host that still reads the deprecated flat _meta["ui/resourceUri"] key? Merge it yourself: @apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}). The nested ui object is the spec shape; the flat key is on its way out.

See it run

The apps story in examples/stories/ is this page as a runnable pair: a server with a UI-bound clock tool and a client that negotiates Apps, reads the tool's _meta.ui.resourceUri, fetches the HTML, and calls the tool.

uv run python -m stories.apps.client