MCP Apps
Maschinelle Übersetzung
Diese Seite wurde automatisch aus der englischen Dokumentation übersetzt, und die englische Seite ist die maßgebliche Fassung. Wenn sich etwas falsch liest, erklärt Übersetzungen, wie du es melden kannst.
Eine MCP App ist ein Tool mit Gesicht: Neben seinen Daten verweist das Tool auf ein HTML-Dokument, das der Host als interaktive Oberfläche rendert.
Zwei Teile, immer zwei Teile:
- Ein Tool, das die Arbeit macht und Daten zurückgibt, wie jedes andere Tool auch.
- Eine
ui://-Ressource mit dem HTML, das der Host dafür anzeigt.
Das Tool trägt eine _meta.ui.resourceUri-Referenz auf die Ressource. Der Host holt sie mit resources/read, rendert sie in einem Sandbox-iframe und schiebt das Ergebnis des Tools per postMessage in diesen iframe. Dein Server sendet oder empfängt niemals ui/*-Nachrichten: Dieser Verkehr läuft zwischen Host und iframe. Du lieferst ein Tool und ein HTML-Dokument; das Theater übernimmt der Host.
Das SDK liefert das als eingebaute Extension Apps (io.modelcontextprotocol/ui) mit. Falls Extensions neu für dich sind, überfliege zuerst jene Seite. Eine Minute, dann komm zurück.
Eine Uhr mit Gesicht
from mcp import Client
from mcp.client import advertise
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=[advertise(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')]
Vier Schritte:
Apps(): Eine Instanz hält deine UI-gebundenen Tools und ihre Ressourcen.@apps.tool(resource_uri="ui://clock/app.html"): ein normales Tool plus der_meta.ui.resourceUri-Stempel. Alles, was@mcp.tool()akzeptiert (name, title, description, ...), wird durchgereicht.apps.add_html_resource("ui://clock/app.html", CLOCK_HTML): die passende Ressource, ausgeliefert alstext/html;profile=mcp-app. Genau dieser MIME-Typ sagt einem Host „das ist eine App, rendere sie“.MCPServer("clock", extensions=[apps]): die Anmeldung. Der Server bewirbt jetztio.modelcontextprotocol/uiuntercapabilities.extensions.
Das HTML selbst lauscht auf das postMessage des Hosts und zeigt das Ergebnis an. Für echte Apps verwende das offizielle Browser-SDK @modelcontextprotocol/ext-apps in deinem HTML. Es gibt dir ontoolresult, callServerTool, getHostContext und onhostcontextchanged statt roher Message-Events.
Graceful Degradation
Nicht jeder Client rendert Apps. Die Spezifikation sagt unverblümt, was das für dich bedeutet:
Tools MÜSSEN ein sinnvolles
content-Array zurückgeben, auch wenn eine UI verfügbar ist.
Das Modell liest content; der iframe ist für Menschen. Ein UI-fähiger Host füttert das Modell trotzdem mit dem Textergebnis, und ein reiner Text-Client bekommt nur das. Das kanonische Muster ist also: ein Tool, zwei Antworten. Sieh dir get_time noch einmal an:
from mcp import Client
from mcp.client import advertise
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=[advertise(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) ist nur dann True, wenn der Client die Extension io.modelcontextprotocol/ui deklariert und text/html;profile=mcp-app in seinen mimeTypes-Einstellungen aufgeführt hat. Das Feld ist Pflicht, ein Client, der es weglässt, zählt also nicht. Genau das deklariert main() in derselben Datei: die Client-Hälfte der Aushandlung – und die reichhaltige Antwort kommt zurück.
Warning
Gib niemals einen Platzhalter wie "[Rendered UI]" als einzigen Inhalt zurück. Wenn der Fallback-Text nutzlos ist, ist das Tool für jeden reinen Text-Client und für das Modell selbst nutzlos. Schreib den Satz.
Den iframe abriegeln
Die Ressourcenseite trägt die Sicherheitsmetadaten: was der iframe laden darf, welche Browser-Berechtigungen er möchte, wie er eingebettet werden will:
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 und permissions sind Anfragen an den Host, kein Serververhalten. Der Host baut daraus die Content-Security-Policy und die Permissions-Policy des iframes, und er darf ablehnen. Prüfe in deinem JS per Feature Detection, statt eine Zusage vorauszusetzen.
ResourceCsp, Feld für Feld (Python-Name, Schlüssel auf der Leitung, was der Host damit macht):
| Python | Leitung (_meta.ui.csp) |
Steuert |
|---|---|---|
connect_domains |
connectDomains |
connect-src: wohin fetch/XHR gehen darf |
resource_domains |
resourceDomains |
img-src, style-src, ...: statische Assets |
frame_domains |
frameDomains |
frame-src: verschachtelte iframes |
base_uri_domains |
baseUriDomains |
base-uri: worauf <base> zeigen darf |
ResourcePermissions: Jedes Feld fordert eine Browser-Berechtigung für den iframe an.
| Python | Leitung (_meta.ui.permissions) |
|---|---|
camera |
camera |
microphone |
microphone |
geolocation |
geolocation |
clipboard_write |
clipboardWrite |
Note
CSP und Berechtigungen liegen auf der Ressource, nie auf dem Tool. Die Tool-Metadaten der Spezifikation haben keinen Platz dafür, und Hosts ignorieren sie dort. Das SDK macht den Fehler unmöglich: @apps.tool() hat schlicht keinen Parameter csp.
Sichtbarkeit
visibility=["app"] an einem Tool sagt „das existiert für den iframe, nicht für das Modell“:
"model": Das Modell darf es aufrufen."app": Der iframe darf es aufrufen (übercallServerTool).- Weggelassen: beide, das ist der Standardwert.
Filtern ist Aufgabe des Hosts. Dein Server listet reine App-Tools in tools/list wie alle anderen; der Host verbirgt sie vor dem Modell. Filtere nicht serverseitig.
Die Regeln, die das SDK durchsetzt
All das schlägt beim Start fehl, nicht in Produktion:
- Ein
resource_urioder ein Ressourcen-URI, der nichtui://...ist, ist einValueErrorzum Zeitpunkt der Dekoration bzw. Registrierung. - Ein Tool, das an einen URI ohne passende registrierte Ressource gebunden ist, ist ein
ValueError, wennMCPServer(extensions=[apps])die Extension übernimmt. Ein Tool, das HTML bewirbt, das beiresources/readmit 404 antwortet, ist eine Fehlkonfiguration, also verweigert der Server die Konstruktion. meta={"ui": ...}an@apps.tool()ist einValueError._meta["ui"]gehört dem Dekorator; sag es mitresource_uri=undvisibility=. Anderemeta=-Schlüssel werden daneben problemlos zusammengeführt.
Weder das TypeScript-ext-apps-SDK noch FastMCP fängt heute irgendetwas davon ab; uns ist lieber, du erfährst es, bevor ein Host es tut.
Über Inline-HTML hinaus
add_html_resource deckt den häufigen Fall ab: einen String mit HTML. Für alles andere, HTML auf der Platte oder generierte Inhalte, baust du die Ressource selbst und reichst sie weiter:
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 ergänzt den MIME-Typ text/html;profile=mcp-app, wenn die Ressource keinen explizit setzt, und weist einen expliziten Widerspruch zurück: Eine ui://-Ressource unter einem anderen MIME-Typ rendert kein Host.
Tip
Du zielst auf einen Pre-GA-Host, der noch den veralteten flachen Schlüssel _meta["ui/resourceUri"] liest? Führe ihn selbst zusammen:
@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"}).
Das verschachtelte ui-Objekt ist die Form der Spezifikation; der flache Schlüssel ist auf dem Weg nach draußen.
Laufen sehen
Die Story apps in examples/stories/ ist diese Seite als lauffähiges Paar: ein Server mit einem UI-gebundenen Uhr-Tool und ein Client, der Apps aushandelt, die _meta.ui.resourceUri des Tools liest, das HTML holt und das Tool aufruft.
uv run python -m stories.apps.client