Meet Prefect Horizon, the enterprise MCP gateway built by the team behind FastMCP
New in version 3.0.0 The MCP Apps extension is an open protocol that lets tools return interactive UIs — an HTML page rendered in a sandboxed iframe inside the host client. Prefab UI 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 JavaScript SDK for host communication, and FastMCP’s AppConfig for resource and CSP management.
How It Works
An MCP App has two parts:
- A tool that does the work and returns data
- 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.
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 "<html>...</html>"
AppConfig
AppConfig controls how a tool or resource participates in the Apps extension. Import it from fastmcp.server.apps:
from fastmcp.apps import AppConfig
On tools, you’ll typically set resource_uri to point to the UI resource:
@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:
@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.
@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.
@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 JavaScript SDK. The simplest approach is to load it from a CDN:
<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>
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:
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 "<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:
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 "<html>...</html>"
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. Requires the qrcode[pil] package.
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 """
<!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>
"""
See all 73 lines
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:
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()