> ## Documentation Index
> Fetch the complete documentation index at: https://gofastmcp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Custom HTML Apps

> Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly.

export const VersionBadge = ({version}) => {
  return <Badge stroke size="lg" icon="gift" iconType="regular" className="version-badge">
            New in version <code>{version}</code>
        </Badge>;
};

<VersionBadge version="3.0.0" />

The [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps) is an open protocol that lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. [Prefab UI](/apps/prefab) builds on this protocol so you never have to think about it, but when you need full control — custom rendering, a specific JavaScript framework, maps, 3D, video — you can use the MCP Apps extension directly.

This page covers how to write custom HTML apps and wire them up in FastMCP. You'll be working with the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resource and CSP management.

## How It Works

An MCP App has two parts:

1. A **tool** that does the work and returns data
2. A **`ui://` resource** containing the HTML that renders that data

The tool declares which resource to use via `AppConfig`. When the host calls the tool, it also fetches the linked resource, renders it in a sandboxed iframe, and pushes the tool result into the app via `postMessage`. The app can also call tools back, enabling interactive workflows.

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import json

from fastmcp import FastMCP
from fastmcp.server.apps import AppConfig, ResourceCSP

mcp = FastMCP("My App Server")

# The tool does the work
@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
def generate_chart(data: list[float]) -> str:
    return json.dumps({"values": data})

# The resource provides the UI
@mcp.resource("ui://my-app/view.html")
def chart_view() -> str:
    return "<html>...</html>"
```

## AppConfig

`AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.server.apps`:

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from fastmcp.server.apps import AppConfig
```

On **tools**, you'll typically set `resource_uri` to point to the UI resource:

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
def my_tool() -> str:
    return "result"
```

You can also pass a raw dict with camelCase keys, matching the wire format:

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@mcp.tool(app={"resourceUri": "ui://my-app/view.html"})
def my_tool() -> str:
    return "result"
```

### Tool Visibility

The `visibility` field controls where a tool appears:

* `["model"]` — visible to the LLM (the default behavior)
* `["app"]` — only callable from within the app UI, hidden from the LLM
* `["model", "app"]` — both

This is useful when you have tools that only make sense as part of the app's interactive flow, not as standalone LLM actions.

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@mcp.tool(
    app=AppConfig(
        resource_uri="ui://my-app/view.html",
        visibility=["app"],
    )
)
def refresh_data() -> str:
    """Only callable from the app UI, not by the LLM."""
    return fetch_latest()
```

### AppConfig Fields

| Field            | Type                  | Description                                                      |
| ---------------- | --------------------- | ---------------------------------------------------------------- |
| `resource_uri`   | `str`                 | URI of the UI resource. Tools only.                              |
| `visibility`     | `list[str]`           | Where the tool appears: `"model"`, `"app"`, or both. Tools only. |
| `csp`            | `ResourceCSP`         | Content Security Policy for the iframe.                          |
| `permissions`    | `ResourcePermissions` | Iframe sandbox permissions.                                      |
| `domain`         | `str`                 | Stable sandbox origin for the iframe.                            |
| `prefers_border` | `bool`                | Whether the UI prefers a visible border.                         |

<Note>
  On **resources**, `resource_uri` and `visibility` must not be set — the resource *is* the UI. Use `AppConfig` on resources only for `csp`, `permissions`, and other display settings.
</Note>

## UI Resources

Resources using the `ui://` scheme are automatically served with the MIME type `text/html;profile=mcp-app`. You don't need to set this manually.

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
@mcp.resource("ui://my-app/view.html")
def my_view() -> str:
    return "<html>...</html>"
```

The HTML can be anything — a full single-page app, a simple display, or a complex interactive tool. The host renders it in a sandboxed iframe and establishes a `postMessage` channel for communication.

### Writing the App HTML

Your HTML app communicates with the host using the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK. The simplest approach is to load it from a CDN:

```html  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
<script type="module">
  import { App } from "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";

  const app = new App({ name: "My App", version: "1.0.0" });

  // Receive tool results pushed by the host
  app.ontoolresult = ({ content }) => {
    const text = content?.find(c => c.type === 'text');
    if (text) {
      document.getElementById('output').textContent = text.text;
    }
  };

  // Connect to the host
  await app.connect();
</script>
```

The `App` object provides:

* **`app.ontoolresult`** — callback that receives tool results pushed by the host
* **`app.callServerTool({name, arguments})`** — call a tool on the server from within the app
* **`app.onhostcontextchanged`** — callback for host context changes (e.g., safe area insets)
* **`app.getHostContext()`** — get current host context

<Note>
  If your HTML loads external scripts, styles, or makes API calls, you need to declare those domains in the CSP configuration. See [Security](#security) below.
</Note>

## Security

Apps run in sandboxed iframes with a deny-by-default Content Security Policy. By default, only inline scripts and styles are allowed — no external network access.

### Content Security Policy

If your app needs to load external resources (CDN scripts, API calls, embedded iframes), declare the allowed domains with `ResourceCSP`:

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from fastmcp.server.apps import AppConfig, ResourceCSP

@mcp.resource(
    "ui://my-app/view.html",
    app=AppConfig(
        csp=ResourceCSP(
            resource_domains=["https://unpkg.com", "https://cdn.example.com"],
            connect_domains=["https://api.example.com"],
        )
    ),
)
def my_view() -> str:
    return "<html>...</html>"
```

| CSP Field          | Controls                                            |
| ------------------ | --------------------------------------------------- |
| `connect_domains`  | `fetch`, XHR, WebSocket (`connect-src`)             |
| `resource_domains` | Scripts, images, styles, fonts (`script-src`, etc.) |
| `frame_domains`    | Nested iframes (`frame-src`)                        |
| `base_uri_domains` | Document base URI (`base-uri`)                      |

### Permissions

If your app needs browser capabilities like camera or clipboard access, request them via `ResourcePermissions`:

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from fastmcp.server.apps import AppConfig, ResourcePermissions

@mcp.resource(
    "ui://my-app/view.html",
    app=AppConfig(
        permissions=ResourcePermissions(
            camera={},
            clipboard_write={},
        )
    ),
)
def my_view() -> str:
    return "<html>...</html>"
```

Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback.

## Example: QR Code Server

This example creates a tool that generates QR codes and an app that renders them as images. It's based on the [official MCP Apps example](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/qr-server). Requires the `qrcode[pil]` package.

```python expandable theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
import base64
import io

import qrcode
from mcp import types

from fastmcp import FastMCP
from fastmcp.server.apps import AppConfig, ResourceCSP
from fastmcp.tools import ToolResult

mcp = FastMCP("QR Code Server")

VIEW_URI = "ui://qr-server/view.html"


@mcp.tool(app=AppConfig(resource_uri=VIEW_URI))
def generate_qr(text: str = "https://gofastmcp.com") -> ToolResult:
    """Generate a QR code from text."""
    qr = qrcode.QRCode(version=1, box_size=10, border=4)
    qr.add_data(text)
    qr.make(fit=True)

    img = qr.make_image()
    buffer = io.BytesIO()
    img.save(buffer, format="PNG")
    b64 = base64.b64encode(buffer.getvalue()).decode()

    return ToolResult(
        content=[types.ImageContent(type="image", data=b64, mimeType="image/png")]
    )


@mcp.resource(
    VIEW_URI,
    app=AppConfig(csp=ResourceCSP(resource_domains=["https://unpkg.com"])),
)
def view() -> str:
    """Interactive QR code viewer."""
    return """\
<!DOCTYPE html>
<html>
<head>
  <meta name="color-scheme" content="light dark">
  <style>
    body { display: flex; justify-content: center;
           align-items: center; height: 340px; width: 340px;
           margin: 0; background: transparent; }
    img  { width: 300px; height: 300px; border-radius: 8px;
           box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
  </style>
</head>
<body>
  <div id="qr"></div>
  <script type="module">
    import { App } from
      "https://unpkg.com/@modelcontextprotocol/ext-apps@0.4.0/app-with-deps";

    const app = new App({ name: "QR View", version: "1.0.0" });

    app.ontoolresult = ({ content }) => {
      const img = content?.find(c => c.type === 'image');
      if (img) {
        const el = document.createElement('img');
        el.src = `data:${img.mimeType};base64,${img.data}`;
        el.alt = "QR Code";
        document.getElementById('qr').replaceChildren(el);
      }
    };

    await app.connect();
  </script>
</body>
</html>"""
```

The tool generates a QR code as a base64 PNG. The resource loads the MCP Apps JS SDK from unpkg (declared in the CSP), listens for tool results, and renders the image. The host wires them together — when the LLM calls `generate_qr`, the QR code appears in an interactive frame inside the conversation.

## Checking Client Support

Not all hosts support the Apps extension. You can check at runtime using the tool's [context](/servers/context):

```python  theme={"theme":{"light":"snazzy-light","dark":"dark-plus"}}
from fastmcp import Context
from fastmcp.server.apps import AppConfig, UI_EXTENSION_ID

@mcp.tool(app=AppConfig(resource_uri="ui://my-app/view.html"))
async def my_tool(ctx: Context) -> str:
    if ctx.client_supports_extension(UI_EXTENSION_ID):
        # Return data optimized for UI rendering
        return rich_response()
    else:
        # Fall back to plain text
        return plain_text_response()
```
