Note
Access to this page requires authorization. You can try signing in or changing directories.
Access to this page requires authorization. You can try changing directories.
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:
You declare a UI resource on the tool: In your
tools/listdefinition, a widget-enabled tool carries_meta.ui.resourceUripointing at aui://resource. The tool handler itself returns ordinary data (text orstructuredContent)—not HTML.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.Cowork fetches the HTML and renders it: Cowork requests the HTML template from your server via
resources/readfor the declaredui://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
textfield: A base64blobbody isn't decoded—serve your HTML astext. - The mime type isn't enforced: Cowork renders any non-empty
text. Still settext/html;profile=mcp-appto 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/permissionson the UI resource, not the tool. Cowork reads them from the resource's_meta.ui(theresources/readresponse); the same fields placed on the tool's_meta.uihave no effect. _meta.ui.domainhas 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 bothmodelandapp—the agent can call it and widgets can call it. - If you include
visibilitybut 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
visibilitybut 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. |
frameDomainsentries arehttps://origins (a leading*.wildcard is accepted)—usehttps://only;http://isn't supported. Malformed entries are dropped.- Outbound network access isn't author-controllable via CSP. Because
connectDomainsandresourceDomainsare 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/callto 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]
validDomainsdoesn't gate widget CSP. Your package manifest'svalidDomains[]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:
cameramicrophonegeolocationclipboardWrite
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 onclipboardWrite; the hyphenatedclipboard-writeand any other unrecognized value are dropped. (camera,microphone,geolocationare 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(onlyframeDomainsis applied. More information: Content Security Policy). Route outbound data needs through a widgettools/callto 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 viatools/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 widgettools/callorui/message, or have the user-visible result drive the conversation.)pip(picture-in-picture) display mode: Cowork presents widgetsinlineandfullscreenand does honorui/request-display-modeswitches between those two (more information: Display mode), butpipisn't offered and apiprequest 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'sui/notifications/tool-input-partialstreaming path isn't delivered). resources/list: Cowork proxiesresources/readfor MCP apps (a widget might read any URI on its own server), but doesn't exposeresources/listto 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.resourceUriand serve the HTML viaresources/read. - Large inlined results:
CallToolResultpayloads 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
resourceUrior 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.resourceUriwith aui://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-callabletools/call.csp.frameDomainsandpermissions(if any) set on the UI resource's_meta.ui(theresources/readresponse), not the tool. Permission key isclipboardWrite(camelCase).- Outbound network needs routed through a widget
tools/call—connectDomains/resourceDomainsaren'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) andui/message(posts to the conversation, not your server). - Tool returns useful data even when no widget renders (graceful degradation).
Related content
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.