Im vorherigen Teil wurden die Basics zu MCP und MCP Apps etabliert: Während MCP Agenten an externe Tools anbindet, heben MCP Apps die daraus resultierenden Ergebnisse über die bloße Textausgabe hinaus und machen sie zu interaktiven Oberflächen. Eine minimalistische VanillaJS-Demo zeigte, wie ein Host eine App in einem abgesicherten iframe einbettet und über die postMessage-API mit ihr interagiert – gekapselt durch das App-Objekt und die AppBridge. Der gesamte Lebenszyklus wurde durchgespielt: Verbindungsaufbau, Austausch von Tool-Parametern und -Ergebnissen samt Host Context sowie ein sauberer Teardown.
Auf dieser Grundlage entwickelt sich nun eine vollständige Fallstudie. Beteiligt sind ein echter MCP Server, ein Sprachmodell, das Agent-Framework Mastra und das AG-UI-Protokoll. Konkret geht es darum, den MCP Server eines Geschäftspartners ins Flugportal zu integrieren und dessen Ergebnisse über eine MCP App sichtbar zu machen.
📂 Quellcode (branch: copilotkit)
Startanweisungen für das Beispiel inklusive Backend und MCP Server stehen in der Readme.
Integration eines MCP Servers mit MCP Apps
Nachdem die prinzipielle Funktionsweise von MCP Apps anhand des ersten Beispiels beleuchtet wurde, geht es nun darum, das erworbene Wissen in einer größeren Fallstudie mit Sprachmodell und MCP Server anzuwenden. Der Ansatz: Der MCP Server eines Geschäftspartners, der auf Hotelbuchungen spezialisiert ist, wird an das Flugportal angebunden. Damit können Kunden nicht mehr nur Flüge, sondern auch Unterkünfte am Zielort anfragen.

Bereitstellung eines MCP Servers
Die konkrete Ausgestaltung des MCP Servers bildet zwar nicht den Schwerpunkt dieses Beitrags. Dennoch lohnt sich ein kurzer Einblick in unsere Node.js-Lösung, um das Gesamtbild abzurunden. Bei alternativen Server-Frameworks gestaltet sich das Vorgehen weitgehend analog.
Der Server selbst wird durch eine Instanz der Klasse McpServer aus dem MCP SDK repräsentiert:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
[...]
const server = new McpServer({
name: 'Flights42 Hotels MCP Server',
version: '1.0.0',
});
Name and version serve only as metadata. Consumers may rely on them to steer clear of potential version clashes.
Through the registerAppTool function provided by the MCP Apps SDK, we go ahead and register a tool with the McpServer instance:
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,
};
},
);
A tool is defined as usual by an identifier, a name, a description, and schemas for parameters and the return value. The schema description is also provided here in the customary manner via 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),
});
Zusätzlich zur Implementierung übergibt man registerAppTool auch die eigentliche Funktion des Tools. Diese bekommt ein Objekt vom Typ findHotelsInputSchema übergeben, führt die Hotelsuche durch und liefert ein Ergebnis zurück, das ein structuredContent mit dem Typ findHotelsResultSchema enthält. Der Rückgabewert umfasst darüber hinaus eine Textinformation.
Was für MCP Apps wirklich zählt, ist allerdings die Definition einer resourceUri in den Tool-Metadaten. Diese URI zeigt auf eine MCP-Ressource, die die HTML-Datei der App zur Verfügung stellt. Auf der Serverseite wird diese Ressource mithilfe der Methode registerAppResource registriert:
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,
[...]
},
],
};
},
);
Außerdem wird der McpServer über einen Express-Server per HTTP bereitgestellt. Der vollständige Quellcode des Servers ist im Artikel-Repository hinterlegt (mcp-server/src/server.ts).
Unmittelbarer Zugriff mittels MCP Inspektor
Mit dem MCP Inspektor lässt sich ein MCP-Server unmittelbar ansteuern. Das umfasst den Abruf von Tool-Metadaten, das Laden von Ressourcen und die Ausführung von Tools:

Um den Inspektor zu verwenden, führt man das Kommando npx @modelcontextprotocol/inspector aus, ohne dass zuvor eine Installation nötig ist. Im nächsten Schritt notiert man die URL des MCP-Servers und stellt dann eine Verbindung her.
Bei unserem MCP-Server lässt sich in den Metadaten des Find-Hotels-Tools die resourceUri der App ausfindig machen. Wenn man danach die zugehörige Ressource abruft, erhält man Zugang zu ihrem HTML.
MCP Apps in das Backend einbinden
Damit ein Agent die Tools eines MCP-Servers nutzen kann, muss dieser dort registriert sein. In den meisten Agent-Frameworks reicht hierfür die Angabe der Serveradresse aus.
Das von uns eingesetzte Agent-Framework Mastra stellt etwa ein Objekt MCPClient bereit, über das sich Metadaten zu sämtlichen angebotenen Tools eines MCP-Servers abfragen lassen. Diese Tools lassen sich beim Agent auf dieselbe Weise registrieren wie lokale Tools:
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
To point an AG-UI client to an existing MCP app, the server emits a message of type 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"
}
]
}
}
}
}
Der entscheidende Punkt ist hier der activityType. Der Wert mcp-apps signalisiert dem Client, dass die Daten für eine MCP App bestimmt sind. In diesem Fall beinhaltet der content-Knoten den Ressourcenverweis, die an das Tool übergebenen Parameter sowie das Resultat.
Hinweis: Unser ExtendedMastraAgent, der in der Demo-Anwendung zum Einsatz kommt (libs/ag-ui-server/extended-mastra-agent.ts), verfügt über Logik, die nach einem Werkzeugaufruf einen solchen Activity Snapshot aussendet – allerdings nur, wenn eine MCP App mit dem entsprechenden Tool verknüpft ist.
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.
Der MCP-Apps-Renderer von CopilotKit
Für die Darstellung des empfangenen Activity Snapshots im Client ist ein Activity Renderer erforderlich. Einen solchen Renderer für MCP Apps stellt CopilotKit bereit. Er ist im sekundären Einstiegspunkt @copilotkit/angular/mcp-apps enthalten und wird in der app.config.ts über die Funktion provideMCPApps konfiguriert:
// 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 Konfiguration, die an provideMCPApps übergeben wird, besitzt den Typ MCPAppsConfig. Hauptsächlich definiert sie die hostInfo und den hostContext – diese Schlüsseldaten haben wir bereits im ersten Teil bei der ersten Demo-Anwendung kennengelernt:
// 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
Offen bleibt, wie die vom Widget abgesetzten Anfragen zum MCP Server gelangen. Theoretisch wäre ein direkter Weg aus dem Browser möglich, sofern der MCP Server passende CORS-Header liefert. Doch CopilotKit sieht einen Proxy im Backend vor: Sämtliche Anfragen werden über den bereits vorhandenen AG-UI-Endpunkt geleitet, wodurch die Serveradresse und eventuelle Zugangsdaten des MCP Servers ausschließlich im Backend bekannt sind. Diese Vermittlerrolle übernimmt die MCPAppsMiddleware des Pakets @ag-ui/mcp-apps-middleware. Unsere AG-UI-Route (ag-ui-route.ts) definiert hierfür genau einmal die Konfiguration des Hotels-MCP-Servers:
// 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 Route-Handler auf dem Server ermittelt daraufhin, ob der eingegangene Run eine Widget-Anfrage enthält. Trifft das zu, durchläuft der Run die Middleware – ohne Agenten und folglich ohne Sprachmodell. Alle übrigen Runs werden wie gewohnt an den Agenten weitergeleitet:
const agent = getExtendedLocalAgent({
[...]
});
const middleware = isProxiedMcpRequest(parsed.input.forwardedProps)
? mcpAppsProxy
: undefined;
[...]
await streamAgentEvents(sse, agent, parsed.input, { middleware });
Die Funktion streamAgentEvents akzeptiert hierfür einen optionalen middleware-Parameter und abonniert anschließend middleware.run(input, agent), anstatt agent.run(input) aufzurufen.
Zur Einordnung ist entscheidend: Die Tools des MCP Servers bleiben – wie bereits dargestellt – auf Agent-Ebene registriert und werden weiterhin während des Runs serverseitig ausgeführt. In dieser Konfiguration agiert die Middleware lediglich als Stellvertreter für die vom Widget ausgelösten resources/read- sowie tools/call-Anfragen.
Darstellung der Activities
Weil wir CopilotKit im Headless Mode einsetzen, müssen wir das Rendering der Activities eigenständig übernehmen. Beim Durchlaufen der Chatverlaufsnachrichten prüfen wir daher, ob eine activity zur aktuellen Nachricht existiert. Ist dies der Fall, rendern wir diese mithilfe der Hilfskomponente app-copilot-activity.
<!--
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>
}
[...]
}
[...]
At the time this was written, no ready-made component existed for visualizing activities in headless mode. That is why app-copilot-activity, as its app- prefix indicates, is a custom-built component.
It accepts the current message and the agentId as inputs, and uses those in a computed to determine which view should be rendered.
// 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()),
),
);
}
Auf activityMessageRenderConfigs() liegen neben den beim Anwender registrierten Konfigurationen auch die Standard-Renderer – hierzu zählt ebenfalls der über provideMCPApps aktivierte Renderer für MCP-Apps. Dieser zieht sich das HTML der jeweiligen App über den bereits erläuterten Proxy und leitet sämtliche weiteren Aufrufe des Widgets dorthin um. Die serverId aus dem Activity Snapshot bestimmt dabei, welcher der beim Proxy hinterlegten MCP Server gemeint ist – die Server-URL bleibt ausschließlich dem Backend vorbehalten.
Die Renderer-Auswahl selbst übernimmt 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,
},
};
}
The selection mechanism mirrors the internal logic CopilotKit applies within its CopilotChatMessageView component: among the renderers that match the given activityType—here, mcp-apps—priority goes first to one registered for the specific agentId, followed by an agent-agnostic entry, and finally a wildcard renderer tied to the '*' activity type. The function then checks the snapshot's contents against the schema stored in the chosen renderer and returns the component along with its input properties, which the template renders using *ngComponentOutlet.
Summary
MCP standardizes how tools connect to agents, and MCP Apps carries this concept into the user interface domain: rather than receiving a tool result as plain text, the user is presented with an interactive, purpose-built interface. An MCP App is a standalone web application that the MCP server exposes as a resource, with a tool's metadata pointing to it through a resourceUri. The host loads the app inside a sandboxed iframe and exchanges messages with it via the postMessage API—wrapped on the app side by the App object and on the host side by the AppBridge.
Through the host context, the host dictates theming and layout, while the app, in return, reports its required dimensions to prevent scrollbars. The lifecycle spans from initialization, through the exchange of tool parameters and results, to an orderly shutdown using teardownResource and close. In a practical setup with a language model, the MCP server registers both the tool and its resource together, the agent incorporates those tools as if they were local, and after a tool call, AG-UI transports an ACTIVITY_SNAPSHOT to the client—where an activity renderer picks it up and displays it as an MCP App. The associated widget does not talk to the MCP server directly; instead, it forwards resource and tool requests through the AG-UI agent to a server-side proxy built on the MCPAppsMiddleware.
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.


