A Practical Guide to Building MCP Apps

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

AI agents are powerful conversationalists, but conversations aren’t always enough. When an agent needs to walk you through a complex dataset, collect structured input, or present a real-time visualization, plain text hits a wall.

Today, bridging that gap means bouncing users out to external web apps – dragging along custom APIs, authentication layers, and fragile state management. The conversational flow shatters, context is lost, and development overhead piles up.

MCP Apps offer a way to go beyond the text. Built as an extension of the open source Model Context Protocol (MCP), MCP Apps standardize how MCP servers can deliver rich, bidirectional UI components like dashboards, forms, interactive visualizations and more. These components and rendered securely and natively within AI hosts, enabling agents to not just describe the answer, but to show it via an interactive interface.

In this 3-part article series on MCP Apps, you will learn:

  1. The Foundations (This article) – Core architectural patterns for declaring UI resources, design principles, handling sandboxed host–server communication & more.
  2. A real-world agentic MCP App workflow use case (Coming Soon!)
  3. How to deploy a remote MCP Server on Amazon Bedrock AgentCore (Coming Soon!)

Getting started

Before we dive into the various fundamental concepts of MCP Apps, let us setup the following project directory structure to provide a practical pattern for building MCP Apps that are not just functional, but coherent and maintainable (other official typescript example servers):

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

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.

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 our 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 the 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.

The below utility files define the common scripts used by other apps:

  • 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.

1. Anatomy of a minimal MCP App

Let us get started with creating our first MCP App.

Step 1 – Resource and Tool Registration

Update the index.ts file with the following:

In this file,

const URI = "ui://mcp-apps-spec-examples";

is the base URI of the UI resources.

const MIME = "text/html;profile=mcp-app" as const;

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",
});

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() }],
  }),
);

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." }] };
  },
);

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)

The MCP App resource GREETING_HTML() is defined 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. 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.';
    });

The host returns hostContext and capabilities, 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
  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
....

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.';
    });
...

(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
    });

A demo to display it in the MCP app is provided 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 as shown below:

It contains the openLink() function which triggers the ui/open-link request with the URL parameter.

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);
  }
}

Let us register this resource and the corresponding tool "open-link-from-app":

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

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 button, the below 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 (Link to full 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 }],
});

Let us register the resource & tool:

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 has 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 (Link to full 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 }],
});

Let’s register the resource & tool as shown below:

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 (Link to full 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);

Registering the resource & the tool:

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, 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 as shown below:

where,

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

registers the domains.

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

<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>

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 succeeded! (CSP allows this)\n\n' + JSON.stringify(json, null, 2).substring(0, 800);
      } catch (err) {
        el.textContent = '❌ Direct fetch blocked: ' + err.message;
      }
    }

Let us take a look at the complete MCP App demo 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"] value in the case. This means that the tool is visible to agent and the MCP Apps.

To hide any tool from the agent but make it callable by MCP apps via tools/call, set visibility value to ["app"] as shown below:

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

Visible only to app

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

Let us create ui/tools-call.ts (Link to full 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"];

Let us 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.

Leave a Reply