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:
- A tool that does the work and returns data, like any other tool.
- 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
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.resourceUristamp. Everything@mcp.tool()accepts (name, title, description, ...) passes through.apps.add_html_resource("ui://clock/app.html", CLOCK_HTML): the matching resource, served astext/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 advertisesio.modelcontextprotocol/uiundercapabilities.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
contentarray 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:
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:
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 (viacallServerTool).- 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_urior resource URI that isn'tui://...is aValueErrorat decoration/registration time. - A tool bound to a URI with no matching registered resource is a
ValueErrorwhenMCPServer(extensions=[apps])consumes the extension. A tool advertising HTML that 404s onresources/readis a misconfiguration, so it refuses to construct. meta={"ui": ...}on@apps.tool()is aValueError. The decorator owns_meta["ui"]; say it withresource_uri=andvisibility=. Othermeta=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:
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