MCP Apps
MCP Apps (SEP-1865) is an MCP extension that lets a tool link to a companion interactive HTML UI, which a compliant host renders in a sandboxed iframe instead of (or alongside) the tool’s text result. Linking a tool only declares the connection — nothing renders it server-side, and a host that doesn’t support the extension just ignores it and the tool behaves exactly as it would without one.
All three tool registration styles support it:
| Style | How | Docs |
|---|---|---|
Folder (.toml + .py) |
[ui] table in the tool’s .toml |
Writing MCP Tools |
Decorated (@mcp.tool) |
ui= keyword argument |
below |
Dynamic (register_request_tool) |
ui= keyword argument |
below |
The ui Dict
Wherever it appears — a decorator kwarg, a register_request_tool kwarg, or (as TOML) a [ui] table — the shape is the same:
| Field | Required | Description |
|---|---|---|
resourceUri |
No* | URI of the paired ui:// resource |
visibility |
No* | Array of "model" and/or "app"; who can see the tool (default: both) |
* At least one of the two must be present, or ui shouldn’t be set at all. resourceUri names the view a compliant host renders when this tool is called — an "app"-only action tool that’s only ever called by a view that’s already open (a form submission, a “claim” button) has no rendering purpose of its own and should omit it, declaring visibility alone. Use resourceUri on an "app"-only tool only when calling it should also refresh a view — see add_sale below for the plain-action case.
resourceUri, when given, must point at a resource served with mimeType text/html;profile=mcp-app — see The Paired Resource below.
Decorated Tools
import scriptling.runtime.mcp as mcp
@mcp.tool("Get the sales report",
ui={"resourceUri": "ui://sales-dashboard/dashboard.html"})
def sales_report():
return {"records": [...]}
@mcp.tool("Add a sale record (called by the dashboard's own form, not the model)",
ui={"visibility": ["app"]})
def add_sale(date, product, amount):
...add_sale is visibility: ["app"]: a compliant host still sees it in tools/list (the natural way for a host to learn it exists, so it can let the view call it), but hides it from the model — it’s meant to be called by the dashboard’s own form, not requested by the AI. It has no resourceUri: the dashboard is already open by the time this gets called, so there’s no view of its own for it to render.
Dynamic (Per-Request) Tools
import scriptling.runtime.mcp as mcp
def auth(request):
mcp.register_request_tool("sales_report", handler="reportmod.report",
description="Get the sales report",
ui={"resourceUri": "ui://sales-dashboard/dashboard.html"})
return NoneSame ui dict, same rules — see Request-Scoped Registration for the rest of register_request_tool’s parameters.
The Paired Resource
resourceUri names a normal Scriptling resource — no code changes needed on the resource side, since resources already support arbitrary URI schemes and mimetypes via a _{stem}.toml sidecar:
resources/ui/sales-dashboard/dashboard.html # The UI itself
resources/ui/sales-dashboard/_dashboard.toml # mimeType + optional CSP# resources/ui/sales-dashboard/_dashboard.toml
mimeType = "text/html;profile=mcp-app"
name = "Sales Dashboard"
[ui.csp]
resourceDomains = ["https://cdn.jsdelivr.net"]The [ui.csp] table (and its siblings [ui.permissions], ui.domain, ui.prefersBorder) tell a compliant host what the sandboxed iframe is allowed to reach — omit it entirely to accept the host’s restrictive same-origin/inline-only default. See Resources and Prompts for how the resource scanner derives a URI from a file’s path.
Returning Structured Data
A UI typically wants its data as structuredContent, not JSON buried in a text block:
import scriptling.mcp.tool as tool
tool.return_structured({"records": [...]})return_structured requires a dict (structuredContent must be a JSON object per the MCP spec) and, for backwards compatibility with clients that don’t read structuredContent, still includes the same JSON as a text block — see Returning Results for how it compares to return_object.
Icons
Any tool — with or without a ui link — can carry visual identifiers via icons, a list of dicts:
@mcp.tool("Get the weather",
icons=[{"src": "https://example.com/weather.png", "mimeType": "image/png", "sizes": ["48x48"]}])
def weather():
...| Field | Required | Description |
|---|---|---|
src |
Yes | An https:// URL or data: URI |
mimeType |
No | Media type, e.g. image/png — useful when it can’t be inferred from src |
sizes |
No | Size hints such as "48x48", or "any" for a scalable format like SVG |
theme |
No | "light" or "dark" — omit for a theme-neutral icon |
Icons aren’t required on every tool — a plain "app"-only action tool like add_sale above typically doesn’t need one. All three registration styles accept icons the same way:
# Folder style (.toml)
[[icons]]
src = "https://example.com/weather.png"
mimeType = "image/png"# Dynamic (register_request_tool) — same shape as the decorator
mcp.register_request_tool("weather", handler="weathermod.forecast",
description="Get the weather",
icons=[{"src": "https://example.com/weather.png", "mimeType": "image/png"}])Scriptling only carries this metadata on the wire — it never fetches or renders icon bytes itself. A host that does render icons is responsible for the MCP spec’s security precautions (treat src and any fetched bytes as untrusted, require an https:// or data: URI, reject unsafe schemes and cross-origin redirects, fetch without credentials, verify content type from magic bytes, and guard against oversized images).
See Also
- Writing MCP Tools — the three registration styles in full
- MCP Apps extension specification