← Blog
AI & Agents·05. September 2026·15 min Lesezeit

UI-Komponenten im Chat: Wenn der Agent statt Text eine bedienbare Tabelle schickt

Agenten antworten mit Text — und jede Reaktion darauf beginnt mit einem Anwendungswechsel. MCP Apps ändern das: Ein Tool liefert die Daten, eine ui://-Ressource die Oberfläche dazu, und geklickt wird direkt im Chatverlauf. Mit lauffähigem Beispielserver, echten Payloads, dem Weg über Power Apps — und dem ehrlichen Stand: in VS Code und Microsoft 365 Copilot verfügbar, in Power Apps Preview, in Copilot Studio erst angekündigt.

Der Flow Rechnung freigeben läuft stündlich, und heute Morgen ist einer gescheitert. Du fragst deinen Agenten nach den letzten Läufen und bekommst eine saubere Liste — und musst trotzdem das Portal öffnen, den Flow suchen, den Lauf finden und ihn neu starten. Der Agent hat die Arbeit nicht abgenommen, er hat sie verschoben.

Dass der Agent die Läufe überhaupt kennt, liegt am Model Context Protocol (MCP): ein offener Anschluss, über den ein Agent fremde Systeme erreicht. Ein MCP-Server stellt Tools bereit, der Agent ruft sie auf und bekommt Daten zurück. Was zurückkommt, war bisher immer Text oder JSON, aus dem das Modell dann eine Antwort formuliert.

MCP Apps erweitern genau diesen Rückweg: Ein Tool darf zu seinen Daten auch die Oberfläche mitliefern. Im Chat steht dann keine Liste, sondern eine Tabelle mit einem Knopf — und der Klick geht direkt an den Server zurück, ohne Umweg über das Modell. In der Microsoft-Welt sickert das gerade ein: in VS Code und Microsoft 365 Copilot ist es da, in Power Apps als Public Preview, in Copilot Studio noch nicht.

Vier Dinge stehen in diesem Artikel:

  1. Wie es funktioniert — die zwei MCP-Bausteine hinter so einer Komponente.
  2. Zwei lauffähige Beispiele — ein kleiner MCP-Server, den du in ein paar Minuten selbst startest: erst eine Tabelle mit Flow-Läufen, dann ein Bestellablauf über drei Schritte. Zum Schluss hängt derselbe Server im echten Copilot Chat von VS Code.
  3. Der Weg über Power Apps — dort erzeugt die Plattform den MCP-Server selbst, statt dass du einen betreibst.
  4. Was fehlt — die Lücken, die Microsoft offen dokumentiert.

Was man zuerst sieht

Der übliche Aufbau: Ein MCP-Server hängt am Agenten, ein Tool liefert sauberes JSON, das Modell formuliert daraus eine Antwort. Im Chat steht dann so etwas:

2026-09-05T06:12:04Z | Rechnung freigeben | Failed | BadRequest: 'approverEmail' is required
2026-09-05T05:12:03Z | Rechnung freigeben | Succeeded
2026-09-05T04:12:01Z | Rechnung freigeben | Running

Korrekt, vollständig — und tot. Nichts davon ist anklickbar. Der Agent kann erzählen, dass ein Lauf fehlgeschlagen ist; er kann keinen Knopf anbieten, ihn neu zu starten.

Was technisch wirklich passiert

Eine UI-Komponente besteht aus zwei MCP-Primitiven, die zusammengebunden werden:

  1. Eine Resource mit dem Schema ui://…. Sie liefert eine eigenständige HTML-Datei — die Komponente.
  2. Ein Tool, das im Feld _meta.ui.resourceUri auf genau diese Resource zeigt.

Ruft der Host das Tool auf, holt er die HTML-Ressource, rendert sie in einem abgeschotteten iframe und schiebt das Tool-Ergebnis hinein. In der Komponente läuft die Gegenstelle: die App-Klasse aus @modelcontextprotocol/ext-apps. Sie meldet sich mit app.connect() beim Host, bekommt die Daten über app.ontoolresult — und kann mit app.callServerTool() von sich aus ein weiteres Tool auf dem Server aufrufen.

Das ist der eigentliche Punkt: Die Kommunikation läuft in beide Richtungen. Der Nutzer klickt in der Komponente, die Komponente ruft den Server, der Server antwortet, die Komponente zeichnet sich neu — ohne dass jemand den Chat verlässt und ohne dass das Modell dazwischen noch einmal raten muss.

Wichtig ist die Aufteilung im Tool-Ergebnis:

  • content — der Text-Fallback. Er bleibt Pflicht, weil nicht jede Oberfläche Komponenten rendert.
  • structuredContent — die Daten für die Komponente. Sie kommen im Widget als result.structuredContent an, nicht als result.data.

Die naheliegende, aber falsche Abkürzung

Zwei Reflexe führen hier in die Irre.

„Dafür gibt es doch Adaptive Cards.” Adaptive Cards sind der ältere, bewährte Weg für strukturierte Antworten in Copilot Studio, und für Formulare und Rückfragen sind sie weiterhin richtig. Aber die Karte gibt der Maker vor, und ihr Verhalten endet bei den definierten Actions. Eine MCP-Komponente ist eine echte Webanwendung im Chat, die zur Laufzeit entscheidet, was sie nachlädt.

„Dann klicke ich das in Copilot Studio zusammen.” Das geht heute nicht. Copilot Studio kann MCP-Server anbinden und deren Tools aufrufen — das inline gerenderte Widget gehört noch nicht dazu. Im Microsoft-365-Roadmap-Eintrag 570433 („Deliver rich interactive app experiences in Copilot Studio agents”) stehen Preview für Oktober 2026 und allgemeine Verfügbarkeit für November 2026. Wer heute etwas ausprobieren will, macht es woanders:

OberflächeStand
VS Code, GitHub Copilot Chatverfügbar; MCP Apps seit Januar 2026, Einstellung chat.mcp.apps.enabled (Standard: an, als experimental markiert)
Microsoft 365 Copilot Chat (declarative agents)verfügbar; laut Doku allgemein verfügbar seit März 2026
Power Apps, model-driven App mit App MCPPublic Preview
Copilot Studio Agentsangekündigt: Preview 10/2026, GA 11/2026

Bauen wir es nach

Der schnellste Weg zum eigenen Gefühl dafür ist ein kleiner MCP-Server, den du selbst startest — nicht der Weg über Power Apps. Der verlangt eine Microsoft-365-Copilot-Lizenz und eine model-driven App (dazu weiter unten mehr), während der Mechanismus derselbe bleibt. Lokal siehst du ihn in fünf Minuten und ohne Mandanten.

Drei Teile spielen zusammen:

  • Der MCP-Server — das Stück, das du schreibst. Er bietet das Tool list_flow_runs an und die Komponente dazu (das zweite Beispiel weiter unten hängt im selben Server). Er steht hier stellvertretend für alles, was in echt Flow-Läufe kennt, etwa die Power-Automate-Management-API; die Läufe selbst sind Demo-Daten im Server, damit du keinen Mandanten brauchst.
  • Die Komponente — eine einzelne HTML-Datei: die Tabelle, die im Chat erscheint, mit einem Knopf Erneut ausführen bei fehlgeschlagenen Läufen.
  • Der Host — die Stelle, die beides zusammenbringt und die Komponente anzeigt. In echt ist das VS Code oder Microsoft 365 Copilot; zum Ausprobieren nehmen wir zuerst den Referenz-Host aus dem MCP-Repository.

Projekt anlegen

mkdir flow-runs-mcp-app && cd flow-runs-mcp-app
npm install @modelcontextprotocol/ext-apps @modelcontextprotocol/sdk
npm install -D typescript vite vite-plugin-singlefile express cors @types/express @types/cors tsx

Verwendet habe ich @modelcontextprotocol/ext-apps 1.7.5 und @modelcontextprotocol/sdk 1.30.0 unter Node 25. vite-plugin-singlefile bündelt die Komponente samt Skript und CSS in eine HTML-Datei — das erspart die Arbeit an der Content Security Policy des iframes, in dem die Komponente später läuft.

Der Server

// server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import {
  registerAppTool,
  registerAppResource,
  RESOURCE_MIME_TYPE,
} from "@modelcontextprotocol/ext-apps/server";
import { z } from "zod";
import express from "express";
import cors from "cors";
import fs from "node:fs/promises";
import path from "node:path";

const server = new McpServer({ name: "Flow Runs", version: "1.0.0" });

// Das ui://-Schema markiert die Ressource als UI-Komponente.
const resourceUri = "ui://flow-runs/mcp-app.html";

type Run = { id: string; flow: string; status: string; started: string; error?: string };
const runs: Run[] = [
  { id: "08584...231", flow: "Rechnung freigeben", status: "Failed",    started: "2026-09-05T06:12:04Z", error: "BadRequest: 'approverEmail' is required" },
  { id: "08584...230", flow: "Rechnung freigeben", status: "Succeeded", started: "2026-09-05T05:12:03Z" },
  { id: "08584...229", flow: "Rechnung freigeben", status: "Running",   started: "2026-09-05T04:12:01Z" },
];

registerAppTool(
  server,
  "list_flow_runs",
  {
    title: "Flow-Läufe anzeigen",
    description: "Zeigt die letzten Läufe eines Cloud Flow als interaktive Tabelle.",
    inputSchema: {},
    annotations: { readOnlyHint: true },
    _meta: { ui: { resourceUri } },
  },
  async () => ({
    structuredContent: { runs },
    content: [{
      type: "text",
      text: runs.map((r) => `${r.started} | ${r.flow} | ${r.status}`).join("\n"),
    }],
  }),
);

registerAppTool(
  server,
  "resubmit_flow_run",
  {
    title: "Lauf erneut ausführen",
    description: "Startet einen fehlgeschlagenen Lauf neu.",
    inputSchema: { runId: z.string() },
    _meta: { ui: { resourceUri } },
  },
  async ({ runId }) => {
    const run = runs.find((r) => r.id === runId);
    if (run) { run.status = "Running"; delete run.error; }
    return { structuredContent: { runs }, content: [{ type: "text", text: `Lauf ${runId} neu gestartet.` }] };
  },
);

registerAppResource(
  server,
  resourceUri,
  resourceUri,
  { mimeType: RESOURCE_MIME_TYPE },
  async () => ({
    contents: [{
      uri: resourceUri,
      mimeType: RESOURCE_MIME_TYPE,
      text: await fs.readFile(path.join(import.meta.dirname, "dist", "mcp-app.html"), "utf-8"),
    }],
  }),
);

const app = express();
app.use(cors());
app.use(express.json());
app.post("/mcp", async (req, res) => {
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
  res.on("close", () => transport.close());
  await server.connect(transport);
  await transport.handleRequest(req, res, req.body);
});
app.listen(3001, () => console.log("MCP server on http://localhost:3001/mcp"));

Die Komponente

Der interessante Teil sind die Zeilen, die die Komponente an den Host hängen — Handler zuerst registrieren, dann connect():

// src/mcp-app.ts
import { App } from "@modelcontextprotocol/ext-apps";

const app = new App({ name: "Flow Runs UI", version: "1.0.0" });

function render(runs: Run[]) {
  // … Tabelle zeichnen …
  // Knopf nur bei fehlgeschlagenen Läufen:
  b.onclick = async () => {
    const res = await app.callServerTool({
      name: "resubmit_flow_run",
      arguments: { runId: r.id },
    });
    render((res.structuredContent as { runs: Run[] }).runs);
    app.sendSizeChanged({ height: document.body.scrollHeight });
  };
}

app.ontoolresult = (result) =>
  render((result.structuredContent as { runs: Run[] })?.runs ?? []);
app.connect();

app.sendSizeChanged() ist kein Detail für später: Der Host kennt die Höhe des Inhalts nicht. Ohne diesen Aufruf steht die Komponente in einem zu kleinen Rahmen mit Scrollbalken.

Bauen, starten, hineinschauen

npx vite build     # → dist/mcp-app.html
npx tsx server.ts  # → http://localhost:3001/mcp

Ob die Verdrahtung stimmt, sieht man ohne jeden Host schon an tools/list:

curl -s -X POST http://localhost:3001/mcp \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
{"name":"list_flow_runs","title":"Flow-Läufe anzeigen",
 "annotations":{"readOnlyHint":true},
 "_meta":{"ui":{"resourceUri":"ui://flow-runs/mcp-app.html"},
          "ui/resourceUri":"ui://flow-runs/mcp-app.html"}}

Zwei Dinge sind hier bemerkenswert. Erstens schreibt das SDK die Zuordnung doppelt hinaus — verschachtelt als ui.resourceUri und flach als ui/resourceUri, damit ältere Hosts sie ebenfalls finden. Zweitens taucht die Komponente in resources/list mit einem eigenen MIME-Typ auf:

{"uri":"ui://flow-runs/mcp-app.html",
 "mimeType":"text/html;profile=mcp-app"}

Der Tool-Aufruf liefert dann beide Kanäle nebeneinander — Fallback-Text und Daten:

{"content":[{"type":"text","text":"2026-09-05T06:12:04Z | Rechnung freigeben | Failed | …"}],
 "structuredContent":{"runs":[
   {"id":"08584...231","flow":"Rechnung freigeben","status":"Failed",
    "started":"2026-09-05T06:12:04Z","error":"BadRequest: 'approverEmail' is required"}]}}

Im Host ansehen

Zum Anschauen reicht der Referenz-Host aus dem ext-apps-Repository. Er ist keine Kopie von Copilot, aber er spricht dasselbe Protokoll:

git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps && npm install && npm run build
cd examples/basic-host && npm install
SERVERS='["http://localhost:3001/mcp"]' npm start   # → http://localhost:8080

Der Referenz-Host zeigt Tool-Eingabe, die gerenderte Komponente und das rohe Tool-Ergebnis untereinander

Oben die Eingabe, unten das rohe Ergebnis — dazwischen die Komponente, die aus demselben Ergebnis eine Tabelle mit Status-Markierungen und einem Knopf macht. Ein Klick auf Erneut ausführen ruft resubmit_flow_run auf dem Server auf, und die Tabelle zeichnet sich mit dem neuen Status neu:

Die Komponente im Host: Ein Klick auf „Erneut ausführen“ setzt den fehlgeschlagenen Lauf auf Running, Fehlermeldung und Knopf verschwinden — Tool Input und Tool Result rahmen den Aufruf ein

Zwischen Klick und neu gezeichneter Tabelle liegt kein Modellaufruf. Der Klick geht direkt an den Server.

Ein zweiter Ablauf: nicht anzeigen, sondern etwas anlegen

Die Tabelle zeigt das Prinzip, bleibt aber ein einziger Klick. Interessanter wird es, wenn mehrere Schritte zusammenhängen und am Ende etwas entsteht. Das zweite Tool im Beispielserver, order_office_supplies, öffnet eine Bestellung für Büromaterial: Mengen wählen, Kostenstelle zuordnen, prüfen, verbindlich bestellen — und danach den Stand nachfragen.

Der Bestellablauf in drei Schritten: Auswahl mit Mengen und Kostenstelle, Prüfen, danach die Bestätigung mit Bestellnummer, Betrag und Freigabe-Verlauf

Drei Schritte, zwei Rückrufe an den Server: place_order legt die Bestellung an und gibt Bestellnummer, Betrag und Freigabe-Verlauf zurück, order_status holt den aktuellen Stand. Beides läuft aus der Komponente heraus, ohne Modell dazwischen.

Drei Dinge fallen dabei auf.

Die Komponente führt ihren Zustand selbst. Auswahl, Mengen und Kostenstelle liegen im Widget — nicht im Server und nicht im Modell. Erst beim Bestellen geht alles gebündelt an das Tool, gegen ein Schema, das der Server prüft:

inputSchema: {
  items: z.array(z.object({ sku: z.string(), qty: z.number().int().positive() })).min(1),
  costCenter: z.string(),
},

Schreibende Aktionen sicherst du selbst ab. place_order legt etwas an, und der Host hilft dabei nicht: destructiveHint und idempotentHint werden ignoriert, eine Rückfrage vor dem Absenden gibt es nur, wenn die Komponente sie stellt. Deshalb der Zwischenschritt Prüfen mit einem eigenen Knopf Verbindlich bestellen, statt direkt in der Liste zu bestellen.

Halte die Höhe fest. Die drei Schritte sind unterschiedlich hoch. Ohne eine Mindesthöhe in der Komponente springt der Chatverlauf bei jedem Wechsel — was darunter steht, rutscht hoch und runter. Ein min-height auf dem body kostet etwas Weißraum und macht den Ablauf ruhig.

Dasselbe im echten Copilot Chat

Der Referenz-Host zeigt das Protokoll, aber keinen Alltag. Den gibt es in VS Code: Seit Januar 2026 rendert der Copilot Chat MCP Apps direkt im Verlauf — nach Angabe des VS-Code-Teams als erster großer Editor. Die Einstellung heißt chat.mcp.apps.enabled, steht standardmäßig auf true und ist als experimental markiert; einschalten muss man also nichts.

Damit derselbe Demo-Server dort landet, reicht eine Datei im Projekt:

// .vscode/mcp.json
{
  "servers": {
    "flow-runs-demo": { "type": "http", "url": "http://localhost:3001/mcp" }
  }
}

Danach steht das Tool im Chat bereit — und die Antwort auf „Zeig mir die letzten Flow-Läufe” ist keine Liste:

Copilot Chat in VS Code: Auf die Frage nach den letzten Flow-Läufen erscheint die Zeile „Ran Flow-Läufe anzeigen – flow-runs-demo (MCP Server)“ und darunter die gerenderte Komponente mit Statusmarkierungen und dem Knopf „Erneut ausführen“

Die Zeile darüber nennt Tool und Server, darunter steht die Komponente — an der Stelle, an der sonst der Fließtext stünde. Der Knopf verhält sich hier genauso wie im Referenz-Host: Der Klick geht direkt an den Server, das Modell wird nicht noch einmal gefragt.

Derselbe Klick im Copilot Chat von VS Code: Der fehlgeschlagene Lauf springt auf Running, Fehlermeldung und Knopf verschwinden, die Tabelle wird kürzer

Für Firmenmandanten hängt daran ein Vorbehalt. Bei Copilot Business und Copilot Enterprise muss die Organisationsrichtlinie „MCP servers in Copilot” aktiv sein — sie ist standardmäßig aus. Seit August 2026 kommen Allowlists in den Enterprise Managed Settings dazu (allowedMcpServers, deniedMcpServers), durchgesetzt unter anderem in VS Code. Bei Copilot Free, Pro, Pro+ und Max greift beides nicht.

Und in der Power Platform?

Für model-driven Apps gibt es den Weg, ohne selbst einen MCP-Server zu betreiben — Power Apps erzeugt ihn. Der Ablauf laut Doku (Public Preview, make.preview.powerapps.com):

  1. App öffnen, links App MCP wählen, Set up MCP — einmalig.
  2. App speichern und veröffentlichen.
  3. Unter Tools → Create custom tool Name, Beschreibung und die Anweisung für die Datenausgabe hinterlegen. Der Ausgabetyp muss JSON sein, sonst gibt es nichts, was eine Komponente rendern könnte.
  4. Test ausführen, dann im nächsten Schritt das HTML der Komponente einfügen und speichern.
  5. Download app package und das Paket in Teams beziehungsweise über das Microsoft-365-Admin-Center bereitstellen.

Voraussetzungen sind eine Microsoft-365-Copilot-Lizenz für Maker und Endnutzer sowie die Erlaubnis, eigene Apps in Teams hochzuladen. Bleibt beim Anlegen des Tools die Meldung „This feature has been disabled” stehen, fehlt die Umgebungseinstellung für AI prompts.

Das HTML muss man dafür nicht von Hand schreiben. Microsoft liefert eine Skill für Coding-Agenten mit, die aus der echten JSON-Ausgabe eines Tools eine Komponente erzeugt:

/plugin marketplace add microsoft/power-platform-skills
/plugin install mcp-apps@power-platform-skills

Aufgerufen wird sie mit /generate-mcp-app-ui, dazu die Beschreibung des gewünschten Bildes und die echte Tool-Ausgabe — die Doku ist an dieser Stelle deutlich: Mit Beispieldaten entsteht eine Komponente, die am echten Tool bricht. Gibt man zusätzlich den Tool-Namen an, verdrahtet die Skill app.callServerTool gleich mit. Wie man solche Skills im Team teilt, steht in Skills und Agenten im Team teilen.

Checkliste vor dem ersten Versuch

  • Ausgabetyp JSON. Ohne strukturierte Daten keine Komponente. Im eigenen Server: structuredContent füllen, nicht nur content.
  • Text-Fallback immer mitliefern. Oberflächen ohne Rendering zeigen sonst eine leere Antwort.
  • connect() zuletzt. Alle Handler vor dem Verbinden registrieren, sonst geht das erste Ergebnis verloren.
  • Die Daten liegen in result.structuredContent — nicht in result.data.
  • Bei Copilot Business/Enterprise die Richtlinie prüfen. „MCP servers in Copilot” ist standardmäßig aus; dazu kommen seit August 2026 die Allowlists allowedMcpServers und deniedMcpServers in den Enterprise Managed Settings.
  • CORS für den Widget-Host freigeben. Copilot rendert unter {hashed-mcp-domain}.widget-renderer.usercontent.microsoft.com; den konkreten Wert liefert der Widget Host URL Generator.
  • Redirect-URI eintragen: https://teams.microsoft.com/api/platform/v1.0/oAuthRedirect für Copilot, https://vscode.dev/redirect für das Agents Toolkit.
  • Authentifizierung „None” ist eine Entwicklungseinstellung. Vor dem Ausrollen OAuth 2.1 oder Entra-SSO. Was eine Agent-Identität dabei leistet — und was nicht — steht in Entra Agent ID.
  • Fähigkeiten prüfen statt annehmen. Nicht unterstützte Aufrufe sind schlicht undefined; also if (!window.openai.requestDisplayMode) return null; beziehungsweise app.getHostContext()?.availableDisplayModes.
  • Design-Tokens statt Hex-Werte (var(--colorNeutralForeground1)), sonst ist die Komponente im dunklen Design unlesbar.

Was unbequem bleibt

Die Verfügbarkeit ist zersplittert. Dasselbe Feature ist in VS Code und Microsoft 365 Copilot verfügbar, in Power Apps Preview und in Copilot Studio angekündigt. Wer heute für Copilot Studio plant, plant auf eine Roadmap-Zusage.

Nicht alles vom Protokoll kommt an. Microsoft dokumentiert die Lücken offen: Datei-Upload und Download-Links aus der Komponente heraus (uploadFile, getFileDownloadUrl), modale Dialoge (requestModal) und frame_domains in der Content Security Policy werden nicht unterstützt. destructiveHint und idempotentHint an Tools werden ignoriert — eine schreibende Aktion abzusichern ist deine Aufgabe, nicht die des Hosts. Vom Anzeigemodus kommt nur Vollbild an.

Die Komponente ist Code, den jemand pflegen muss. Meine gebündelte Beispieldatei ist 432 kB groß, und Microsofts Vorlage lädt Fluent UI zur Laufzeit vom CDN — ohne Internetverbindung bleibt die Komponente leer. Kein Argument dagegen, aber ein Posten in der Wartung, den eine Adaptive Card nicht hat.

Das Modell bleibt außen vor. Lädt die Komponente Daten nach, weiß das Modell davon zunächst nichts. Das ist gewollt und schnell — aber die nächste Frage im Chat bezieht sich womöglich auf einen Stand, den der Agent nie gesehen hat. Für Aktionen, die den Gesprächsverlauf betreffen, gehört der Rückweg in den Kontext eingeplant.

Siehe auch

Quellen