DEV Community

Cover image for How I render interactive UI in my AI Agent chatflows using MCP Apps
Ashita Prasad
Ashita Prasad

Posted on • Edited on

How I render interactive UI in my AI Agent chatflows using MCP Apps

by Ashita Prasad (GitHub, LinkedIn, X, Instagram)

You know the feeling of building an incredibly smart AI agent that chats flawlessly, but the second your user needs to interact with a complex dataset, fill out a structured form, or view a real-time chart, that conversation hits a dead end.

And you realize - Plain text just isn't enough!

Right now, the only way around this is forcing users out of the chat and into an external web app. For you, that means suddenly wrestling with custom APIs, building redundant authentication layers, and taping together fragile state management. Whereas, for your users, the seamless conversational flow shatters, context vanishes, and the experience instantly feels disjointed.

And, instead of building something magical, you end up buried in development overhead just to show a simple graph.

I was going through a similar experience recently while building an Agentic chatflow and then I came across MCP Apps. Built as an extension of the open-source Model Context Protocol (MCP), MCP Apps give you a standardized escape route from the plain-text trap and let your MCP server deliver rich, bidirectional UIs - like interactive dashboards, data collection forms, and real-time visualizations—rendered securely and natively right inside your AI host.

Are you ready to fix your AI Agent Chat-flow? In this 2-part step-by-step tutorial series, I will explain:

  1. The Foundations of MCP Apps (This article) - Covering the core architectural patterns for declaring UI resources, practical design principles, and how to handle sandboxed host–server communication.
  2. How you can deploy a real-world TypeScript MCP App Server on Amazon Bedrock AgentCore. (Link)

Getting started

1. Project directory structure

Let us create the following directory structure for our MCP server project:

src/
  index.ts
  ui/
    greeting.ts
    host-style-variables.ts
    ...
    tools-call.ts
  utils/
    apply-host-context.ts
    message-handler.ts
    rpc-client.ts
    shared-styles.ts
Enter fullscreen mode Exit fullscreen mode

I have created this practical directory structure for building MCP Apps so that it is not just functional, but coherent and maintainable, where:

  • src/index.ts is the MCP server entry point where we register all tools and UI resources,
  • src/ui contains UI resources for each MCP App, and
  • src/utils contains the shared utilities for design and communication used across UI resources, making them maintainable and consistent.

2. Shared design system for consistency & maintainability

As MCP Apps provide the visual interface to interact with MCP tools, a consistent design system across all UI resources provides a better user experience. In this project, utils/shared-styles.ts contains:

  • Design tokens for background, text, border, etc.
  • light-dark(...) fallbacks so the same app can adapt cleanly across themes.
  • Typography tokens, spacing utilities, and border radius primitives.
  • Reusable UI components for cards, badges, buttons, inputs, textareas, logs, and tables.

A snapshot of shared-styles.ts file is provided below:

In absence of shared-styles.ts, each app would feel disconnected and require re-implementation of styling, spacing, components and form controls.

3. Other utilities

We also define some more utility files that contain the common scripts used across all MCP apps defined in the server:

  • utils/rpc-client.ts (Link to code file): Creates the JSON-RPC client embedded in each HTML template that manages request IDs, tracks pending promises, and provides request and notify helpers.
  • utils/message-handler.ts (Link to code file): Handles inbound messages, resolves pending RPC calls, and responds to host context changes.

A deeper look into MCP Apps via examples

Now, we are ready to dive deeper into the various concepts involved while building an MCP App.

1. Anatomy of a minimal MCP App

Let us get started with creating our first MCP App.

Step 1 - Resource and Tool Registration

Add the following code in the index.ts file:

In this file,

const URI = "ui://mcp-apps-spec-examples";
Enter fullscreen mode Exit fullscreen mode

is the base URI of the UI resources.

const MIME = "text/html;profile=mcp-app" as const;
Enter fullscreen mode Exit fullscreen mode

is the official mime-type of the HTML content which is loaded by the host as a sandboxed iframe.

const server = new McpServer({
  name: "mcp-apps-spec-examples",
  version: "1.0.0",
});
Enter fullscreen mode Exit fullscreen mode

initializes a new Model Context Protocol (MCP) Server instance.

server.registerResource(
  "greeting",
  `${URI}/greeting`,
  { mimeType: MIME, description: "A static greeting with no interaction." },
  async (uri) => ({
    contents: [{ uri: uri.href, mimeType: MIME, text: GREETING_HTML() }],
  }),
);
Enter fullscreen mode Exit fullscreen mode

registers a new UI resource named "greeting" with the unique identifier "ui://mcp-apps-spec-examples/greeting". The HTML content is provided by GREETING_HTML() which we will cover shortly.

server.registerTool(
  "greeting",
  {
    description: "A greeting tool with no interaction.",
    _meta: { ui: { resourceUri: `${URI}/greeting` } },
  },
  async () => {
    return { content: [{ type: "text" as const, text: "Greeting executed." }] };
  },
);
Enter fullscreen mode Exit fullscreen mode

registers a tool linked to the resource "ui://mcp-apps-spec-examples/greeting" through _meta.ui.resourceUri.

This pattern is the backbone of MCP Apps as the server declares the view, the tool points at the view, and the host knows exactly what to load.

Step 2 - UI Resource Creation (App Template)

Let us now define the MCP App resource GREETING_HTML() in ui/greeting.ts. It is a static HTML with no user interaction.

This UI runs in a sandboxed iframe and communicates with the host through auditable JSON-RPC messages.

When the UI calls ui/initialize as shown below:

request('ui/initialize', { protocolVersion: '2025-11-21' }).then((res) => {
  notify('ui/notifications/initialized');
  document.getElementById('status').textContent = '✅ Handshake complete – UI is live.';
});
Enter fullscreen mode Exit fullscreen mode

The host returns hostContext and capabilities (covered later in detail), post which the UI sends ui/notifications/initialized to complete the handshake.

This is the minimum viable MCP App which is just HTML + the MCP Apps handshake.

Step 3 - Running the MCP App

To witness the MCP app in action,

  1. We will first compile the TypeScript code via:
npm run build
Enter fullscreen mode Exit fullscreen mode
  1. Download a host that supports MCP Apps extension. We will use VS Code Insiders to test our MCP Apps.
  2. Open a new folder in VS Code Insiders and create .vscode/mcp.json file which runs the MCP server locally:

Now, using the chat prompt show greeting we can trigger the tool call rendering the MCP App as shown below.

Greeting App

2. Host-aware MCP App Theme

You can witness in the above demo that the MCP App card theme is currently not blending with the host theme. To provide a consistent user experience it is important to make an embedded app look native instead of bolted on iframe. The MCP Apps specification provides this provision during the MCP Apps protocol handshake - When the View (App) sends an ui/initialize request to the host, the host responds with its current hostContext (theme + style tokens) as shown below:

--color-background-primary = var(--vscode-editor-background)
--color-background-secondary = var(--vscode-sideBar-background)
....
--color-text-primary = var(--vscode-foreground)
--color-text-secondary = var(--vscode-descriptionForeground)
....
--font-weight-semibold = 600
--font-weight-bold = bold
....
--font-text-xs-line-height = 1.5
--font-text-sm-line-height = 1.5
....
Enter fullscreen mode Exit fullscreen mode

These host-provided CSS variables are practical styling input that a UI can inspect and render using applyHostContextScript() function defined in utils/apply-host-context.ts.

Let us modify ui/greeting.ts to execute applyHostContext(res?.hostContext); to update the CSS theme and make it host-aware:

import { applyHostContextScript } from "../utils/apply-host-context.js";
...
...
${applyHostContextScript()}
...
    request('ui/initialize', { protocolVersion: '2025-11-21' }).then((res) => {
      applyHostContext(res?.hostContext);
      notify('ui/notifications/initialized');
      document.getElementById('status').textContent = '✅ Applied Host Context & Handshake complete.';
    });
...
Enter fullscreen mode Exit fullscreen mode

(Link to full code)

Now, using the same chat prompt we can trigger the tool call rendering the MCP App with host theme applied on it.

Greeting App - Host Context

3. Discovering Host Capabilities

An MCP App should not assume that every host supports the same set of features. hostCapabilities are sent to the View when it sends an ui/initialize request to the host which describes the capabilities the host supports like:

Capability Description
openLinks Host supports opening external URLs/links from within the MCP app in the host's browser or external application.
serverTools Can proxy tool calls where the host will re-fetch the tool list and update its UI accordingly.
serverResources Host can proxy resource reads and refresh its resource listing when the server's resource set changes.
logging Host supports the MCP logging capability, allowing the server to emit structured log messages back to the host.
sandbox.permissions.clipboardWrite App runs in a sandboxed environment, but has been granted the permission to copy content to the user's clipboard.
updateModelContext Host can accept context updates pushed from the app. The supported content types can be - audio, image, structuredContent (JSON), etc.
downloadFile App can trigger a file download in the host environment (e.g., save to Downloads).

Host capabilities can be obtained from the host response to 'ui/initialize' as shown below:

request('ui/initialize', { protocolVersion: '2025-11-21' }).then((res) => {
  applyHostContext(res?.hostContext);
  notify('ui/notifications/initialized');
  const caps = res?.hostCapabilities || {}; // host capabilities
});
Enter fullscreen mode Exit fullscreen mode

Let us add the UI Resource (ui/host-capabilities.ts) and register the resource & tool as shown here. On calling the tool, the MCP app will get rendered showing all the host capabilities as shown below:

Host capabilities

4. Example Host Capability - Open Link

Let us explore the host capability of directly navigating from the iframe by opening an external URL.

Create the App UI resource ui/open-link.ts (code).

It contains the openLink() function which triggers the ui/open-link request with the URL parameter. This lets the host know that the app is requesting it to open the specified URL.

async function openLink() {
  const url = document.getElementById("urlInput").value;
  try {
    await request("ui/open-link", { url });
    el.textContent = "✅ Host accepted the request for:\n" + url;
  } catch (err) {
    el.textContent = "❌ Host denied or error:\n" + JSON.stringify(err);
  }
}
Enter fullscreen mode Exit fullscreen mode

Let us register this resource and the corresponding tool "open-link-from-app" (code).

After entering the prompt, the agent displays the MCP App as shown below:

On clicking the Open button, the MCP App requests the host to open the URL and the host opens a confirmation dialog which demonstrates the security-first approach where "apps request, hosts decide".

Upon pressing the Open confirmation button, the requested website is opened in the default browser.

5. Updating Model Context via MCP App

Interactions with an MCP App can often generate useful context (of various content types) that can be directly added to the host's model context. This helps with preference capture, session refinement, and app-guided workflows.

Let us create ui/update-model-context.ts (code), which adds the text entered by the user to the model context via ui/update-model-context request:

await request("ui/update-model-context", {
  content: [{ type: "text", text }],
});
Enter fullscreen mode Exit fullscreen mode

Let us register the resource & tool (code).

Upon triggering the tool, the MCP App is displayed as shown below:

Update Model Context

Once the user enters the text and presses the Update Context button, the updated context can be seen in the chat box.

6. Adding Message to the Chat Box

MCP App also provides the ability to send content back into the host's chat box, to enable user to edit it before sending it to the chat flow.

Let us create ui/ui-message.ts (code).

To send the message to chat box, ui/message request can be triggered with the text content.

await request("ui/message", {
  role: "user",
  content: [{ type: "text", text }],
});
Enter fullscreen mode Exit fullscreen mode

Let's register the resource & tool (code).

Upon triggering the tool, the MCP App gets displayed as shown below:

ui/message

It allows user to communicate a text message from the MCP App to the host's chat box. This opens up new possibilities as the App is no longer a visual tool, and is a part of the conversation loop.

7. Resizing the iframe

As the content of an MCP App expands, there is a provision to send a size-changed notification to the host to ensure that the frame is sized correctly.

Let us create ui/size-changed.ts (code).

We will use ResizeObserver to report any changes to the dimensions of an Element's content or border box and send ui/notifications/size-changed so the host can resize the frame.

const ro = new ResizeObserver(() => {
  notifyCount++;
  const w = document.documentElement.scrollWidth;
  const h = document.documentElement.scrollHeight;
  notify('ui/notifications/size-changed', { width: w, height: h });
  document.getElementById('sizeInfo').textContent =
    'Notifications sent: ' + notifyCount + '  (current: ' + w + '×' + h + ')';
});
ro.observe(document.body);
Enter fullscreen mode Exit fullscreen mode

Let us now register the resource & the tool (code).

We can press the +Add Item button to add new data rows. The updated value of scroll width and scroll height are notified to the host so that it can update the iframe height accordingly.

Size changed

This feature solves a real UX pain point as the host can get the required size and grow or shrink the iframe accordingly.

8. Content Security Policy (CSP)

So far we have been working with Apps that have self-contained CSS & JS, and are not making any request to fetch data using external API.

As MCP Apps are executed inside a sandbox by the host to avoid any security risks, Content Security Policy (CSP) is the mechanism for explicitly defining which resources the MCP App is allowed to load or execute. This prevents malicious scripts or external resources from being injected into the MCP App, which in turn protects the host.

The typical flow of applying CSP in an MCP App is as follows:

  1. MCP server returns a tool response with UI metadata.
  2. The host renders the MCP App in a sandboxed iframe.
  3. The host applies CSP rules.
  4. The MCP App can only access approved resources.

This external access is declared through the metadata during resource registration. The resource must include _meta.ui.csp so that the host can allow MCP App access to only the specific domains. Different types of domains that can be provided are:

Field Purpose
connectDomains Origins the MCP App can reach via network/data requests (fetch, XHR, or WebSocket).
resourceDomains Origins allowed to load static assets like images, scripts, stylesheets, fonts, media.
frameDomains Origins for nested iframes
baseUriDomains Allowed base URIs for the document

Let us register csp-example, a sample resource and tool (code), where

csp: {
  connectDomains: ["https://httpbin.org"],
  resourceDomains: ["https://cdn.jsdelivr.net"],
},
Enter fullscreen mode Exit fullscreen mode

registers the allowed domains.

Now create ui/csp-example.ts (code), where

<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"><\/script>
Enter fullscreen mode Exit fullscreen mode

loads the external dayjs javascript library that is used to format the current date using dayjs().format('dddd, MMMM D YYYY') method.

Also, when we press the button Fetch, the MCP App executes runDirectFetch() to fetch the latest content from REST API endpoint https://httpbin.org/get as shown below:

async function runDirectFetch() {
  const el = document.getElementById('result');
  el.textContent = 'Testing direct fetch from browser…';
  try {
    const res = await fetch('https://httpbin.org/get');
    const json = await res.json();
    el.textContent = '✅ Direct fetch successful! (CSP allows this)\\n\\n' + JSON.stringify(json, null, 2).substring(0, 800);
  } catch (err) {
    el.textContent = '❌ Direct fetch blocked: ' + err.message;
  }
}
Enter fullscreen mode Exit fullscreen mode

Let us now execute tool which loads the MCP App as shown below:

9. Calling MCP Tools

One of the key innovations of MCP Apps is the ability provided to invoke another MCP tool using the tools/call method from inside an MCP App making it an interaction surface for broader tool orchestration.

Let us create and register a new tool eval-tool on the MCP server which evaluates a string expression:

Once you create a new build and restart the server, you can verify that this tool is now visible to the agent.

Visible to Agent

MCP Apps spec, provides a provision to restrict this visibility of any tool to only the MCP Apps available on the server using _meta.ui.visibility field which defaults to ["model", "app"] that means that the tool is visible to agent and the MCP Apps.

You can hide any tool from the agent but make it callable by MCP apps via tools/call, by setting its visibility value to ["app"] (code) as shown below:

_meta: {
  ui: {
    visibility: ["app"],
  },
},
Enter fullscreen mode Exit fullscreen mode

Let us verify the same. The tool is no longer visible to the agent as shown below:

Visible only to app

This feature now enables UI-only interactions (refresh buttons, form submissions) without exposing implementation details to the agent/model.

Let us create ui/tools-call.ts (code). When the user presses the Call tools/call button, it makes a tools/call request and populates the result as shown below:

const res = await request("tools/call", {
  name: "eval-tool",
  arguments: { expression },
});
el.textContent = res["structuredContent"]["result"];
Enter fullscreen mode Exit fullscreen mode

Let us now run the MCP App to evaluate some mathematical expressions as shown below:

tools/call example

You can now witness how powerful the MCP App interface can be for invoking deeper tool workflows.

Final Words

Today, we deep dived into some of the foundational building blocks of an MCP App. With these learnings, in the next article we will look at a real world agentic workflow use case demonstrating the power of MCP Apps.

I would love to hear your experience with MCP Apps or about any issues you faced while going through this tutorial. Please mention it in the comment section below and I will definitely address it. Also, in case you have any other suggestion, feel free add it in the comments.

Disclaimer: The opinions expressed here are my own and do not necessarily represent those of current or past employers. Please note that you are solely responsible for your judgement on checking facts. This post does not monetize via any advertising.

Top comments (3)

Collapse
 
harsh_prasad_0e76026a256f profile image
Harsh Prasad

Learnt something new today, looking forward to the next one.

Collapse
 
ashita profile image
Ashita Prasad

Thanks Harsh. Glad you liked the article 😀

Collapse
 
himanshu_iwanati_23782ef9 profile image
Himanshu Iwanati

Hi, I wanted to ask what's the difference between the host-context and host's model context ? like is host-context is it simply just the details of theme + style-tokens of host or more than that ? and host's model context is something what I understood is that we are updating it with any useful dynamic changes that occurs in our MCP apps, so we are constantly updating it.