MCP apps plugin author guide for Cowork (Frontier)

Cowork can render interactive UI widgets delivered by your MCP server, following the MCP Apps Extension (SEP-1865).

A widget is an HTML/JS view that Cowork renders inline in the conversation inside a sandboxed iframe, and wires up to your server so it can show data, take input, and call your tools back.

This article describes what Cowork supports, what parts of the protocol it does and doesn't implement, and the contract and limits your server must respect. It's intended for authors of MCP servers, including those published through the marketplace and built-in connectors. It doesn't assume any particular SDK—if you use the official @modelcontextprotocol/ext-apps server/client helpers, everything below still applies because those helpers emit the same wire protocol.

Note

If you're new to building widgets, the official Apps SDK and the SEP-1865 spec are the right starting points. This guide focuses specifically on Cowork's behavior.

How widget rendering works in Cowork

Cowork follows the spec's template/data separation model. A widget is delivered in three steps:

  1. You declare a UI resource on the tool: In your tools/list definition, a widget-enabled tool carries _meta.ui.resourceUri pointing at a ui:// resource. The tool handler itself returns ordinary data (text or structuredContent)—not HTML.

  2. Cowork detects the widget after the tool runs: When that tool completes a tools/call, Cowork knows (from the declaration in the previous step) that the result has an associated UI resource, and mounts a widget for that tool invocation. The tool's result data is delivered alongside so the widget can render immediately.

  3. Cowork fetches the HTML and renders it: Cowork requests the HTML template from your server via resources/read for the declared ui:// URI, then renders the returned HTML in a sandboxed iframe.

This process means two registrations per widget-enabled tool, exactly as in the spec: the tool (returns data, declares the URI), and the resource (serves the HTML).

  Agent calls your tool ──► tools/call ──► your MCP server
                                             returns DATA (+ tool declared ui://…)
  Cowork mounts widget  ◄── notify + data
  Cowork fetches HTML   ──► resources/read(ui://…) ──► your MCP server
                                             returns HTML (text/html;profile=mcp-app)
  Cowork renders iframe ◄── HTML

Declare a widget tool

The tool definition must include _meta.ui.resourceUri (SEP-1865 §Resource Discovery):

// tools/list — tool definition
{
  "name": "show_claims_dashboard",
  "description": "Open an interactive claims dashboard.",
  "inputSchema": { /* … */ },
  "_meta": {
    "ui": { "resourceUri": "ui://your-app/claims-dashboard.html" }
  }
}

Requirements Cowork enforces on the declared URI:

  • It must use the ui:// scheme. Other schemes are ignored (no widget is rendered).
  • It must be at most 1024 characters.

Serve the HTML resource

When Cowork issues resources/read for the ui:// URI, your server must return the HTML with the MCP Apps mime type:

// resources/read response
{
  "contents": [
    {
      "uri": "ui://your-app/claims-dashboard.html",
      "mimeType": "text/html;profile=mcp-app",
      "text": "<!doctype html> … </html>"
    }
  ]
}

SEP-1865 §UI Resource Format defines this mime type and allows the body as base64 blob instead of text.

There are two caveats about Cowork:

  • Cowork reads only the text field: A base64 blob body isn't decoded—serve your HTML as text.
  • The mime type isn't enforced: Cowork renders any non-empty text. Still set text/html;profile=mcp-app to stay spec-correct.

Treat your HTML as self-contained: inline your scripts and styles, and embed assets as data: URLs where practical. Cowork doesn't host, transform, or supply your assets. For anything your widget needs from the network at runtime, route it through a widget tools/call to your server, rather than have the widget make arbitrary outbound requests Learn what the iframe CSP does and doesn't allow in Content Security Policy.

Honored _meta.ui fields

_meta.ui appears in two places—on the tool and on the UI resource—and Cowork reads a specific set of fields from each. Be deliberate about where you put each field: csp and permissions take effect only when set on the UI resource, not the tool. Cowork reads the fields listed in the following sections and ignores the rest.

On the tool (tools/list definition)

Field Type Effect in Cowork
resourceUri string (ui://…) Required for a widget. Identifies the HTML resource Cowork fetches via resources/read.
visibility array of "model" / "app" Controls whether the tool is exposed to the agent and/or callable from a widget. More information: Tool visibility

On the UI resource (resources/read response - contents[]._meta.ui)

Field Type Effect in Cowork
csp.frameDomains array of strings The only CSP key Cowork applies (nested-iframe origins). More information: Content Security Policy.
permissions array of strings Browser feature permissions your widget requests. Only a fixed allowlist is honored. More information: Iframe permissions

Note

  • Set csp / permissions on the UI resource, not the tool. Cowork reads them from the resource's _meta.ui (the resources/read response); the same fields placed on the tool's _meta.ui have no effect.
  • _meta.ui.domain has no effect** (maximum 256 characters if set). Don't depend on it.

Tool visibility

_meta.ui.visibility (SEP-1865 §Resource Discovery → Visibility) declares who can invoke a tool:

  • "model": The agent (LLM) can call the tool.
  • "app": A rendered widget can call the tool (via widget callbacks, described later).

Cowork's behavior:

  • If you omit visibility, the tool defaults to both model and app—the agent can call it and widgets can call it.
  • If you include visibility but it doesn't include "model", Cowork hides the tool from the agent's tool list (per the spec's RFC 2119 MUST). App-only tools are still callable from your widget.
  • If you include visibility but it contains no recognized values (empty, or only unknown strings), Cowork treats it as fail-closed: the tool is hidden from the agent.

This behavior lets you ship "app-only" tools (for example, a refresh_data tool that the widget calls on a button selection) that the agent never sees, while keeping data tools visible to the agent.

Content security policy

SEP-1865's McpUiResourceCsp defines four keys (connectDomains, resourceDomains, frameDomains, baseUriDomains). Cowork applies only frameDomains, and only from the UI resource's _meta.ui.csp (the resources/read response):

Key Status Meaning
frameDomains ✅ applied Origins the widget can embed as nested iframes (maps to CSP frame-src).
connectDomains ⚠️ not applied Not supported. Doesn't open outbound network origins.
resourceDomains ⚠️ not applied Not supported.
baseUriDomains ⚠️ not applied Not supported.
  • frameDomains entries are https:// origins (a leading *. wildcard is accepted)—use https:// only; http:// isn't supported. Malformed entries are dropped.
  • Outbound network access isn't author-controllable via CSP. Because connectDomains and resourceDomains are dropped, you can't widen the widget's network reach by declaring origins—the widget runs under Cowork's fixed iframe CSP.
  • Route anything your widget needs from the network through a widget tools/call to your server (your server makes the outbound request and returns the data). This pattern is supported and also keeps user credentials and tokens on the server side.

[!NOTE]

validDomains doesn't gate widget CSP. Your package manifest's validDomains[] is the package-level domain allowlist; it doesn't control the widget iframe CSP, so it isn't what makes a domain reachable. Listing your widget's origins there is still good hygiene.

Iframe permissions

The UI resource's _meta.ui.permissions property (§UI Resource Format) lets a widget request browser feature access. Cowork honors only this fixed allowlist—the SEP-1865 spelling, which is camelCase for the multi-word token:

  • camera
  • microphone
  • geolocation
  • clipboardWrite

These maps to W3C Permissions-Policy features applied via the inner iframe's allow="…" attribute—they aren't iframe sandbox tokens. Like csp, the tool reads permissions from the resource's _meta.ui, not the tool's.

Note

  • Use the spec's camelCase key. The declaration key is clipboardWrite (matching > SEP-1865), even though the underlying W3C Permissions-Policy feature is named.
  • clipboard-write. Cowork keys on clipboardWrite; the hyphenated clipboard-write and any other unrecognized value are dropped. (camera, microphone, geolocation are single words and match either way.)

Widget → server communication

After rendering a widget, it can call back through Cowork to your MCP server. Cowork proxies a bounded set of JSON-RPC methods from the widget to the originating server:

Method Behavior Purpose
resources/read ✅ forwarded to your server Fetch the widget's HTML template (Cowork does this to mount the widget) and any additional resources.
tools/call ✅ forwarded to your server The widget invokes a tool on your server - for example, refresh data on a user action.
ui/message ✅ to the conversation (not your server) Cowork injects the message as a user turn into the conversation (to the agent). Use it to drive the conversation, not to call your server.

Any other method—including initialize, tools/list, sampling/*, elicitation/*, and notifications/*—isn't forwarded and is rejected. This provides a least-authority boundary: a widget can't enumerate tools, re-initialize the connection, or drive sampling and elicitation from inside the iframe.

Note

ui/message goes to the agent, not your server. Only resources/read and tools/call reach your MCP server. If a widget button should hand work back to your server, use tools/call. If it should say something to the agent on the user's behalf, use ui/message.

For tools/call from a widget, Cowork additionally enforces visibility at call time: if the target tool's declared visibility excludes "app", the call is refused. (A tool with no visibility declaration is allowed, matching the default.)

To protect your server and the user's session, Cowork rate-limits widget-initiated calls to 60 requests per minute per conversation.

Display mode

A widget can request a display-mode change at runtime through ui/request-display-mode (§Display Modes): Cowork supports switching between inline and fullscreen. pip (picture-in-picture) isn't available and such a request is rejected. Design your widget to work in both inline and fullscreen modes. Don't depend on pip.

Tool result delivery to the widget

When a widget-enabled tool finishes, Cowork sends that tool's full result to widget—the content, structuredContent, and _meta of the CallToolResult—through the spec's ui/notifications/tool-result (§Notifications (Host → View)). By using the official Apps SDK, your widget's tool-result handler (such as app.ontoolresult) receives this data.

Constraints:

  • The inlined result is limited to 64 KiB (serialized). If your result is larger, the result data is omitted. The widget still mounts and you can fetch data on demand via a widget-initiated tools/call. Design large payloads to be pulled rather than pushed.
  • If the tool returns an error result, the result data isn't inlined.

Keep structuredContent compact. Put bulk or large data behind an app-callable tools/call the widget fetches when needed.

Stateful servers and sessions

(See SEP-1865 §Lifecycle for the connection, initialization, interactive, and cleanup phases.)

If your server returns an Mcp-Session-Id during initialize, Cowork captures it and includes it with the widget. When the widget later calls back (resources/read, tools/call, ui/message), Cowork re-attaches that session ID as the Mcp-Session-Id header on the upstream request. This process means stateful MCP servers work—the widget's follow-up calls land in the same session as the original handshake. You don't need to do anything beyond following the standard MCP session-id contract.

Sandbox and security model

Widgets render in a sandboxed iframe. Design your widget accordingly:

  • Your widget loads inside a sandboxed, cross-origin iframe isolated from Cowork: it can't read the Cowork page's DOM, cookies, or storage.
  • Cowork's iframe CSP constrains network access, and you can't widen it via _meta.ui.csp (only frameDomains is applied. More information: Content Security Policy). Route outbound data needs through a widget tools/call to your server.
  • Cowork brokers all widget → server traffic through an authenticated, per-session channel. User credentials are never exposed to the iframe—- Cowork attaches the appropriate auth to the upstream request on the widget's behalf.
  • Ship a self-contained widget (inline scripts and styles, assets as data: URLs where practical) and pull any runtime data via tools/call, rather than depending on specific Cowork CSP handling.

Because your server is the trust boundary for the content it serves, return only HTML you control, and treat any data rendered in the widget as you would any other untrusted input.

Limitations

The following parts of the broader MCP / MCP Apps surface aren't implemented by Cowork. Plan around them:

  • ui/update-model-context: (§Requests (View → Host))—widgets can't silently update the agent's context. (The Apps SDK exposes this; Cowork rejects it. Use a widget tools/call or ui/message, or have the user-visible result drive the conversation.)
  • pip (picture-in-picture) display mode: Cowork presents widgets inline and fullscreen and does honor ui/request-display-mode switches between those two (more information: Display mode), but pip isn't offered and a pip request is rejected.
  • ui/open-link: (§Requests (View → Host))—not supported. A widget's open-link request is denied; don't rely on it for navigation.
  • Server-pushed / streaming widget updates: Cowork delivers data with the tool result (and on widget-initiated tools/call). It doesn't support a server pushing new widget state without a corresponding call (the spec's ui/notifications/tool-input-partial streaming path isn't delivered).
  • resources/list: Cowork proxies resources/read for MCP apps (a widget might read any URI on its own server), but doesn't expose resources/list to widgets, and doesn't bridge reads to other servers.
  • MCP sampling and prompts: sampling/* and prompt features aren't exposed to widgets.
  • Inline HTML in tool results: Cowork follows the spec's predeclared-resource model. Returning HTML directly in a tool result (the older MCP-UI style) isn't honored; declare _meta.ui.resourceUri and serve the HTML via resources/read.
  • Large inlined results: CallToolResult payloads over 64 KiB aren't pushed to the widget.

If you need one of these, fetch-on-demand patterns (resources/read / app-callable tools/call) cover most cases.

Graceful degradation

Widgets are additive. A widget-enabled tool should return meaningful text or structuredContent from its handler regardless of whether a widget renders:

  • If widget rendering is unavailable for a session, or your resourceUri or MIME type is malformed, the tool still runs, and its data still flows to the agent. The user just sees the data without the custom UI.
  • Always make the tool's return value self-sufficient for the agent to reason about.

This condition is also why "display" widgets (charts, previews) should return a text summary in the same result: the agent keeps working whether or not the widget mounts.

When to use a widget vs. elicitation

Cowork already supports MCP elicitation (structured input requests mid-conversation). For simple confirmations, short enum choices, or flat forms, elicitation is the lighter option—no UI code, and it works without the widget pipeline. Reach for a widget when you need a rich, interactive, or visual surface: searchable pickers, dashboards, charts, previews, or live status.

Quick checklist

  • Tool declares _meta.ui.resourceUri with a ui:// URI (≤ 1024 characters).
  • Tool handler returns data (text or compact structuredContent), not HTML.
  • A matching resource serves the HTML with mime type text/html;profile=mcp-app.
  • structuredContent (or whatever the widget needs at mount) stays under 64 KiB; bulk data is fetched via an app-callable tools/call.
  • csp.frameDomains and permissions (if any) set on the UI resource's _meta.ui (the resources/read response), not the tool. Permission key is clipboardWrite (camelCase).
  • Outbound network needs routed through a widget tools/callconnectDomains / resourceDomains aren't applied.
  • App-only tools marked "visibility": ["app"]; agent-facing tools include "model".
  • Widget callbacks restricted to resources/read, tools/call (forwarded to your server) and ui/message (posts to the conversation, not your server).
  • Tool returns useful data even when no widget renders (graceful degradation).
  • Spec: MCP Apps Extension (SEP-1865), 2026-01-26

    relevant sections:

    - [Resource Discovery](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery)
    - [Visibility](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#resource-discovery)
    - [UI Resource Format](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#ui-resource-format)
    - [Display Modes](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#display-modes)
    - [Notifications (Host → View)](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#notifications-host--view)
    - [Requests (View → Host)](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#requests-view--host)
    - [Lifecycle](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx#lifecycle)
    
  • Apps SDK: @modelcontextprotocol/ext-apps

Note

The section anchors in the preceding list following GitHub's heading-slug scheme for the .mdx source. If an anchor doesn't resolve in your viewer, it lands at the top of the spec. Scroll to the named section.