Articles · · 11 min read

MCP Apps From Scratch (Part 1)

AI

When an MCP tool returns a result, the host usually hands the model a block of text. An MCP App is the same idea with a UI attached: the tool points at a web page, and a host like Claude, ChatGPT or VS Code renders that page in a sandboxed iframe, next to the model’s reply.

If you write frontend for a living, the interesting part is what that page is made of. It is not a host-specific widget format. It is HTML, CSS and JavaScript — in this series, a plain web component — bundled into a single file. You already know how to build that.

I am learning this by building it, and writing it up as I go. If I can learn it directly from a small, working example, that proves its value. The longer goal is to bring an MCP App to this site, starting as small as possible and growing from there.

Why should a frontend dev care?

Shopping is moving into the chat. Customers are starting to find, compare and buy products by asking an AI assistant instead of browsing a website, and the big platforms are building checkout into their agents.

That raises a question for every company with a product to sell: who builds the experience the customer sees? MCP Apps are the answer. Your developers write the app (the product card, the options, the cart) as a web component, and the chat host delivers it to the customer wherever they are.

What this series covers

The code lives in one repo, mcp-apps-series. Each post is a complete example in its own folder. A shared test host in host/ is a slimmed-down copy of the official basic-host from the ext-apps SDK, so you do not need a Claude or ChatGPT account to try any of this locally.

The five posts:

  1. Hello MCP App — one tool, one ui:// resource, and a view that renders structuredContent from ontoolresult. That is this post.
  2. The view talks back — a button that calls callServerTool, a button that calls openLink, and how host capabilities gate both.
  3. Inside the host — the sandbox iframe, the CSP header, and AppBridge.
  4. Theming and host context — onhostcontextchanged and CSS custom properties.
  5. Going live — a free deploy, connecting to Claude, and pointing the tool at posts from this site.

Run it

You will need Node 20+ and npm.

git clone https://github.com/mdownes/mcp-apps-series.git
cd mcp-apps-series/post-1-hello
npm install
npm start

Open http://localhost:8080. npm start builds the view and the shared host, then starts the MCP server on port 3001 and the host together. The host calls the hello tool when the page loads, so you should see Hello, world! and an ISO timestamp in the sandboxed view.

Press Call to run the tool again. To greet someone else, set Arguments to {"name":"Ada"} and press Call. The greeting updates to “Hello, Ada!”.

If you only want the MCP server, without the host, npm run start:stdio starts it over stdio, which some desktop clients prefer. Opening http://localhost:3001/mcp in a browser will not show the view: that URL only accepts MCP requests. The view always appears through a host.

The server: a tool and a resource

An MCP App is two registrations that point at each other.

The tool is hello. Its _meta.ui.resourceUri tells the host: when you show this result, render the UI at this URI.

const RESOURCE_URI = "ui://hello/view.html";

registerAppTool(
  server,
  "hello",
  {
    title: "Hello",
    description: "Returns a greeting and the current server time.",
    inputSchema: z.object({
      name: z.string().optional().describe("Name to greet. Defaults to 'world'."),
    }),
    outputSchema: z.object({
      greeting: z.string(),
      time: z.string(),
    }),
    _meta: { ui: { resourceUri: RESOURCE_URI } },
  },
  async ({ name }) => {
    const who = name?.trim() || "world";
    const time = new Date().toISOString();
    const greeting = `Hello, ${who}!`;
    return {
      content: [{ type: "text", text: `${greeting} The time is ${time}.` }],
      structuredContent: { greeting, time },
    };
  },
);

The callback is the tool implementation. It runs when a model (or our test host) calls hello. The return value is a CallToolResult with two payloads:

  • content is for the model. Here it is a single text block, such as "Hello, world! The time is 2026-11-10T15:00:00.000Z."
  • structuredContent is for the view. Here it is { greeting, time }.

Those field names come from the MCP specification. You cannot rename them. If the tool declares an outputSchema, as this one does, structuredContent must match it.

The resource is the UI itself. registerAppResource serves the single HTML file that Vite built into dist/view.html. The MIME type is RESOURCE_MIME_TYPE from the SDK, which is text/html;profile=mcp-app. The ui:// URI is not fetched over HTTP by the view. The host reads the resource and writes the HTML into a sandbox.

registerAppResource(server, RESOURCE_URI, RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async () => ({
  contents: [{
    uri: RESOURCE_URI,
    mimeType: RESOURCE_MIME_TYPE,
    text: await fs.readFile(VIEW_PATH, "utf-8"),
    // The view loads nothing external (data arrives via the host), so declare no domains.
    _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] } } },
  }],
}));

The empty CSP lists are deliberate. This view does not fetch fonts, APIs or images. Data arrives through the host. Post 3 will look at how the host turns those lists into a Content-Security-Policy header on the sandbox.

The view: a custom element and a timing rule

The view is a plain custom element, <hello-view>. It extends HTMLElement, opens a shadow root, and never talks to the MCP server itself. The host pushes the tool result in. There is no framework: render() builds a paragraph and a <time> element and sets their textContent.

The one import is App from the ext-apps SDK. It’s the view’s half of the protocol: it does the handshake with the host and gives you the ontoolresult hook.

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

type Data = { greeting: string; time: string };

class HelloView extends HTMLElement {
  private data?: Data;
  private app = new App({ name: "hello-view", version: "0.1.0" });

  constructor() {
    super();
    this.attachShadow({ mode: "open" });
    this.render();
  }

  connectedCallback() {
    // Register BEFORE connect() so a fast host cannot deliver the result first.
    this.app.ontoolresult = (result) => {
      this.data = result.structuredContent as Data;
      this.render();
    };
    this.app.connect();
  }

  private render() {
    const root = this.shadowRoot!;
    root.innerHTML = `
      <style>
        :host { display: block; font: 16px/1.5 system-ui; padding: 1rem; }
        time { opacity: 0.65; }
      </style>
    `;
    const message = document.createElement("p");
    if (!this.data) {
      message.textContent = "Waiting for the tool result…";
      root.append(message);
      return;
    }
    message.textContent = this.data.greeting;
    const time = document.createElement("time");
    time.dateTime = this.data.time;
    time.textContent = this.data.time;
    root.append(message, time);
  }
}

customElements.define("hello-view", HelloView);

Two details matter.

First, ontoolresult reads result.structuredContent, not result.content. The text in content is for the model. The view stores the structured object in this.data and calls render(), which sets textContent on the greeting and the time.

Second, the handler is registered before app.connect(). connect() posts a ui/initialize request to the host through window.parent (here, via the sandbox proxy), waits for the reply, then sends ui/notifications/initialized to say the view is ready. If you flip the two lines, a fast host can send the tool result before the handler exists, and the view stays on “Waiting for the tool result…”.

view.html is a small page shell that mounts the element. Vite and vite-plugin-singlefile bundle that page and view.ts into one file, dist/view.html. That is what the resource returns. A single file is required: the host writes the HTML into the sandbox, and the view is not free to go off and load a separate /assets/index.js.

export default defineConfig({
  plugins: [viteSingleFile()],
  build: {
    rollupOptions: { input: "view.html" },
    outDir: "dist",
    emptyOutDir: true,
  },
});

What happens when the page loads

The test host and the MCP server run on different ports, and the sandbox proxy is a third origin on port 8081. The view never talks to the server directly. Every message goes through the host.

sequenceDiagram
    participant H as Host (:8080)
    participant S as MCP server (:3001)
    participant P as Sandbox proxy (:8081)
    participant V as View (hello-view)

    H->>S: resources/read ui://hello/view.html
    H->>S: tools/call hello
    H->>P: sandbox-resource-ready (the HTML)
    P->>V: write the HTML into the inner iframe
    V->>H: ui/initialize (app.connect())
    V->>H: initialized
    Note over H: The host waits for initialized
    H->>V: tool-input
    S-->>H: CallToolResult (content + structuredContent)
    H->>V: tool-result
    Note over V: ontoolresult sets data and render() runs

A few things are easy to miss.

The host can start tools/call in parallel with resources/read. It does not immediately push the result into the view. It waits until the view has connected and sent initialized. Only then does it send tool-input and tool-result. That is why the ontoolresult registration has to come first: by the time initialized goes out, the handler must already exist.

The sandbox proxy only relays messages and applies the CSP. It does not know what hello means. Post 3 will walk through that host code. For this post, the important idea is the hand-off: the server returns both payloads, the host keeps content for the model and forwards structuredContent to the view.

Next up

Post 1 is deliberately small: one tool, one resource, a view that only displays what it is given. The view cannot yet call the tool itself, or open a link.

In part 2, the view talks back. We will add a button that calls callServerTool, a button that calls openLink, and look at the capabilities object that lets the host decide what an untrusted view is allowed to do.

The code for this post is in post-1-hello. Clone it, run it, then change it. That is the point of the series.