--- title: Custom HTML Apps sidebarTitle: Custom HTML description: Build apps with your own HTML, CSS, and JavaScript using the MCP Apps extension directly. icon: code tag: NEW --- import { VersionBadge } from '/snippets/version-badge.mdx' 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 import json from fastmcp import FastMCP from fastmcp.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 "..." ``` ## AppConfig `AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.server.apps`: ```python from fastmcp.apps import AppConfig ``` On **tools**, you'll typically set `resource_uri` to point to the UI resource: ```python @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 @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 @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. | 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. ## 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 @mcp.resource("ui://my-app/view.html") def my_view() -> str: return "..." ``` 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 ``` 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 See the full [ext-apps SDK documentation](https://github.com/modelcontextprotocol/ext-apps) for the complete API reference. 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. ## 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 from fastmcp.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 "..." ``` | 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 from fastmcp.apps import AppConfig, ResourcePermissions @mcp.resource( "ui://my-app/view.html", app=AppConfig( permissions=ResourcePermissions( camera={}, clipboard_write={}, ) ), ) def my_view() -> str: return "..." ``` 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 import base64 import io import qrcode from mcp import types from fastmcp import FastMCP from fastmcp.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 """\
""" ``` 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 from fastmcp import Context from fastmcp.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() ```