Gateway
Gatewaybeta11 min

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:

yaml
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.

FieldPurpose
data_toolThe tool whose result the app renders. Required for form; optional for static capture kinds.
data_argsStatic string args passed to data_tool on the initial load.
rows_pathJSON-path to the row array inside structuredContent (e.g. $.items). Auto-detected from common keys (items/rows/data/results) when omitted.
id_fieldJSON-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:

yaml
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:

yaml
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.

yaml
- 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:

yaml
visible_if:  "${status == 'active'}"
required_if: "${tier == 'Gold' && budget > 0}"

The kind catalogue

KindRendersTypical data_tool shape
tableSortable rows with per-row actionsarray of objects
listTitle + sublines per itemarray of objects
detailLabel/value pairs for one recordone object
key_valueDense label/value spec sheetone object
formInput form; submits via tools/call(uses inputSchema)
confirmationConfirm/cancel prompt for one action
selectionMulti-select list → ids to a toolarray of objects
chartZero-dependency bar chart over numeric columnsarray of objects
mapCoordinate plot (or raster tiles) with markersrows with lat/lng or GeoJSON
code_viewerRead-only monospace text/codeobject with text/content/code
media_playerAudio/video element from a URLobject with a media URL
image_galleryResponsive image gridarray of objects with image URLs
signature_padDraw a signature → PNG to a tool— (submits to data_tool)
file_uploadPick files → contents to a tool
camera_captureCapture a photo (camera) → a tool
audio_recorderRecord 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:

yaml
- 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.

yaml
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>; the id grammar forbids path/scheme/authority injection, and mcpg is reserved so a federated upstream can't impersonate an app.
  • Tighten-only CSP. A per-app csp declaration is intersected with the deployment csp_policy (and the OpenAI widgetCSP alias) — declaring an axis can only narrow it, never widen it.
  • Permissions clamp. permissions (camera, microphone, geolocation, clipboard_write) are filtered to the deployment allowed_permissions.
  • No raw HTML / no injection. The shell renders all data via textContent (never innerHTML); the binding is delivered as a JSON data island (<script type="application/json">, parsed with JSON.parse, never assigned onto window); links are restricted to http(s)/mailto.
  • Credential firewall. public_values may 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 set allow_credential_values: true. App-side secret resolution is not wired, so secrets do not reach the client.
yaml
- 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 (AppsConfigGatewayAppConfig).
  • A complete, runnable example is the mcp-apps deployment template.
  • Generative UI and remote externalUrl apps are deliberately deferred for now.