Templated MCP Apps
Turn any tool into an interactive UI from config alone. The gateway ships one reviewed HTML bundle per kind and mints ui://mcpg/<id> resources from a declarative binding — no HTML, no plugin, no build step.
MCP Apps (SEP-1865, the
io.modelcontextprotocol/ui extension) let a server attach an interactive HTML
UI to a tool: the host renders it in a sandboxed <iframe>, and the iframe
drives the conversation by calling tools. Normally you write and host that
HTML.
Templated MCP Apps flip that around. You describe an app in config — "a
table over this tool, with an Open action" — and the gateway mints a
ui://mcpg/<id> resource for it. MCPG ships one reviewed, hardened HTML shell
per kind (table, form, chart, map, …) that adapts to your tool's schema at
runtime. No HTML, no plugin, no build step, and one audited rendering
surface instead of N hand-rolled bundles.
This is distinct from MCPG's proxy posture (passing through and policing the
_meta.ui of an upstream's own apps). You can run both at once; see the
mcp-apps deployment example.
Enable it
Templated apps live under mcp.configurations.apps. The feature is off by
default; set enabled: true and add a registry:
mcp:
configurations:
apps:
enabled: true # master switch (advertisement + egress policy + registry)
registry:
- id: customers
kind: table
title: Customers
data_tool: crm.list
That single entry makes a new resource, ui://mcpg/customers, appear in
resources/list with mime type text/html;profile=mcp-app. A host that
supports Apps renders it; on load it calls crm.list and draws the result as a
table. No columns were declared — the gateway derived them from the tool's
output schema.
The id becomes the resource's path segment and must match [a-z0-9][a-z0-9-]*.
The authority is always the literal mcpg, so a minted app is always
ui://mcpg/<id> and can never be shadowed by a federated server.
How data flows
An app is a thin renderer over an ordinary tool. On load the shell calls the
app's data_tool and reads its structuredContent — the same machine-
readable JSON every MCP tool can return. Nothing app-specific is required of the
backend; point data_tool at a sql, http, command, federated, or mock
tool and it works.
| Field | Purpose |
|---|---|
data_tool | The tool whose result the app renders. Required for form; optional for static capture kinds. |
data_args | Static string args passed to data_tool on the initial load. |
rows_path | JSON-path to the row array inside structuredContent (e.g. $.items). Auto-detected from common keys (items/rows/data/results) when omitted. |
id_field | JSON-path to a row's stable id (default $.id); used by selection and row actions. |
Schema introspection. When you omit columns (table/list/selection) or
fields (detail/form), the gateway derives them from the bound tool's
outputSchema / inputSchema at boot. Explicit config always wins;
introspection only fills the gap. Tools imported after boot (e.g. a federated
tool) won't be introspected until the next config reload — declare explicit
columns for those.
Columns and formats
columns bind table/list/chart cells to JSON-paths over each row:
columns:
- { field: $.name, header: Name }
- { field: $.tier, header: Tier, format: badge }
- { field: $.balance, header: Balance, format: currency, align: end }
- { field: $.site, header: Site, format: link }
format is one of text (default), number, currency, date, badge, or
link. align is start | center | end. A column can carry a
visible_if expression (see Expressions).
detail and key_value apps use fields instead (field + label +
format).
Actions
Every interaction re-enters the gateway as a normal tools/call — the full
plugin pipeline (policy, transforms, audit) runs, with no app exemption.
Declare row_actions and map arguments from the row:
row_actions:
- id: open
label: Open
tool: crm.get
arg_map: { id: $.id } # argName → JSON-path over the row
- id: suspend
label: Suspend
tool: crm.suspend
arg_map: { id: $.id }
confirm: "Suspend this account?" # client confirmation before firing
primary_action: open # fired on row click / primary tap
primary_action must reference one of the row_actions ids.
Forms and uiSchema
A form turns a tool's input schema into a submit form; Submit calls
data_tool with the collected values. ui_schema is a tight, mcpg-native
overlay that shapes the inputs — it never introduces raw HTML.
- id: new-account
kind: form
title: New account
data_tool: crm.create
fields:
- { field: name, label: Name }
- { field: email, label: Email }
- { field: tier, label: Tier }
- { field: budget, label: Monthly budget, format: currency }
- { field: notes, label: Notes }
ui_schema:
order: [name, email, tier, budget, notes]
groups:
- { label: Identity, fields: [name, email] }
- { label: Plan, fields: [tier, budget] }
- { label: Other, fields: [notes] }
widgets:
tier: { widget: select, enum_from: { tool: crm.tiers, value_field: $.id, label_field: $.label } }
budget: { widget: currency, required_if: "${tier == 'Gold'}" }
notes: { widget: textarea, help: "Optional internal notes." }
The widget vocabulary is closed: text, textarea, select, multiselect,
date, datetime, number, currency, slider, toggle, radio, file,
color, hidden, markdown, json, and array (a repeatable list of scalar
inputs → a JSON array). enum_from sources select options from a sibling
tool's result at render time.
Expressions
visible_if / required_if (on widgets) and visible_if (on columns) take a
small, safe expression — evaluated client-side by a hand-written interpreter
(no eval, no Function). The grammar is ternary, ||, &&, !,
comparisons, parens, string/number/bool/null literals, and dotted identifiers
resolved against the current row or form draft:
visible_if: "${status == 'active'}"
required_if: "${tier == 'Gold' && budget > 0}"
The kind catalogue
| Kind | Renders | Typical data_tool shape |
|---|---|---|
table | Sortable rows with per-row actions | array of objects |
list | Title + sublines per item | array of objects |
detail | Label/value pairs for one record | one object |
key_value | Dense label/value spec sheet | one object |
form | Input form; submits via tools/call | (uses inputSchema) |
confirmation | Confirm/cancel prompt for one action | — |
selection | Multi-select list → ids to a tool | array of objects |
chart | Zero-dependency bar chart over numeric columns | array of objects |
map | Coordinate plot (or raster tiles) with markers | rows with lat/lng or GeoJSON |
code_viewer | Read-only monospace text/code | object with text/content/code |
media_player | Audio/video element from a URL | object with a media URL |
image_gallery | Responsive image grid | array of objects with image URLs |
signature_pad | Draw a signature → PNG to a tool | — (submits to data_tool) |
file_upload | Pick files → contents to a tool | — |
camera_capture | Capture a photo (camera) → a tool | — |
audio_recorder | Record a clip (microphone) → a tool | — |
Capture kinds (signature_pad, file_upload, camera_capture,
audio_recorder) submit to their data_tool. The two device kinds request the
matching permissions (see below).
Maps
map defaults to a zero-network coordinate plot — points are drawn on a
canvas with no external requests, so it adds nothing to the app's CSP. Opt into
a raster basemap per app:
- id: stores
kind: map
title: Store locations
data_tool: geo.stores
map:
lat_field: $.lat # or geojson_path: $.featureCollection
lng_field: $.lng
label_field: $.name
# mode: raster_tiles # opt-in basemap; needs tile_url + a CSP allowance
# tile_url: "https://tiles.example.com/{z}/{x}/{y}.png"
row_actions: [{ id: open, label: Open, tool: geo.get, arg_map: { id: $.id } }]
primary_action: open
raster_tiles mode fetches from tile_url, so the tile host must be allowed by
your CSP policy (below).
App-Provided Tools
An app can expose read-only client state back to the agent as a tool. The
host sees it auto-namespaced app.<id>.<name>; the source must be one of a
fixed allowlist of client readers, and the value is read-only — mutations still
go through tools/call.
app_tools:
- { name: current_selection, source: selection, description: "Rows the user has selected." }
- { name: draft, source: form_draft, description: "The current form draft." }
Sources: selection, visible_rows, form_draft, map_viewport. This is
experimental and best-effort across host APIs.
Security model
Templated apps inherit the same envelope as proxied ones, plus a few guarantees specific to gateway-authored apps:
- Frozen authority. Apps are always
ui://mcpg/<id>; theidgrammar forbids path/scheme/authority injection, andmcpgis reserved so a federated upstream can't impersonate an app. - Tighten-only CSP. A per-app
cspdeclaration is intersected with the deploymentcsp_policy(and the OpenAIwidgetCSPalias) — declaring an axis can only narrow it, never widen it. - Permissions clamp.
permissions(camera,microphone,geolocation,clipboard_write) are filtered to the deploymentallowed_permissions. - No raw HTML / no injection. The shell renders all data via
textContent(neverinnerHTML); the binding is delivered as a JSON data island (<script type="application/json">, parsed withJSON.parse, never assigned ontowindow); links are restricted tohttp(s)/mailto. - Credential firewall.
public_valuesmay inject plain config into the app (labels, public tokens, flags), but any value carrying a secret reference (cred://,env://,vault://, …) is dropped unless you explicitly setallow_credential_values: true. App-side secret resolution is not wired, so secrets do not reach the client.
- id: customers
kind: table
title: Customers
data_tool: crm.list
permissions: [] # request nothing
csp: { connect_domains: ["https://api.example.com"] }
public_values: { brand_label: "Acme" }
theme: { accent: "#4f8cff", density: compact }
Reference
- Every field with its type and default is in the generated
configuration reference
(
AppsConfig→GatewayAppConfig). - A complete, runnable example is the
mcp-appsdeployment template. - Generative UI and remote
externalUrlapps are deliberately deferred for now.