Skip to content

Interactive views (MCP Apps)

An agent that brings a manifest can ship interactive views with its tools. A view is a small HTML page, such as a map of the places search_places found, that AI clients supporting MCP Apps show next to the tool’s result. Claude (web and desktop), ChatGPT, VS Code, and Goose render views today. Other clients, such as terminal agents, show the tool’s text result instead, so every tool that shows a view still returns a useful text result. Loading a view is free.

Views go in the manifest’s top-level ui list, and each tool that shows one names it with a tool-level ui. The HTML is inline in the manifest, so the person approving the install can read exactly what will run.

{
"ui": [
{
"name": "map",
"title": "Places map",
"html": "<!doctype html><html>...</html>",
"csp": {
"connectDomains": ["https://tiles.openfreemap.org"],
"resourceDomains": ["https://unpkg.com", "https://tiles.openfreemap.org"]
},
"permissions": { "geolocation": {} },
"prefersBorder": true
}
],
"tools": [
{
"name": "search_places",
"ui": { "resource": "map" }
}
]
}

The example shows only the view fields. The rest of the format, including the app section (app.id, app.name, app.version, the format’s literal field names for your agent), is in the Wire SDK README.

FieldRequiredWhat it does
nameYesThe view’s name within your agent: lowercase letters, digits, and single hyphens, up to 64 characters. The container serves it at ui://<app.id>/<name>-<hash>. See How clients get a view.
titleNoA human-readable name, up to 120 characters, shown on the consent screen.
htmlYesThe whole page as an HTML document, with its scripts and styles inline or loaded from a declared domain. Up to 512 KB.
cspNoThe outside domains the page may use, by kind. See Declare every domain.
permissionsNoBrowser permissions the page asks the client for: camera, microphone, geolocation, clipboardWrite.
prefersBorderNoWhether the client should draw a border around the view.

A tool shows a view with ui: { "resource": "<name>" } on the tool’s entry in the manifest’s tools. The name has to be one of the manifest’s views, or the manifest is refused. Only tools take ui, not actions: an action’s output reaches the view through the tool that runs it.

A tool that shows a view still returns its normal result, and that result has to be useful on its own. Clients that don’t render views, such as terminal agents, show only the text, and the agent reads the text either way. For a map, return the places as text (names, addresses, coordinates) and let the view draw them. A hint such as "presentation": "map" in the result is a good way to tell a text-only agent what the view would have shown.

A view can reach only the domains it declares. Clients apply the list as the page’s Content Security Policy, and anything not on it is blocked, including network requests: a view with no connectDomains can’t send data anywhere.

csp keyWhat the page may do with those domains
connectDomainsSend and receive data (fetch, WebSocket)
resourceDomainsLoad scripts, styles, images, and fonts
frameDomainsEmbed pages in frames
baseUriDomainsUse as the page’s base URL

Wire checks every domain when you register the manifest:

  • https origins only: https://host or https://host:port, such as https://tiles.openfreemap.org. No path (not even a trailing /), query, fragment, or credentials, and no other scheme.
  • A real host name. At least two labels, such as example.com. IP addresses, localhost, and single-label names are refused.
  • One leading wildcard label at most, over at least two labels. https://*.openfreemap.org is allowed. https://*.com, a bare *, and a * anywhere else are refused.
  • Never Wire’s own domains. usewire.io and every name under it are refused, so a view can’t reach Wire as the person using it.
  • No duplicates, and at most 32 entries in each list.

Load heavy libraries, such as a mapping library, from a CDN listed in resourceDomains rather than bundling them into html. That keeps the view well under its size limit and the source on the consent screen readable.

LimitValue
Views per manifest8
html per view512 KB
html across all views1 MiB
Domains per csp list32
The whole manifest2 MiB, of which at most 1 MiB is outside the views’ html

The container declares the MCP Apps extension (io.modelcontextprotocol/ui) when a client connects. In tools/list, each tool that shows a view carries _meta.ui.resourceUri, the view’s ui:// URI. The URI ends in the first 8 hex digits of the HTML’s SHA-256, such as ui://someday/places-map-3f2a9c1b, so a changed view gets a new URI: a client that cached the old page can’t show a stale version after an update, and a request for an old URI is refused. Take the URI from tools/list or resources/list rather than building it. The client reads that URI with resources/read and gets the page as text/html;profile=mcp-app, with the view’s csp, permissions, and prefersBorder under _meta.ui. Views also appear in resources/list, next to the container’s files and any skill.

  • Free. Listing or reading a view isn’t a tool call or a file download, so it never costs credits.
  • Authenticated like any request. A view is read over the same connection, with the same credential, as the agent’s tool calls.
  • Sandboxed in the client. The client renders the view in a sandboxed frame. The view receives the tool result the agent already got, and can call the agent’s tools back through the client over the same connection. It can’t reach the container any other way.
  • Part of the container’s setup, not its content. A view isn’t stored as entries, so searching, exploring, or querying the container never returns it.

A view works best behind its own tool. Keep the tool that finds records free of ui, and add a render_* tool that takes the ids the agent chose, looks those records up, and carries the view:

  • The agent decides what to show. It searches, reads the results, and passes on only the records worth showing, so the view never has to guess.
  • Every client still gets a useful answer. The data tool returns plain results everywhere, and only the render call produces a view.
  • The view gets exactly the fields it draws. The render tool reads them straight from the records, so a map gets numeric coordinates instead of parsing them out of text.

render_places_map takes place ids from search_places and reads their names and coordinates with wire_query:

{
"name": "render_places_map",
"description": "Show saved places on a map. Pass the ids of the places to show, from search_places.",
"inputSchema": {
"type": "object",
"properties": { "ids": { "type": "array", "items": { "type": "string" }, "maxItems": 50 } },
"required": ["ids"]
},
"tool": {
"name": "wire_query",
"args": {
"sql": "SELECT _entry_id AS id, name, lat, lng FROM place WHERE _entry_id IN (SELECT value FROM json_each(?1, '$.ids'))",
"params": ["{\"ids\": {{input.ids}}}"]
}
},
"result": { "places": "{{tool.rows}}", "_meta": { "columns": "{{tool.columns}}" } },
"ui": { "resource": "places-map" }
}

The ids arrive as a list and are written into one JSON parameter, and json_each expands it in SQL, so the ids are only ever compared as values. The tool doesn’t need wire_query turned on for agents: it runs it on their behalf, for exactly these records. The column names are only for the view, so they go under _meta in the result, and the view receives them as _meta.view. The model reads places and nothing else. See custom tools.

A tool-level ui can also set visibility, a list of "model" and "app". The default is both: the agent sees the tool, and the view can call it. The container lists the tool with its visibility in _meta.ui.visibility, and the client that shows the view is what applies it:

  • ["app"] marks the tool as one for the view only, such as “pin this place” on a map. Clients that support MCP Apps don’t offer it to the agent, and the view can still call it.
  • ["model"] marks the tool as the agent’s only. Clients that follow the MCP Apps spec won’t let a view call it.

A client that doesn’t support MCP Apps, such as a terminal agent, ignores visibility and lists the tool like any other, so an app-only tool still needs a sensible description and a useful result.

{ "name": "pin_place", "ui": { "resource": "map", "visibility": ["app"] } }

The consent screen names every tool marked for the view only.

The connect screen shows each view the agent ships: its title and name, its size, which tools show it, every domain it declares grouped by kind, and every permission it asks for in plain words, such as “See your location”. The page’s source is one click away, as read-only text. Wire never runs or renders a view on the consent screen. See Connecting agents.

To change a view, register your manifest again with a higher app.version. The new HTML is served at a new URI, so clients never mix the two versions. A new version reaches a container when someone approves the update (see Updates). The review lists every view added, changed, or removed, and calls out each domain and permission the new version adds, such as “New: the Places map (map) view can now send and receive data from https://api.maptiler.com.” Until the update is approved, clients keep getting the view the container has installed.

Disconnecting your agent from a container keeps its views there, as it keeps its skill. Uninstalling removes them along with its tools.