MCP Apps in Angular mit CopilotKit: Rich Chat Interfaces Instead of Text Responses

  1. AG-UI verstehen: Der Standard für Agentic User Interfaces
  2. AG-UI in der Praxis: Das SDK für TypeScript
  3. AG-UI End-2-End: Server und Client verbinden
  4. Agentic UI mit Angular, CopilotKit und AG-UI umsetzen
  5. A2UI: Wie KI dynamische UIs zur Laufzeit erzeugt
  6. A2UI mit AG-UI und CopilotKit in Angular integrieren
  7. Custom Catalogs in A2UI: Eigene Komponenten für KI-generierte UIs
  8. A2UI mit DSL: Kontrollierbare Dashboards optimiert für Performance
  9. Agentic UI mit MCP Apps: Tool-Ergebnisse als interaktive Widgets
  10. MCP Apps in Angular mit CopilotKit: Rich Chat Interfaces Instead of Text Responses

Im vorherigen Teil haben wir die Grundlagen von MCP und MCP Apps gelegt: MCP verbindet Agenten mit externen Tools, und MCP Apps hebt deren Ergebnisse von schlichtem Text auf die Ebene interaktiver Benutzeroberflächen. Anhand einer minimalen, auf VanillaJS basierenden Demo haben wir gesehen, wie ein Host eine App in einen abgesicherten iframe lädt und über die postMessage-API mit ihr kommuniziert – abstrahiert durch das App-Objekt und die AppBridge. Vom Aufbau der Verbindung über den Austausch von Tool-Parametern, -Ergebnissen und Host Context bis zum geordneten Teardown haben wir den gesamten Lebenszyklus durchgespielt.

Dieses Fundament wenden wir nun auf eine vollständige Fallstudie an. Diesmal kommen ein echter MCP Server, ein Sprachmodell, das Agent-Framework Mastra sowie das AG-UI-Protokoll ins Spiel. Ziel ist es, den MCP Server eines Geschäftspartners an unser Flugportal anzubinden und dessen Ergebnisse mithilfe einer MCP App darzustellen.

📂 Quellcode (branch: copilotkit)

Die Readme zeigt, wie man das Beispiel samt Backend und MCP Server startet.

Integration eines MCP Servers mit MCP Apps

Nachdem wir mit dem ersten Beispiel die prinzipielle Funktionsweise von MCP Apps geklärt haben, wollen wir unser Wissen nun auf eine größere Fallstudie mit Sprachmodell und MCP Server anwenden. Die Idee ist es, den MCP Server eines Geschäftspartners, der auf Hotelbuchungen spezialisiert ist, an unser Flugportal anzubinden. Somit können sich unsere Kunden nicht nur über Flüge, sondern auch über Hotels in der Zielstadt erkundigen:

Bereitstellung eines MCP Servers

Die Implementierung des MCP Servers steht zwar nicht im Fokus dieses Artikels. Zum besseren Verständnis will ich jedoch trotzdem einen kurzen Blick auf unsere Node.js-Implementierung werfen. Beim Einsatz anderer Server-Frameworks ist die Vorgehensweise sehr ähnlich.

Ein Objekt vom Typ McpServer aus dem MCP SDK repräsentiert den Server selbst:

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';

[...]

const server = new McpServer({
  name: 'Flights42 Hotels MCP Server',
  version: '1.0.0',
});

Der Name und die Version sind lediglich Metadaten. Konsumenten können sie nutzen, um mögliche Versionskonflikte zu vermeiden.

Mit der Funktion registerAppTool aus dem MCP Apps SDK registrieren wir beim McpServer ein Tool:

import {
  registerAppTool,
} from '@modelcontextprotocol/ext-apps/server';

[...]

export const HOTELS_RESOURCE_URI = 'ui://hotels/results.html';

registerAppTool(
  server,
  'findHotels',
  {
    title: 'Find Hotels',
    description:
      'Find three demo hotels for a city. Use this when the user asks for hotels in a specific city.',
    inputSchema: findHotelsInputSchema,
    outputSchema: findHotelsResultSchema,
    _meta: {
      ui: {
        resourceUri: HOTELS_RESOURCE_URI,
      },
    },
  },
  async (input) => {
    const result = findHotels(findHotelsInputSchema.parse(input));
    const hotels = result.hotels;
    return {
      content: [{ type: 'text', text: `${hotels.length} hotels found` }],
      structuredContent: result,
    };
  },
);

Wie gewohnt definieren eine Id, ein Name, eine Beschreibung sowie Schemas für Parameter und Rückgabewert das Tool. Die Schemabeschreibung erfolgt auch hier wie üblich über Zod:

export const findHotelsInputSchema = z.object({
  city: z.string().trim().min(1).describe('The city to search hotels for.'),
});

export const findHotelsResultSchema = z.object({
  city: z.string(),
  hotels: z.array(hotelSchema),
});

Daneben nimmt registerAppTool auch die Implementierung des Tools entgegen. Diese erhält ein Objekt vom Typ findHotelsInputSchema, sucht nach Hotels und retourniert ein Ergebnis mit einem structuredContent vom Typ findHotelsResultSchema. Zusätzlich enthält der Rückgabewert eine textuelle Information.

Der für MCP Apps entscheidende Punkt ist jedoch die Registrierung einer resourceUri in den Metadaten des Tools. Diese URI verweist auf eine MCP-Ressource, die die HTML-Datei der App liefert. Der Server registriert diese Ressource mit der Funktion registerAppResource:

import {
  registerAppResource,
  RESOURCE_MIME_TYPE
} from '@modelcontextprotocol/ext-apps/server';

[...]

const htmlPath = resolve(distDir, 'index.html');

registerAppResource(
  server,
  'Flights42 Hotel Results',
  HOTELS_RESOURCE_URI,
  {
    description: 'Hotel results rendered as an interactive MCP App.',
  },
  async () => {
    const html = await readFile(htmlPath, 'utf8');

    return {
      contents: [
        {
          uri: HOTELS_RESOURCE_URI,
          mimeType: RESOURCE_MIME_TYPE, 
            // text/html;profile=mcp-app
          text: html,
          [...]
        },
      ],
    };
  },
);

Zusätzlich machen wir den McpServer über einen Express-Server via HTTP zugänglich. Den gesamten Quellcode des Servers findet man im Repository des Artikels (mcp-server/src/server.ts).

Direkter Zugriff mit dem MCP Inspektor

Die Anwendung MCP Inspektor erlaubt es uns, direkt mit einem MCP-Server zu interagieren. Dazu gehört das Abrufen von Metadaten zu Tools, das Abrufen von Ressourcen sowie das Ausführen von Tools:

Der Inspektor lässt sich ohne vorherige Installation mit dem Kommando npx @modelcontextprotocol/inspector starten. Danach erfasst man die Adresse des MCP-Servers und verbindet sich.

Bei unserem MCP-Server findet man in den Metadaten des Find-Hotels-Tools die resourceUri der App. Ruft man anschließend die damit verbundene Ressource ab, gelangt man zu ihrem HTML.

MCP Apps ins Backend integrieren

Damit ein Agent die Tools eines MCP-Servers aufgreift, muss dieser dort registriert werden. Bei den meisten Agent-Frameworks genügt dazu die Angabe der Adresse des MCP-Servers.

Das von uns verwendete Agent-Framework Mastra bietet zum Beispiel ein Objekt MCPClient an, mit dem sich Metadaten zu allen angebotenen Tools eines MCP-Servers abrufen lassen. Diese Tools lassen sich genauso wie lokale Tools beim Agent hinterlegen:

import { MCPClient } from '@mastra/mcp';
[...]

const hotelsMcpTools = await new MCPClient({
      id: 'hotels-mcp-client',
      servers: { hotels: { url: new URL('http://127.0.0.1:3002/mcp') } },
    }).listTools();

export const ticketingAgent = new Agent({
  id: 'ticketingAgent',
  tools: {
    [...],
    ...hotelsMcpTools,
  },
});

MCP Apps via AG-UI

Um den Client via AG-UI auf eine vorhandene MCP App hinzuweisen, sendet der Server eine Nachricht vom Typ ACTIVITY_SNAPSHOT:

{
  "type": "ACTIVITY_SNAPSHOT",
  "messageId": "0145bd3d-fb17-4d72-907a-9b2a1d9fad63",
  "activityType": "mcp-apps",
  "content": {
    "serverHash": "",
    "serverId": "hotels",
    "resourceUri": "ui://hotels/results.html",
    "toolInput": {
      "city": "Paris"
    },
    "result": {
      "content": [
        {
          "type": "text",
          "text": "{ \"city\": \"Paris\", \"hotels\": [...] }"
        }
      ],
      "structuredContent": {
        "city": "Paris",
        "hotels": [
          {
            "id": "grand-palace",
            "name": "Grand Palace Paris",
            "sterne": 5,
            "imageUrl": "http://127.0.0.1:3002/assets/hotels/grand-palace.svg"
          },
          {
            "id": "skyline-suites",
            "name": "Skyline Suites Paris",
            "sterne": 4,
            "imageUrl": "http://127.0.0.1:3002/assets/hotels/skyline-suites.svg"
          },
          {
            "id": "biz-hotel",
            "name": "Biz Hotel Paris",
            "sterne": 3,
            "imageUrl": "http://127.0.0.1:3002/assets/hotels/biz-hotel.svg"
          }
        ]
      }
    }
  }
}

Wichtig ist hier der activityType. Aufgrund des Wertes mcp-apps erkennt der Client, dass es sich hierbei um Informationen für eine MCP App handelt. Der Knoten content enthält in diesem Fall den Verweis auf die Ressource, die an das Tool übersendeten Parameter und das Ergebnis.

Tip: Der in unserer Demo-Anwendung verwendete ExtendedMastraAgent (libs/ag-ui-server/extended-mastra-agent.ts) enthält ein wenig Logik, die nach einem Tool Call einen solchen Activity Snapshot versendet, sofern eine MCP App mit dem jeweiligen Tool verknüpft ist.

NOTE

Agentic UI with Angular

Wenn du MCP Apps, AG-UI und Agenten nicht nur einzeln nutzen, sondern zu einer tragfähigen Architektur zusammenfügen willst:
In meinem Buch Agentic UI mit Angular gehe ich genau auf diese Patterns und Trade-offs im Detail ein.

Cover des eBooks Agentic UI with Angular

Mehr zum eBook →

Der MCP-Apps-Renderer von CopilotKit

Um den empfangenen Activity Snapshot im Client darzustellen, brauchen wir einen Activity Renderer. CopilotKit liefert einen solchen Renderer für MCP Apps mit. Er steckt im sekundären Einstiegspunkt @copilotkit/angular/mcp-apps und wird in der app.config.ts mit der Funktion provideMCPApps eingerichtet:

// src/app/app.config.ts

import { provideCopilotKit } from '@copilotkit/angular';
import { provideMCPApps } from '@copilotkit/angular/mcp-apps';

import { mcpAppsConfig } from './mcp-apps.config';

[...]

export const appConfig: ApplicationConfig = {
  providers: [
    [...],

    provideCopilotKit({
      [...]
    }),

    provideMCPApps(mcpAppsConfig),

    [...]
  ],
};

Host-Informationen konfigurieren

Die an provideMCPApps übergebene Konfiguration ist mit dem Typ MCPAppsConfig typisiert. Sie legt vor allem die hostInfo und den hostContext fest – jene Eckdaten, die wir im ersten Teil im Rahmen der ersten Demo-Anwendung besprochen haben:

// src/app/mcp-apps.config.ts

import type { MCPAppsConfig } from '@copilotkit/angular/mcp-apps';

export const mcpAppsConfig: MCPAppsConfig = {
  hostInfo: { name: 'Flights42 MCP Host', version: '1.0.0' },
  hostContext: {
    displayMode: 'inline',
    theme: 'light',
    styles: {
      variables: {
        '--color-ring-primary': '#3f51b5',
      },
    },
  },
};

Der Proxy im Backend

Bleibt die Frage, wie die vom Widget ausgehenden Anfragen den MCP Server erreichen. Prinzipiell ginge das direkt aus dem Browser – entsprechend gesetzte CORS-Header am MCP Server vorausgesetzt. CopilotKit geht allerdings von einem Proxy im Backend aus: Die Anfragen laufen über den ohnehin vorhandenen AG-UI-Endpunkt; Adresse und etwaige Zugangsdaten des MCP Servers bleiben damit ein Backend-Detail. Diese Proxy-Aufgabe übernimmt die MCPAppsMiddleware aus dem Paket @ag-ui/mcp-apps-middleware. Unsere AG-UI-Route (ag-ui-route.ts) deklariert dazu die Konfiguration des Hotels-MCP-Servers ein einziges Mal:

// ai-server/src/mastra/routes/ag-ui-route.ts

import {
  MCPAppsMiddleware,
  type MCPClientConfig,
} from '@ag-ui/mcp-apps-middleware';

[...]

const HOTELS_MCP_SERVER: MCPClientConfig = {
  type: 'http',
  url: 'http://127.0.0.1:3002/mcp',
  serverId: 'hotels',
};

const mcpAppsProxy = new MCPAppsMiddleware({
  mcpServers: [HOTELS_MCP_SERVER],
});

Der serverseitige Route-Handler prüft anschließend, ob der eingehende Run eine Widget-Anfrage transportiert. Falls ja, läuft der Run durch die Middleware – ohne Agent und damit ohne Sprachmodell. Alle anderen Runs gehen wie gehabt an den Agenten:

const agent = getExtendedLocalAgent({
  [...]
});

const middleware = isProxiedMcpRequest(parsed.input.forwardedProps)
  ? mcpAppsProxy
  : undefined;

[...]

await streamAgentEvents(sse, agent, parsed.input, { middleware });

Die Funktion streamAgentEvents nimmt dafür einen optionalen middleware-Parameter entgegen und abonniert dann middleware.run(input, agent) statt agent.run(input).

Wichtig für die Einordnung: Die Tools des MCP Servers bleiben – wie oben gezeigt – auf Agent-Ebene registriert und werden weiterhin während des Runs auf dem Server ausgeführt. Die Middleware dient in diesem Setup ausschließlich als Proxy für die vom Widget initiierten resources/read- und tools/call-Anfragen.

Activities darstellen

Da wir CopilotKit im Headless Mode verwenden, müssen wir uns selbst um das Rendern der Activities kümmern. Beim Iterieren über die Nachrichten des Chatverlaufs prüfen wir daher, ob für die aktuelle Nachricht eine activity vorliegt. Falls ja, zeigen wir diese mit der Hilfskomponente app-copilot-activity an.

<!-- 
  src/app/domains/shared/ui-assistant/chat-messages/chat-messages.html 
-->

[...]
@for (view of views(); track view.id) {

  [...]

  @if (view.activity) {
    @let activity = view.activity;
    <article class="msg {{ view.variant }}">
      <div class="avatar">{{ view.avatar }}</div>
      <div>
        <div class="bubble" [class.bubble--a2ui]="activity.isSurface">
          <app-copilot-activity
            [message]="activity.message"
            [agentId]="agentId()" />
        </div>
        <div class="meta"></div>
      </div>
    </article>
  }

  [...]

}

[...]

Als dieser Text entstand, gab es für den Headless Mode noch keine vorgefertigte Komponente zum Visualisieren von Aktivitäten. Deswegen handelt es sich bei app-copilot-activity, wie der Präfix app- verrät, um eine selbst geschriebene Komponente.

Sie nimmt die aktuelle Nachricht sowie die agentId entgegen und leitet daraus in einem computed die darzustellende Ansicht ab:

// src/app/domains/shared/util-copilotkit/activity/copilot-activity.ts

[...]

@Component({
  selector: 'app-copilot-activity',
  imports: [NgComponentOutlet],
  template: `
    @let view = rendered();
    @if (view) {
      <ng-container *ngComponentOutlet="view.component; inputs: view.inputs" />
    }
  `,
  [...]
})
export class CopilotActivity {
  private readonly copilotKit = inject(CopilotKit);

  readonly message = input.required<ActivityMessage>();
  readonly agentId = input.required<string>();

  protected readonly rendered = computed(() =>
    toRenderActivity(
      this.message(),
      this.copilotKit.activityMessageRenderConfigs(),
      this.agentId(),
      this.copilotKit.getAgent(this.agentId()),
    ),
  );
}

Die Liste activityMessageRenderConfigs() umfasst neben den von der Anwendung registrierten Konfigurationen auch die mitgelieferten Renderer – darunter der über provideMCPApps eingerichtete MCP-Apps-Renderer. Er besorgt sich das HTML der App über den zuvor besprochenen Proxy und leitet auch die weiteren Anfragen des Widgets dorthin. Die serverId aus dem Activity Snapshot gibt dabei an, welcher der beim Proxy konfigurierten MCP Server gemeint ist – die URL des Servers kennt nur das Backend.

Die eigentliche Auswahl des Renderers übernimmt die Funktion toRenderActivity:

export function toRenderActivity(
  message: ActivityMessage,
  configs: readonly RenderActivityMessageConfig[],
  agentId: string,
  agent: AbstractAgent | undefined,
): RenderedActivity | null {
  const matches = configs.filter(
    (candidate) => candidate.activityType === message.activityType,
  );

  const config =
    matches.find((candidate) => candidate.agentId === agentId) ??
    matches.find((candidate) => candidate.agentId === undefined) ??
    configs.find((candidate) => candidate.activityType === '*');

  if (!config) {
    return null;
  }

  const parsed = config.content.safeParse(message.content);
  if (!parsed.success) {
    [...]
    return null;
  }

  return {
    component: config.component,
    inputs: {
      activityType: message.activityType,
      content: parsed.data,
      message,
      agent,
    },
  };
}

Die Auswahl folgt der Logik, die CopilotKit intern in seiner Komponente CopilotChatMessageView verwendet: Unter den Renderern mit passendem activityType – in unserem Fall mcp-apps – gewinnt zunächst einer, der für die konkrete agentId registriert wurde; danach kommt ein Agent-unabhängiger Eintrag zum Zug und als letzter Ausweg ein Wildcard-Renderer mit dem activityType '*'. Anschließend validiert die Funktion den Inhalt des Snapshots mit dem im Renderer hinterlegten Schema und liefert die Komponente samt ihrer Inputs zurück, die das Template über *ngComponentOutlet darstellt.

Zusammenfassung

MCP standardisiert die Anbindung von Tools an Agenten, und MCP Apps hebt diese Idee auf die Ebene der Benutzeroberfläche: Statt eines Tool-Ergebnisses in Textform erhält der Anwender eine interaktive, maßgeschneiderte Oberfläche. Eine MCP App ist dabei eine eigenständige Web-Anwendung, die der MCP Server als Ressource bereitstellt und auf die die Metadaten eines Tools über eine resourceUri verweisen. Der Host lädt die App in einen abgesicherten iframe und kommuniziert mit ihr über die postMessage-API – abstrahiert durch das App-Objekt auf Seite der App und die AppBridge auf Seite des Hosts.

Über den Host Context gibt der Host Theming und Layout vor; die App meldet umgekehrt ihren Platzbedarf, damit keine Scrollbar entsteht. Der Lebenszyklus reicht von der Initialisierung über den Austausch von Tool-Parametern und -Ergebnissen bis zum geordneten Teardown mit teardownResource und close. In einem realen Setup mit Sprachmodell registriert der MCP Server Tool und Ressource gemeinsam, der Agent bindet die Tools wie lokale Tools ein, und nach einem Tool Call transportiert AG-UI einen ACTIVITY_SNAPSHOT zum Client, den ein Activity Renderer aufgreift und als MCP App darstellt. Das zugehörige Widget verbindet sich dabei nicht direkt mit dem MCP Server, sondern leitet Ressourcen- und Tool-Anfragen über den AG-UI-Agenten an einen server-seitigen Proxy auf Basis der MCPAppsMiddleware weiter.

Interesse an produktionsreifen Agentic-UI-Architekturen?

In meinem Workshop beschäftigen wir uns mit AG-UI, A2UI, MCP Apps, HITL-Patterns und modernen Angular-Architekturen für reale agentische Systeme.

Workshop: Agentic AI mit Angular – AG-UI, A2UI, MCP Apps & HITL-Patterns

Alle Details →

Agentic UI with Angular

Architecting Agentic AI with Open Standards

Integriere AI-Agents in Angular mit offenen Standards.

Mehr zum Buch