MCP and MCP Apps

Before diving into the SDK and the code, it is worth examining the two building blocks our solution relies on: MCP as the foundation and MCP Apps as the extension for interactive surfaces.

MCP

The Model Context Protocol (MCP) is an open standard that allows third parties to provide tools. Anthropic introduced the popular protocol in November 2024 and handed it over to the Agentic AI Foundation at the end of 2025 — a fund under the umbrella of the Linux Foundation. MCP delivers both metadata about the provided tools and the ability to invoke them:

Agentic UI mit MCP Apps: Tool-Ergebnisse als interaktive Widgets — figure 1

This makes it very easy for agents to integrate such tools. Other core responsibilities of MCP include providing resources — such as files, documents, or database content — as well as offering reusable prompts.

MCP Apps

MCP Apps is an extension to MCP that makes it possible to offer interactive user interfaces for the provided tools. Such an app can invoke a tool, visualize the execution progress, and present the received results. We use it here for the latter, especially since the invocation in our system happens through AG-UI.

From a technical standpoint, an MCP App is its own web application that can be built on any framework or on VanillaJS. MCP Apps are provided via MCP as resources. The metadata of a tool references the respective app with a resourceUri:

Agentic UI mit MCP Apps: Tool-Ergebnisse als interaktive Widgets — figure 2

{
  "ui": {
    "resourceUri": "ui://hotels/results.html"
  },
  "ui/resourceUri": "ui://hotels/results.html"
}

Using this URI, an application retrieves the entire file via MCP and thus visualizes the respective tool. In the example shown, this URI appears twice, whereby the second, flat version (ui/resourceUri) exists solely for reasons of backward compatibility. The nested version corresponds to the current standard.

The application that embeds an app is called the host. To avoid conflicts, the host isolates the app in a sandbox. An iframe is used for this purpose. So that the app does not feel like a foreign element, the host passes along a so-called host context, which, among other things, defines well-specified CSS variables for theming or informs about the available space. In addition, the host passes the parameters sent to the invoked tool as well as the received result.

To avoid a scrollbar in the iframe, the app informs the host about its space requirement, allowing the host to enlarge the iframe accordingly. All communication between host and app happens through the exchange of JSON documents. Both sides typically send these via the postMessage API. The JSON documents are defined in the MCP-Apps protocol.

Agentic UI with Angular

If you want to not only integrate MCP Apps, but also embed them cleanly into larger architectures:
In my book Agentic UI mit Angular, I go into exactly these patterns and trade-offs in detail.

Cover des eBooks Agentic UI with Angular

Mehr zum eBook →

First Demo Application

The following sections illustrate how a host and an app work. For the sake of simplicity, this first example does not use an agent and therefore no language model either. Instead, we use a simple host based on VanillaJS that merely loads an app, which is also built on VanillaJS.

The host loads this app into an iframe and sends it, among other things, a few hotels:

Agentic UI mit MCP Apps: Tool-Ergebnisse als interaktive Widgets — figure 4

The entire source code is located in the demo repository under mcp-apps-demo.

MCP Apps SDK

For the app, it provides an App object, and the host uses an AppBridge:

Agentic UI mit MCP Apps: Tool-Ergebnisse als interaktive Widgets — figure 5

The two objects connect through what is called a transport. The SDK ships with a transport for postMessage. Once both parties are connected, they can send messages to each other. Both objects have methods for sending messages; receiving happens through event handlers.

Providing a Host

To load an app, the host first creates an iframe, which it secures via the sandbox property. It then loads the HTML of the linked resource into it. For simplicity, we instead set the src property to a hardcoded HTML file:

const frameHost = document.getElementById('iframe-host');

if (!(frameHost instanceof HTMLDivElement)) {
  throw new Error('Missing iframe host element.');
}

const iframe = document.createElement('iframe');
iframe.title = 'MCP App Demo';
iframe.sandbox.add('allow-scripts');
iframe.sandbox.add('allow-same-origin');
// iframe.srcdoc = '<html>...</html>';
iframe.src = './app.html';
frameHost.append(iframe);

As soon as the sandbox property is present, the browser prohibits actions in the iframe such as downloads, submitting forms, or top-level navigation. By default, a sandbox also forbids executing scripts in the iframe and assigns the origin null to the application loaded in the iframe. That would be counterproductive in the case of MCP Apps, especially since the app is based on JavaScript and an origin of null prohibits communication via postMessage. For this reason, our host relaxes the sandbox somewhat with the exceptions allow-scripts and allow-same-origin.

Once the app has been loaded into the iframe, the host establishes a connection with the AppBridge:

import {
  AppBridge,
  PostMessageTransport,
} from '@modelcontextprotocol/ext-apps/app-bridge';

[...]

const bridge = new AppBridge(
  null,
  { name: 'MCP Apps Demo Host', version: '1.0.0' },
  { logging: { level: 'info' } },
);

bridge.onsizechange = (event) => {
  iframe.style.height = `${Math.ceil(event.height ?? 0)}px`;
};

await bridge.connect(
  new PostMessageTransport(iframe.contentWindow, iframe.contentWindow),
);

await waitForInitialization(bridge);

bridge.sendToolInput({
  arguments: {
    city: 'Graz',
  },
});

bridge.sendToolResult({
  content: [
    {
      type: 'text',
      text: 'The host sends this tool result to the app.',
    },
  ],
  structuredContent: {
    city: 'Graz',
    hotels: ['Grand Palace', 'Skyline Suites', 'Biz Hotel'],
  },
});

bridge.sendHostContextChange({
  availableDisplayModes: ['fullscreen'],
  displayMode: 'fullscreen',
  theme: 'light',
  styles: {
    variables: {
      "--color-background-primary": "#3f51b5"
    } as StyleVariables
  }
});

The first argument expected by the AppBridge is a so-called (MCP) client, which allows a direct connection to the MCP server. However, since we do not want coupling to the MCP server here, but only need to visualize the tool-call results received via AG-UI, we pass the value null intended for such cases.

The defined application name and version are made available by the AppBridge to the app. The app can check whether it can communicate with this host or whether, for example, it is dealing with a no longer supported version. These checks do not happen automatically; they would have to be implemented explicitly if needed — something we deliberately omit in this example.

The logging level info gives us the opportunity to trace all exchanged messages on the JavaScript console. After initialization, the app triggers the onsizechange event without further action. The host uses this to resize the iframe to the required size. This avoids a scrollbar in the iframe.

The connect method establishes the connection to the App object in the app. The PostMessageTransport, which uses the postMessage API for communication, receives the contentWindow of the iframe both as the source of messages sent to the host and as the destination of messages sent by the host.

After calling connect, one must wait until the app triggers the oninitialized event. This is done with the helper function waitForInitialization, which converts the event into a promise:

function waitForInitialization(bridge: AppBridge): Promise<void> {
  return new Promise((resolve) => {
    bridge.oninitialized = () => {
      resolve();
    };
  });
}

After initialization, the host sends the parameters passed to the tool (sendToolInput), the received result (sendToolResult), and the host context (sendHostContextChange). The latter defines details about the presentation and behavior of the app embedded in the host.

The typing requires specifying all CSS variables for styling. To limit this demo to just one variable, a type assertion to StyleVariables is performed here. Unfortunately, the SDK does not publish this type, which is why we have to derive it from the public API:

type StyleVariables = NonNullable<
  McpUiHostContext['styles']
>['variables'];

Providing an App

The MCP app creates an App instance, sets up event handlers for receiving the passed parameters, the tool result, and the host context, and connects to the host:

import { App } from '@modelcontextprotocol/ext-apps';

[...]

const app = new App({
  name: 'MCP Apps Demo App',
  version: '1.0.0',
});

app.ontoolinput = (input) => {
  [...]
};

app.ontoolresult = (result) => {
  [...]
};

app.onhostcontextchanged = (context) => {
  [...]
};

await app.connect();

Cleaning Up App and Host

If the host removes an app again — for instance, because the user closes the widget or the conversation moves on — both sides should shut down in an orderly fashion. The SDK offers two mechanisms for this: a teardown request that gives the app the opportunity to save its state, as well as closing the underlying connection.

On the host side, the AppBridge requests an orderly shutdown with teardownResource and waits for confirmation from the app. Only then does it close the postMessage connection with close and remove the iframe:

await bridge.teardownResource({});
await bridge.close();
iframe.remove();

The app reacts to this request via the onteardown event handler. The handler may work asynchronously; the host waits for the returned promise before removing the iframe. The app can end the connection itself at any time with app.close():


app.onteardown = async () => {
  return {};
};

The app can also initiate the teardown itself — for example, via a close button in its interface. To do this, it calls requestTeardown:

await app.requestTeardown();

The host learns about this through the onrequestteardown event and decides whether it actually initiates the shutdown:

bridge.onrequestteardown = async () => {
  await bridge.teardownResource({});
  iframe.remove();
};

Both the AppBridge and the App object inherit close from the Protocol base class of the MCP SDK.

Closing Thoughts and Next Steps

While MCP brings standardization to how agents connect with tools, MCP Apps extends this idea into the user interface layer: rather than returning a text-based tool result, the user is presented with an interactive, purpose-built surface. An MCP App is essentially a standalone web application that the metadata of a tool points to via a resourceUri. The host loads this application within a sandboxed iframe and exchanges messages with it through the postMessage API — wrapped on the app side by the App object and on the host side by the AppBridge.

Using a minimal demo built on VanillaJS, we walked through the entire lifecycle: setting up the host, running initialization, passing tool parameters, results, and host context between the two parties, and finally performing an orderly shutdown with teardownResource and close. We deliberately omitted both an agent and a language model from this exercise to keep the focus entirely on the interaction between the host and the app.

With that groundwork in place, we are ready to move on. In the next article, we put this knowledge to work in a full case study: connecting the MCP server of a business partner that specializes in hotel bookings to our flight portal. This time, we bring along a language model, the Mastra agent framework, and the AG-UI protocol, which ships an ACTIVITY_SNAPSHOT to the client after a tool call — one that the Activity Renderer for MCP Apps, included in CopilotKit, renders for us.

Continue to the next article →

Looking to build production-grade agentic UI architectures?

My workshop covers AG-UI, A2UI, MCP Apps, HITL patterns, and modern Angular architectures for real-world agentic systems.

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

Get all the details →