60e0ffc959
Upgrade checks / Notify on failure (push) Has been cancelled
Upgrade checks / Close issue on success (push) Has been cancelled
Schema Crash Test / Real-world schema crash test (232K schemas) (push) Has been cancelled
Run static analysis / static_analysis (push) Has been cancelled
Tests / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Tests / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Tests / Tests with lowest-direct dependencies (push) Has been cancelled
Tests / MCP conformance tests (push) Has been cancelled
Tests / Integration tests (push) Has been cancelled
Tests / Package install smoke (push) Has been cancelled
Upgrade checks / Static analysis (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.13 on ubuntu-latest (push) Has been cancelled
Upgrade checks / Tests: Python 3.10 on windows-latest (push) Has been cancelled
Upgrade checks / Integration tests (push) Has been cancelled
Update MCPServerConfig Schema / update-config-schema (push) Has been cancelled
Update SDK Documentation / update-sdk-docs (push) Has been cancelled
305 lines
9.8 KiB
Plaintext
305 lines
9.8 KiB
Plaintext
---
|
|
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
|
|
---
|
|
|
|
import { VersionBadge } from '/snippets/version-badge.mdx'
|
|
|
|
<VersionBadge version="3.0.0" />
|
|
|
|
Everything on this page is for when you want full control: your own HTML, your own JavaScript framework, a map library, a 3D viewer, custom video playback. [Interactive Tools](/apps/prefab) wrap the MCP Apps extension so you never have to think about it — this page is what you reach for when you need to think about it.
|
|
|
|
You'll be working with two things: the [`@modelcontextprotocol/ext-apps`](https://github.com/modelcontextprotocol/ext-apps) JavaScript SDK for host communication, and FastMCP's `AppConfig` for resources and CSP.
|
|
|
|
## 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 "<html>...</html>"
|
|
```
|
|
|
|
## AppConfig
|
|
|
|
`AppConfig` controls how a tool or resource participates in the Apps extension. Import it from `fastmcp.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. |
|
|
|
|
<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`. No need to set it manually.
|
|
|
|
```python
|
|
@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
|
|
<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
|
|
|
|
See the full [ext-apps SDK documentation](https://github.com/modelcontextprotocol/ext-apps) for the complete API reference.
|
|
|
|
<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
|
|
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`:
|
|
|
|
```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 "<html>...</html>"
|
|
```
|
|
|
|
Hosts may or may not grant these permissions. Your app should use JavaScript feature detection as a fallback.
|
|
|
|
## Example: a 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 fastmcp import FastMCP
|
|
from fastmcp.apps import AppConfig, ResourceCSP
|
|
from fastmcp.tools import ToolResult
|
|
from fastmcp.types import ImageContent
|
|
|
|
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=[ImageContent(type="image", data=b64, mime_type="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
|
|
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()
|
|
```
|