Zum Inhalt

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:

  1. Ein Tool, das die Arbeit macht und Daten zurückgibt, wie jedes andere Tool auch.
  2. 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

server.py
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 als text/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 jetzt io.modelcontextprotocol/ui unter capabilities.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:

server.py
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:

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 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 (über callServerTool).
  • 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_uri oder ein Ressourcen-URI, der nicht ui://... ist, ist ein ValueError zum Zeitpunkt der Dekoration bzw. Registrierung.
  • Ein Tool, das an einen URI ohne passende registrierte Ressource gebunden ist, ist ein ValueError, wenn MCPServer(extensions=[apps]) die Extension übernimmt. Ein Tool, das HTML bewirbt, das bei resources/read mit 404 antwortet, ist eine Fehlkonfiguration, also verweigert der Server die Konstruktion.
  • meta={"ui": ...} an @apps.tool() ist ein ValueError. _meta["ui"] gehört dem Dekorator; sag es mit resource_uri= und visibility=. Andere meta=-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:

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 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