mirror of
https://github.com/PrefectHQ/fastmcp.git
synced 2026-08-22 13:34:17 +02:00
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Jeremiah Lowin <jlowin@users.noreply.github.com> Co-authored-by: Marvin Context Protocol <41898282+Marvin Context Protocol@users.noreply.github.com> Co-authored-by: voidborne-d <voidborne-d@users.noreply.github.com> Co-authored-by: marvin-context-protocol[bot] <225465937+marvin-context-protocol[bot]@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: d 🔹 <258577966+voidborne-d@users.noreply.github.com> Co-authored-by: Jeremiah Lowin <153965+jlowin@users.noreply.github.com> Co-authored-by: nightcityblade <nightcityblade@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Bill Easton <strawgate@users.noreply.github.com> Co-authored-by: Sumanshu Nankana <sumanshunankana@gmail.com> Co-authored-by: Eric Robinson <ericrobinson@indeed.com> Co-authored-by: Martim Santos <martimfasantos@gmail.com> Co-authored-by: d 🔹 <liusway405@gmail.com> Co-authored-by: Matthieu B <66959271+mtthidoteu@users.noreply.github.com> Co-authored-by: Sascha Buehrle <47737812+saschabuehrle@users.noreply.github.com> Co-authored-by: Hakancan <142545736+hkc5@users.noreply.github.com> Co-authored-by: nightcityblade <jackchen@haloailabs.com> Co-authored-by: Matt Hallowell <17804673+mhallo@users.noreply.github.com> Co-authored-by: nate nowack <thrast36@gmail.com> Co-authored-by: Bill Easton <williamseaston@gmail.com> Co-authored-by: Marcus Shu <46469249+shulkx@users.noreply.github.com> Co-authored-by: Rushabh Doshi <radoshi@gmail.com> Co-authored-by: AIKAWA Shigechika <shige@aikawa.jp> Co-authored-by: Jeremy Simon <simonjer805@gmail.com> Co-authored-by: Miguel Miranda Dias <7780875+pandego@users.noreply.github.com> Co-authored-by: Anthony James Padavano <padavano.anthony@gmail.com> Co-authored-by: Mostafa Kamal <hiremostafa@gmail.com> Fix auto-close MRE script posting comment without closing (#3386) Fix WorkOS token scope verification bypass 🤖 Generated with Codex (#3407) Fix initialize McpError fallthrough 🤖 Generated with Codex (#3413) Fix transform arg collisions with passthrough params (#3431) Fix get_* returning None when latest version is disabled (#3439) Fix get_* returning None when latest version is disabled (#3421) Fix server lifespan overlap teardown (#3415) Fix $ref output schema object detection regression (#3420) resolved annotations (#3429) Fix async partial callables rejected by iscoroutinefunction (#3438) Fix async partial callables rejected by iscoroutinefunction (#3423) fix: add version to components (#3458) fix: use intent-based flag for OIDC scope patch in load_access_token (#3465) Fixes #3461 fix: normalize Google scope shorthands and surface valid_scopes (#3477) fix: resolve ty 0.0.23 type-checking errors and bump pin (#3481) fix: shield lifespan teardown from cancellation (#3480) fix: forward custom_route endpoints from mounted servers (#3462) fix updates _get_additional_http_routes() to traverse providers, Fixes #3457 fix: remove hardcoded version from CLI help text (#3456) fix: monty 0.0.8 compatibility, drop external_functions from constructor (#3468) fix: task test teardown hanging 5s per test (#3499) Closes #3498 fix: validate workspace path is a directory before cursor install (#3440) Fixes #3426 fix: handle re.error from malformed URI templates in build_regex (#3501) fix: reject empty/OIDC-only required_scopes in AzureProvider (#3503) fix: restrict $ref resolution to local refs only (SSRF/LFI) (#3502) fix warnings and timeouts (#3504) close upgrade check issue when build passes (#3505) Closes #3484 fix: URL-encode path params to prevent SSRF/path traversal (GHSA-vv7q-7jx5-f767) (#3507) fix: prevent path traversal in skill download (#3493) fix: prefer IdP-granted scopes over client-requested scopes in OAuthProxy (#3492) fix: remove unrelated transform and http.py changes from PR scope fix: remove forced follow_redirects from httpx_client_factory calls (#3496) fix: stop passing follow_redirects to httpx_client_factory fix: restore follow_redirects=True for custom httpx client factories Closes #3509 fix: CSRF double-submit cookie check in consent flow (#3519) fix: validate server names in install commands (#3522) fix: use raw strings for regex in pytest.raises match (#3523) fix: reject refresh tokens used as Bearer access tokens (#3524) fix: route ResourcesAsTools/PromptsAsTools through server middleware (#3495) fix: resolve Pyright "Module is not callable" on @tool, @resource, @prompt decorators (#3540) fix: filter warnings by message in KEY_PREFIX test (#3549) fix: suppress output schema for ToolResult subclass annotations (#3548) fix: increase sleep duration in proxy cache tests (#3567) fix: store absolute token expiry to prevent stale expires_in on reload (#3572) fix: preserve tool properties named 'title' during schema compression (#3582) Fix loopback redirect URI port matching per RFC 8252 §7.3 (#3589) Fix app tool routing: visibility check and middleware propagation (#3591) Fix query parameter serialization to respect OpenAPI explode/style settings (#3595) Fix dev apps form: union types, textarea support, JSON parsing (#3597) fix(google): replace deprecated /oauth2/v1/tokeninfo with /oauth2/v3/userinfo (#3603) fix: resolve EntraOBOToken dependency injection through MultiAuth (#3609) fix(docs): correct misleading stateless_http header (#3622) fix: filesystem provider import machinery (#3626) Closes #3625 (issues 2, 3, 6) fix: recover StdioTransport after subprocess exits (#3630) fix(server): preserve mounted tool task metadata (#3632) fix: scope deprecation warning filter to FastMCPDeprecationWarning (#3649) fix imports, add PrefabAppConfig (#3650) fix: resolve CurrentFastMCP/ctx.fastmcp to child server in mounted background tasks (#3651) Fix blocking docs issues: chart imports, Select API, Rx consistency (#3652) closed by default (#3657) Fix prompt caching middleware missing wrap/unwrap round-trip (#3666) fix: serialize object query params per OpenAPI style/explode rules (#3662) Fixes #2857 fix: HTTP request headers not accessible in background task workers (#3631) fix: restore HTTP headers in worker execution path for background tasks (#3681) fix: strip discriminator after dereferencing schemas (#3682) fix: remove stale ty:ignore directives for ty 0.0.26 (#3684) Fix docs gaps in app provider pages (#3690) fix: dev apps log panel UX improvements (#3698) fix dev server empty string args (#3700)
305 lines
10 KiB
Text
305 lines
10 KiB
Text
---
|
|
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'
|
|
|
|
<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
|
|
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`:
|
|
|
|
```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`. You don't need to set this 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: 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 """\
|
|
<!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()
|
|
```
|