One read of Claude Documentationclaude-docs-20261110T170709Z
15 pages moved out of 287 read.
What this read moved
1-15 of 15connectors/building/mcp-apps/app-tools New page · 256 lines, new page
# Let Claude call tools in your MCP App ## Register an app tool in a view ### Register with `app.registerTool` and a schema library ### Register with handlers and your own JSON Schema ## Test an app tool in Claude ### Check whether your organization has access to app tools ## Update app tools while a view is open ## Design app tools ### Approval before an app tool runs ### Keep each app tool definition constant ### Write a complete first line for each tool description ### Return text from app tools ### Report a failure from an app tool ## Limits on app tools ## Views that Claude can reach ## Troubleshoot app tools ### Claude finds no tools in the view ### `app.registerTool is not a function` ### `Client does not support tool capability` ### `Schema (vendor: zod) does not implement Standard JSON Schema (~standard.jsonSchema)` ## Next steps
A whole new page. There's nothing to diff it against, so here is what it says.
# Let Claude call tools in your MCP App
> Register tools in your MCP App so that Claude can change what's on screen. Test a tool and fix SDK errors.
With an app tool, Claude can change what your [MCP App](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md) shows while a user has it open. For example, in a shopping list app, a user asks Claude to add milk, and milk appears in the list on screen.
This page is for developers whose MCP App already renders in Claude. App tools are available to a limited set of organizations, and not in every conversation, so [check whether your organization has access](#check-whether-your-organization-has-access-to-app-tools).
> [!NOTE]
>
> If you don't have an MCP App yet, start with [Add an interactive UI to your MCP server](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md).
## Register an app tool in a view
A view is one rendered instance of your app's UI in Claude.
To give Claude an action in your app, register a tool in the view's script, either with a schema library or with your own JSON Schema:
* **[With a schema library](#register-with-app-registertool-and-a-schema-library)**: call `app.registerTool`
* **[With your own JSON Schema](#register-with-handlers-and-your-own-json-schema)**: set the `onlisttools` and `oncalltool` handlers
Call `app.registerTool` before the script calls `app.connect()`, so that the view declares the `tools` capability when it connects.
The [MCP Apps SDK](https://modelcontextprotocol.github.io/ext-apps/api/index.html) has two functions with similar names. Use the one that runs in the view:
* **[`registerAppTool`](https://modelcontextprotocol.github.io/ext-apps/api/functions/server-helpers.registerAppTool.html)**: call it on your server to register the tool that opens the view
* **[`app.registerTool`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#registertool)**: call it in the view to register an app tool
The two samples do the same thing. Each is the script for a view that shows a shopping list, and each registers one tool, `add_item`, which adds an item to that list.
To run either sample, add `<ul id="items"></ul>` to the view's HTML.
### Register with `app.registerTool` and a schema library
Use this option to describe each tool's input with a schema library, so that you don't write JSON Schema yourself. Call `app.registerTool` once for each tool. This option has these requirements:
* **Schema library**: for the input schema, pass a schema object from a library that implements [Standard Schema](https://standardschema.dev/) with JSON Schema output, such as zod 4.2 or later
* **SDK version**: `app.registerTool` requires `@modelcontextprotocol/ext-apps` 1.7.0 or later
* **Bundler**: the sample imports both packages by name, which a browser can't resolve, so bundle the view's script. If your view loads the SDK from a CDN, as in the [quickstart](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md#add-the-ui-to-the-tool), [register with handlers](#register-with-handlers-and-your-own-json-schema).
This sample registers `add_item` and writes its input schema with zod:
```ts theme={null}
import { App } from "@modelcontextprotocol/ext-apps";
import { z } from "zod";
const app = new App({ name: "Shopping list", version: "1.0.0" });
app.registerTool(
"add_item",
{
title: "Add item",
description: "Add an item to the shopping list that is open in this view.",
inputSchema: z.object({ name: z.string().describe("The item to add") })
},
async ({ name }) => {
const item = document.createElement("li");
item.textContent = name;
document.querySelector("#items")?.append(item);
return { content: [{ type: "text", text: "Added " + name + "." }] };
}
);
await app.connect();
```
### Register with handlers and your own JSON Schema
Use this option to write each tool's JSON Schema yourself. Write these pieces:
* **`onlisttools`**: return the tool definitions
* **`oncalltool`**: run each call. Check the arguments, because the handlers don't validate them.
* **`App` constructor**: pass `{ tools: { listChanged: true } }` as the second argument
This sample registers `add_item` this way:
```ts theme={null}
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App(
{ name: "Shopping list", version: "1.0.0" },
{ tools: { listChanged: true } }
);
app.onlisttools = async () => ({
tools: [
{
name: "add_item",
title: "Add item",
description: "Add an item to the shopping list that is open in this view.",
inputSchema: {
type: "object",
properties: { name: { type: "string", description: "The item to add" } },
required: ["name"]
}
}
]
});
app.oncalltool = async ({ name, arguments: args }) => {
if (name !== "add_item" || typeof args?.name !== "string") {
return {
content: [
{ type: "text", text: "Nothing was added. Call add_item with the item's name as a string." }
]
};
}
const item = document.createElement("li");
item.textContent = args.name;
document.querySelector("#items")?.append(item);
return { content: [{ type: "text", text: "Added " + args.name + "." }] };
};
await app.connect();
```
In a view that loads the SDK from a CDN, keep [the CDN import from the quickstart](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md#add-the-ui-to-the-tool) instead of the import line in this sample.
## Test an app tool in Claude
To confirm that Claude can find and call your tool, test it in Claude on the web. For `add_item` from the [shopping list samples](#register-an-app-tool-in-a-view), follow these steps:
1. [Add your server to Claude as a custom connector](https://claude.com/docs/connectors/building/testing.md#test-in-claude-as-a-custom-connector)
2. Start a new conversation with the [unified Claude experience](https://support.claude.com/en/articles/16761823-claude-cowork-and-chat-are-one-claude), which combines chat and Cowork. If the message box offers a choice of **Chat** or **Cowork**, your account doesn't have it.
3. Set the permission setting in the message box to **Manual**. In **Auto**, Claude can use a tool without asking.
4. Ask Claude to open the app, and name your connector in the message
5. In a second message, ask for a change, such as adding milk to the list.
Claude asks you to approve the **Add item** tool, and after you allow it, milk appears in the view.
### Check whether your organization has access to app tools
App tools are available to a limited set of organizations, so the account that you test with may not have them. To check your access, follow these steps:
1. [Test a tool in Claude](#test-an-app-tool-in-claude)
2. If Claude doesn't ask you to approve the tool and doesn't run it, rule out the causes under [Claude finds no tools in the view](#claude-finds-no-tools-in-the-view)
3. If none of those causes applies, your organization may not have access. Keep your app working without app tools, and test again later.
In an organization without app tools, the rest of the view continues to work.
## Update app tools while a view is open
You can enable, disable, update, remove, or add tools while a view is open. For example, disable an editing tool while no document is open.
* **With `app.registerTool`**: keep the handle that the function returns. Call `enable()`, `disable()`, `update()`, or `remove()` on that handle.
* **To add a tool after `connect()`**: call `app.registerTool` again
* **With the `onlisttools` handler**: call `app.sendToolListChanged()` when the list that the handler returns changes. `app.sendToolListChanged` requires `@modelcontextprotocol/ext-apps` 1.7.0 or later.
## Design app tools
A conversation can hold two views of your app, and each of Claude's calls reaches only one of them. Write each tool to change only its own view's state.
### Approval before an app tool runs
Claude asks the user to approve a tool before the tool runs, unless the user's saved choice, such as **Always allow**, or the conversation's permission setting already applies to the call.
* **What the prompt names**: the connector and the tool's `title`. If you set no `title`, the user sees the `name`.
* **What else the user can read**: the values that Claude is sending, your `description`, and the tool's inputs
* **If the user declines**: the tool isn't called, and Claude receives an error result that says so
* **Always allow**: the prompt can offer this option, and Claude can then run that tool on later calls without asking
When your view calls your own server's tools with `app.callServerTool`, the user doesn't see the prompt.
### Keep each app tool definition constant
A user saves a choice on the approval prompt, such as **Always allow**, for the tool as you defined it. Claude can ask for approval again after changes such as these:
* A change to the tool's name, title, description, or input schema
* A change to the server tool that opens the view
Put a value that changes, such as the name of the open file, in the tool's results.
### Write a complete first line for each tool description
Write the first line so that Claude can understand it without the rest of the description. When Claude lists a view's tools, it receives each tool's name and the first line of its description, up to 200 characters. Claude receives the rest of the description when it looks up that tool.
### Return text from app tools
Put what Claude needs in a `text` block, because Claude reads the `text` blocks in a result's `content`.
* **`structuredContent`**: Claude reads it, as JSON, only when it receives no text at all from `content`
* **Other block types**: if you return a block of another type, such as an image, Claude receives text for that block too and doesn't read the result's `structuredContent`
### Report a failure from an app tool
To report a failure, return `isError: true` or throw an error. Claude receives an error result, which might not contain your message.
When Claude can recover, for example by changing an input, return a normal result that says what to change.
## Limits on app tools
| What is limited | Limit | If you exceed the limit |
| - | - | - |
| Tools in one view | 64 | Claude can't use any of the view's tools |
| All of a view's tool definitions together | 1 MiB as JSON | Claude can't use any of the view's tools |
| Tool name | Letters, digits, `.`, `_`, and `-`. Up to 128 characters. Unique in the view. | Claude doesn't receive the tool |
| Input schema type | Every tool has `type: "object"` at the root of its `inputSchema` | Claude can't use any of the view's tools |
| Input schema size | Up to 64 KiB as JSON, nested up to 32 levels | Claude receives an empty object schema for the tool, and the user doesn't see **Always allow** in the prompt |
| Time to respond | 45 seconds, not counting the user's approval | Claude receives an error result that says the view didn't respond and the action might have happened |
| Result text | 100,000 characters | Claude receives the first 100,000 characters of text and a line that says the rest was removed. If `structuredContent` is longer than the limit, Claude can receive none of it. |
For work that can take longer than the time limit, start the work and return immediately. Register a second tool that reports the outcome.
## Views that Claude can reach
If Claude doesn't call your view's tools, check the view's connector, where you test, and whether the view is loaded:
* **Remote connector**: the view must come from a remote connector. Claude doesn't call the tools of a view from a local server, such as one in the Claude Desktop configuration file.
* **Where to test**: test on the web, in a conversation with the [unified Claude experience](#test-an-app-tool-in-claude).
* On Team and Enterprise plans, an Owner or Primary Owner controls it with the **Chat and Cowork unified** setting in [**Organization settings > Capabilities**](https://claude.ai/admin-settings/capabilities).
* **Loaded view**: if the view isn't loaded in the Claude page that the user has open, Claude's call isn't sent. Claude receives an error result that says so.
* When a view loads again, it starts from your HTML, so store on your server anything that you need to keep.
## Troubleshoot app tools
To see the messages between your view and Claude, [open developer tools](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md#open-developer-tools). The SDK logs these protocol messages with `console.debug`, so in Chrome you see them at the **Verbose** level.
### Claude finds no tools in the view
The view renders and throws no exception, but Claude doesn't use a tool that you registered. Check for each of these causes:
* **You asked for the change in the message that opened the app**: when the first view opens in a conversation, Claude receives its tools with your next message. Ask Claude to open the app, and name your connector in the message. Then ask for the change in a second message.
* **You passed an `inputSchema` that isn't a schema object**:
* **Cause**: `app.registerTool` can't convert a plain object, such as JSON Schema or the `{ name: z.string() }` shape that server tools take.
* **Effect**: the view responds to `tools/list` with an error, and Claude can't see the view's other tools either.
* **Fix**: pass a schema object from your schema library, or use [the `onlisttools` and `oncalltool` handlers](#register-with-handlers-and-your-own-json-schema).
* **The view exceeds a limit, or Claude can't reach it**: check the [limits on app tools](#limits-on-app-tools) and the [views that Claude can reach](#views-that-claude-can-reach)
* **Your organization or conversation doesn't have app tools**: [check whether your organization has access](#check-whether-your-organization-has-access-to-app-tools)
### `app.registerTool is not a function`
The installed `@modelcontextprotocol/ext-apps` package is older than 1.7.0. Upgrade the package, or use [the `onlisttools` and `oncalltool` handlers](#register-with-handlers-and-your-own-json-schema).
### `Client does not support tool capability`
On an `App` that declared no `tools` capability, the SDK throws this error when you:
* Call `app.registerTool` for the first time after `connect()`
* Set `onlisttools` or `oncalltool`
The full message ends with `(required for tools/call)` or `(required for tools/list)`.
To fix the error, register the tool before `connect()`, or pass `{ tools: { listChanged: true } }` as the second argument to the `App` constructor.
### `Schema (vendor: zod) does not implement Standard JSON Schema (~standard.jsonSchema)`
* **Cause**: with `@modelcontextprotocol/ext-apps` 2.x, the view responds to `tools/list` with this error when the schema comes from zod 4.1 or earlier
* **Where it appears**: in the view's console at the **Verbose** level, in a line that starts with `Sending message`
* **Fix**: upgrade zod to 4.2 or later
## Next steps
* [Send messages to Claude from your MCP App](https://claude.com/docs/connectors/building/mcp-apps/send-messages.md): send Claude a request when the user acts in the view
* [Supersede older widget instances](https://claude.com/docs/connectors/building/mcp-apps/instance-supersession.md): keep one copy of the app active when Claude opens it more than once
* [Design guidelines](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines.md#interaction-patterns): decide which interactions belong in the app and which in the conversation
* [Troubleshoot MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md): fix an app that doesn't appear or loses its assets
connectors/building/mcp-apps/design-guidelines Changed · +30 / -4 lines
### Side panel
connectors/building/mcp-apps/display-modes New page · 204 lines, new page
# Show your MCP App in the side panel or full screen ## Declare the display modes that a view supports ## Open a view in the side panel without a click ## Request full screen from a click or key press ### Read the result of `requestDisplayMode` ## Lay out a view by the size that Claude reports ## Check whether your organization has the side panel ### Views in a conversation without the side panel ## What happens to an open view when Claude calls a tool ## Troubleshoot display modes ### The returned `mode` differs from the mode that you requested ### A view with `preferredDisplayMode` stays in the conversation ### Invalid params error when a view starts or requests a mode ## Next steps
A whole new page. There's nothing to diff it against, so here is what it says.
# Show your MCP App in the side panel or full screen
> Move your MCP App from the conversation to the side panel or full screen. Declare display modes, request a mode, and lay out by the size Claude reports.
When the conversation is too small for your [MCP App](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md), Claude can show the app in a larger area. For example, a dashboard can stay open next to the conversation while the user continues to chat with Claude.
A view is one rendered instance of your app's UI in Claude. On the web and in Claude Desktop, Claude shows a view in these places:
* **The conversation**: the view appears inline
* **The side panel**: the view appears in a panel next to the conversation, and the conversation stays visible
* **Full screen**: the view fills the chat area
Claude reports the display mode `inline` for a view in the conversation. It reports `fullscreen` for a view in the side panel and for a view that is full screen.
The side panel is available to a limited set of organizations, and not in every conversation, so [check whether your organization has the side panel](#check-whether-your-organization-has-the-side-panel). Outside that section, assume that the conversation has the side panel.
> [!NOTE]
>
> This page is for developers whose MCP App already renders in Claude. These tasks are described on other pages:
>
> * **Choosing a display mode for your content**: see [Design guidelines](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines.md#display-modes)
> * **Designing for the mobile apps**: see [Mobile guidelines](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines.md#mobile-guidelines)
> * **Fixing an app that doesn't render**: see [Troubleshoot MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md)
## Declare the display modes that a view supports
In the view's script, pass the list of modes as `availableDisplayModes` in the second argument of the [`App`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html) constructor. If your view works in both modes, list both, as in `{ availableDisplayModes: ["inline", "fullscreen"] }`.
* **No list**: Claude treats the view as supporting both modes
* **An empty list, or a list without `fullscreen`**: Claude shows no expand button above the view, and doesn't automatically move the view out of the conversation
* **`fullscreen` without `inline`**: a card appears in the conversation in place of the view.
* When the user opens the view from the card, it opens in the side panel, or full screen where the panel can't open.
* When Claude calls the tool, Claude also tries to open the view in the side panel without a click.
If you list a value that the [MCP Apps SDK](https://modelcontextprotocol.github.io/ext-apps/api/index.html) doesn't define, such as `sidebar`, [the view doesn't start](#invalid-params-error-when-a-view-starts-or-requests-a-mode).
## Open a view in the side panel without a click
To ask Claude to open a view in the side panel without a click, set `preferredDisplayMode` to `fullscreen` on your server.
Put the field in `_meta.ui` of the view's `ui://` resource, on the content item in the `resources/read` result. This example shows that content item:
```json theme={null}
{
"uri": "ui://example/home",
"mimeType": "text/html;profile=mcp-app",
"text": "<!DOCTYPE html>...",
"_meta": { "ui": { "preferredDisplayMode": "fullscreen" } }
}
```
With the SDK, return this item in `contents` from the [read callback](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md#add-the-ui-to-the-tool) that you pass to `registerAppResource()`.
To test the preference, start a new conversation and ask Claude to open your app. The view opens in the side panel, and its [`displayMode`](#lay-out-a-view-by-the-size-that-claude-reports) is `fullscreen`.
> [!NOTE]
>
> `preferredDisplayMode` is an extension to `_meta.ui`. It isn't part of the MCP Apps specification or the SDK's types, so don't expect other hosts to read it. The place where you declare it can change.
* **Timing**: Claude tries once to move the view, after the view has started and the tool call has returned without an error
* **Full screen**: a preference doesn't open the view full screen
* **If the view doesn't move**: check [why a view with a preference stays in the conversation](#a-view-with-preferreddisplaymode-stays-in-the-conversation)
## Request full screen from a click or key press
To let the user move your view out of the conversation, give the view a button that requests `fullscreen`. In the view's script, call [`requestDisplayMode`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#requestdisplaymode) with `fullscreen` directly inside a click or key handler.
* **With a recent click or key press in the view**: Claude opens the view in the side panel, where the mode is also `fullscreen`
* **Without one**: Claude refuses the request
To run this sample, add `<button id="expand">` to the view's HTML. Use `@modelcontextprotocol/ext-apps` 1.4.0 or later, which the `addEventListener` call requires.
The sample is the script for a view. It declares both modes, shows a button where Claude offers `fullscreen`, and applies a compact layout when the view is narrower than 600 pixels:
```ts theme={null}
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App(
{ name: "example-app", version: "1.0.0" },
{ availableDisplayModes: ["inline", "fullscreen"] }
);
const expandButton = document.querySelector<HTMLButtonElement>("#expand")!;
function layOut() {
const context = app.getHostContext();
const size = context?.containerDimensions;
const canExpand = context?.availableDisplayModes?.includes("fullscreen") ?? false;
expandButton.hidden = !canExpand || context?.displayMode === "fullscreen";
document.body.classList.toggle("compact", !!size && "width" in size && size.width < 600);
}
app.addEventListener("hostcontextchanged", layOut);
expandButton.addEventListener("click", async () => {
const { mode } = await app.requestDisplayMode({ mode: "fullscreen" });
console.log(`Claude set the display mode to ${mode}`);
});
await app.connect();
layOut();
```
To test the sample, click the button in Claude. The view moves to the side panel, the button disappears, and [the view's console](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md#open-developer-tools) shows `Claude set the display mode to fullscreen`.
Where the side panel can't open, such as in a narrow window or a browser without `Element.moveBefore`, the view opens full screen if Claude offers that mode.
### Read the result of `requestDisplayMode`
When you call `requestDisplayMode`, the SDK sends a `ui/request-display-mode` request, and the call resolves with the mode that Claude set. The call also resolves when Claude refuses the request.
To find out whether Claude granted the request, compare the returned `mode` with the mode that you requested:
| Outcome | Returned `mode` | What happens to the view |
| - | - | - |
| Claude grants a `fullscreen` request | `fullscreen` | The view moves, and then receives host context changes with the new `displayMode` and size |
| Claude refuses the request | The view's existing mode | The view doesn't move |
| You request `fullscreen` from the side panel | `fullscreen` | The view stays in the side panel |
| You request a value that the SDK doesn't define | None | The promise rejects with an invalid params error |
To return the view to the conversation, request `inline`. If Claude refuses a request, check [what a request needs](#the-returned-mode-differs-from-the-mode-that-you-requested).
## Lay out a view by the size that Claude reports
In the view's script, choose your layout from `hostContext.containerDimensions`, which gives the size of the view's container in pixels. `containerDimensions` has these fields in each mode:
* **In `fullscreen` mode**: `width` and `height`, once Claude has measured the container
* **In `inline` mode**: `width` and `maxHeight`
> [!NOTE]
>
> You can read the current mode from the `displayMode` field of the [host context](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiHostContext.html), but the value is `fullscreen` in both the side panel and full screen.
Lay out again on each [`hostcontextchanged`](https://modelcontextprotocol.github.io/ext-apps/api/types/app.AppEventMap.html) event, as [the sample that requests full screen](#request-full-screen-from-a-click-or-key-press) does. The mode and the size both change during a view's life, for example when the user drags the panel's edge, moves the view to full screen, or narrows the window.
Design for these side panel widths:
* **Opening width**: about half the width of the chat area
* **Minimum width**: about 320 pixels
Keep controls within [`safeAreaInsets`](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines#safe-areas). In the side panel, Claude reports an inset on the edge next to the conversation.
## Check whether your organization has the side panel
To check your access to the side panel, use a view that supports `fullscreen`:
1. Reload Claude on the web, or reload the main Claude Desktop window
2. Open your app in a conversation that has the [unified Claude experience](https://support.claude.com/en/articles/16761823-claude-cowork-and-chat-are-one-claude), which combines chat and Cowork. If the message box offers a choice of **Chat** or **Cowork**, your account doesn't have it.
3. Look for the expand button, an icon that Claude shows above an inline view. A user clicks it to open the view in the side panel, or full screen where the panel can't open.
If Claude shows no expand button, your organization may not have access. Keep your app working without the side panel, and test again later.
### Views in a conversation without the side panel
Keep your own full-screen button in the view, and expect this behavior:
* **A `fullscreen` request**: where the side panel isn't available, the view opens full screen when you request `fullscreen`, if Claude offers that mode
* **`fullscreen` without `inline`**: in an organization without the side panel, a view that lists `fullscreen` but not `inline` appears in the conversation
## What happens to an open view when Claude calls a tool
Claude opens a new view for each new tool call. If the new view supports `fullscreen`, it can replace a view that is already open in the side panel or full screen:
* **The open view is full screen**: the new view can replace it, whichever connector the open view came from.
* The new view must send `ui/notifications/initialized` within 15 seconds of when Claude starts to load it. `app.connect()` sends that notification.
* The 15 seconds include reading the `ui://` resource and loading the view.
* A view that starts later appears in the conversation, behind the full-screen view.
* **The open view is in the side panel**: the new view replaces it if both views came from the same connector
* **The new view lists `fullscreen` but not `inline`**: the new view opens in place of anything that the side panel shows
The replaced view returns to the conversation, and its `displayMode` becomes `inline`. To stop that view from acting on outdated data, see [Supersede older widget instances](https://claude.com/docs/connectors/building/mcp-apps/instance-supersession.md).
## Troubleshoot display modes
To read what your view logs, such as the mode that a request returned, [open developer tools](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md#open-developer-tools).
### The returned `mode` differs from the mode that you requested
Claude refused the request, and the view didn't move. Check that the request meets requirements such as these:
* **A recent click or key press in the view**: Claude refuses a `fullscreen` request with no recent press, such as a request when the view loads or from a timer
* **Focus in the view**: the view must also have focus. On a button that requests `fullscreen`, don't cancel `mousedown`.
* **A mode that Claude offers**: the requested mode must be in `hostContext.availableDisplayModes`. Check the list before you show your own button.
* Claude lists `inline` and `fullscreen` there.
* It lists only `inline` where views can't leave the conversation, such as a chat in its own Claude Desktop window.
* **No open prompt, for a return to the conversation**: while an approval or sign-in prompt is open, Claude refuses an `inline` request from the side panel.
### A view with `preferredDisplayMode` stays in the conversation
The view stays in the conversation in cases such as these:
* **The side panel shows something else**: such as a file, an artifact, or another connector's view
* **The user closed the side panel**: they closed it earlier in the same conversation, while it showed a view from your connector. This case no longer applies after the user opens one of your connector's views with the expand button, or reloads the page.
* **The side panel can't open**: for example, the window is narrow, or the side panel isn't available in the conversation
* **The view doesn't support `fullscreen`**: your [`availableDisplayModes`](#declare-the-display-modes-that-a-view-supports) list doesn't include the mode
### Invalid params error when a view starts or requests a mode
If you list a value that the SDK doesn't define, such as `sidebar`, in `availableDisplayModes`, `ui/initialize` fails with an invalid params error. `app.connect()` then rejects, and the view doesn't start.
If you pass such a value to `requestDisplayMode`, that call also rejects with an invalid params error. The SDK defines the values `inline`, `fullscreen`, and `pip`.
## Next steps
* [Let Claude call tools in your MCP App](https://claude.com/docs/connectors/building/mcp-apps/app-tools.md): let Claude change what the user has open in the view
* [Let people open your MCP App without asking Claude](https://claude.com/docs/connectors/building/mcp-apps/entry-points.md): list your app in Claude so that people open it themselves
connectors/building/mcp-apps/download-files New page · 147 lines, new page
# Download files from your MCP App ## Send a file from a view ### Check whether Claude offers downloads ### Add an export button to a view ### Build the `contents` of a download request ### Send one download request at a time ## Handle the result of a download request ## File types that Claude refuses ## Troubleshoot a rejected download request ### `Invalid params for ui/download-file` ### `Request timed out` ### Code `-32601`, `Method not found` ## Next steps
A whole new page. There's nothing to diff it against, so here is what it says.
# Download files from your MCP App
> Save a file from your MCP App to the user's device with app.downloadFile. Handle the result, fix rejected requests, and avoid refused file types.
You can save a file, such as a CSV file or an image, from your [MCP App](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md) to the user's device. For example, in a shopping list app, a user clicks an export button to save the list as a CSV file. Claude asks the user to confirm before the download starts.
Your app's UI runs in Claude in a sandboxed frame, where no download starts from an `<a download>` link, a `blob:` URL, or a `data:` URL. Send each export through [`app.downloadFile`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#downloadfile) instead.
> [!NOTE]
>
> This page is for developers whose MCP App already renders in Claude. If you don't have an MCP App yet, start with [Add an interactive UI to your MCP server](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md).
## Send a file from a view
A view is one rendered instance of your app's UI in Claude.
To save a file, call `app.downloadFile` with the file's contents in the view's script. The user sees the **Download file** dialog, or **Download files** for several files. The dialog lists the files in your request, and the user clicks **Download** to start the download.
`app.downloadFile` requires `@modelcontextprotocol/ext-apps` 1.1.2 or later.
### Check whether Claude offers downloads
Claude doesn't offer downloads everywhere that your view can appear, so check each time a view starts. After `app.connect()` resolves, read `app.getHostCapabilities()?.downloadFile` in the view's script:
* **If `downloadFile` is set**: show your export control
* **If `downloadFile` isn't set**: offer the content another way, such as text that the user can copy
### Add an export button to a view
Send the download request when the user clicks a control in the view, while the view is on screen, so that the user expects the dialog.
To run this sample, add `<button id="export">` and an element with `id="status"` to the view's HTML. Clicks have no effect while a request is pending, and the user sees no button where Claude doesn't offer downloads:
```ts theme={null}
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Shopping list", version: "1.0.0" });
await app.connect();
const button = document.querySelector("#export");
const status = document.querySelector("#status");
if (app.getHostCapabilities()?.downloadFile) {
let waiting = false;
button?.addEventListener("click", async () => {
if (waiting) {
return;
}
waiting = true;
let message = "Download started.";
try {
const { isError } = await app.downloadFile(
{
contents: [
{
type: "resource",
resource: {
uri: "file:///shopping-list.csv",
mimeType: "text/csv",
text: "item,quantity\nmilk,2\n"
}
}
]
},
{ timeout: 600_000 }
);
if (isError) {
message = "The download didn't finish.";
}
} catch {
message = "The download couldn't be confirmed.";
}
waiting = false;
if (status) {
status.textContent = message;
}
});
} else {
button?.remove();
}
```
Open the view in Claude and click the button. The **Download file** dialog appears and lists `shopping-list.csv`.
### Build the `contents` of a download request
Put each file's data and name inside the request's `contents`, and don't include links:
* **File data**: use the `text` field for text and the `blob` field for binary data. Encode `blob` as standard base64 with no `data:` prefix.
* **Links**: omit `resource_link` items, because Claude doesn't fetch them for the view. If you send only `resource_link` items, Claude refuses the request. If you mix them with files, Claude can also refuse the request.
* **File name**: end `uri` with a name and an extension, such as `file:///report.csv`. The last path segment becomes the saved file's name.
* **Characters in the file name**: write a literal space instead of `%20`. Percent-encoding isn't decoded.
* Characters such as `%`, `:`, `"`, `*`, `<`, `>`, `|`, `~`, and `\` become `_`.
* **Several files**: you can send several files in one request
### Send one download request at a time
Wait for the result of one request before you send the next. If you send a second request from the same view while the **Download file** dialog is open, one of the two requests [ends with `isError: true`](#handle-the-result-of-a-download-request).
## Handle the result of a download request
Read `isError` in the result of `app.downloadFile`:
* **`false`**: Claude started the download
* **`true`**: tell the user that the download didn't finish
You can't tell from the result which of these cases occurred:
* Claude refused the request
* The user canceled
* Claude closed the **Download file** dialog
* Claude couldn't start the download
If no dialog appeared, check the [file types that Claude refuses](#file-types-that-claude-refuses). If the promise rejects and you receive no result, catch the error and [find its cause](#troubleshoot-a-rejected-download-request).
## File types that Claude refuses
If you send a file whose name ends in an extension on Claude's blocked list, Claude refuses the file.
* **Blocked**: the list includes programs, installers, shortcuts, and some script types. Blocked extensions include `.exe`, `.bat`, `.js`, `.jar`, `.dmg`, and `.lnk`.
* **Saved after the user confirms the download**: other script types, such as `.py` and `.sh`
If Claude refuses one file in a request, it refuses the whole request. No dialog appears, and the result has `isError: true`.
## Troubleshoot a rejected download request
For failures outside [the `isError` cases](#handle-the-result-of-a-download-request), the promise from `app.downloadFile` rejects. Read the error's message and `code`.
### `Invalid params for ui/download-file`
The error's message includes this text when you send a malformed request. Causes include a `blob` that isn't [standard base64](#build-the-contents-of-a-download-request) and an item that has no `uri`.
### `Request timed out`
The error's message includes this text when the [MCP Apps SDK](https://modelcontextprotocol.github.io/ext-apps/api/index.html) stops waiting for a result, after 60 seconds by default. Claude sets no time limit on the **Download file** dialog. To wait longer, pass `timeout` in milliseconds in the second argument to `app.downloadFile`, such as `{ timeout: 600_000 }`.
After a timeout, the dialog can remain open with a working **Download** button. Don't tell the user that nothing was saved.
### Code `-32601`, `Method not found`
Claude doesn't offer downloads where this view appears. [Check `app.getHostCapabilities()?.downloadFile`](#check-whether-claude-offers-downloads) before you send the request.
## Next steps
* [Open external links from MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/external-links.md): take the user to a URL outside Claude
* [Design guidelines](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines.md#interaction-patterns): decide which interactions belong in the app and which in the conversation
* [Troubleshoot MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md): open developer tools to inspect the view's requests
connectors/building/mcp-apps/embedded-sites New page · 271 lines, new page
# Embed a website in an MCP App ## Where embedded sites load ### Allowed origins in a directory connector ### Allowed origins in a custom connector ### Availability of embedded sites for custom connectors ## Declare the origins that a view embeds ### Accepted forms for a `frameDomains` entry ### Hosts that Claude refuses in `frameDomains` ### Wildcard entries in a directory connector ## Read the allowed origins in the view ## Allow Claude to frame your site ## Test embedded sites with a custom connector ### Check whether your organization has access to embedded sites ### Limits on origins in a custom connector ## Design your site for the iframe sandbox ### Sessions and cookies in an embedded site ### Sign a user in to an embedded site ## Device features in views that embed sites ## Debug an embedded site that fails to load ### Content Security Policy error names `frame-src` ### Content Security Policy error names `frame-ancestors` ### Console error names `X-Frame-Options` ### Console line names `frameDomains` ## Next steps
A whole new page. There's nothing to diff it against, so here is what it says.
# Embed a website in an MCP App
> Embed your own website in an MCP App. Declare its origin in frameDomains, check whether it loads for your connector, and set your site's frame-ancestors header.
An embedded site is a website that your [MCP App](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md) loads in an `<iframe>` inside its view. A view is one rendered instance of your app's UI in Claude. For example, you can show your product's own editor or dashboard in a view.
This page is for developers who have a working MCP App in Claude, served by a remote MCP server.
> [!NOTE]
>
> These cases are covered on other pages:
>
> * **Opening a site in a new tab instead**: see [Open external links from MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/external-links.md)
> * **Building your first MCP App**: see [Add an interactive UI to your MCP server](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md)
You declare your site's origin, and Claude has to allow that origin for your connector. Before you build, check these points:
* **Where sites load**: Claude applies different rules to directory connectors and custom connectors, so start with [where embedded sites load](#where-embedded-sites-load)
* **Device features**: check [which device features your connector's views lose](#device-features-in-views-that-embed-sites), such as the camera
## Where embedded sites load
Claude has to allow a site's origin for your connector before the site loads.
* **Directory connector**: a connector that has a listing in the [Connectors Directory](https://claude.com/docs/connectors/directory.md)
* **Custom connector**: a connector that a user adds to Claude by URL
| Your connector | When a site loads |
| - | - |
| Directory connector with a [Verified](https://claude.com/docs/connectors/verification.md#verified) listing | Only when the listing allows the site's origin |
| Directory connector with a [Community](https://claude.com/docs/connectors/verification.md#community) listing | Claude blocks the origin |
| Custom connector | Only after the user allows the origin in a conversation. Available to a [limited set of organizations](#availability-of-embedded-sites-for-custom-connectors), and not in every conversation. |
| Local server | Claude blocks the origin. A server on your own machine that Claude reaches through a [tunnel URL](https://claude.com/docs/connectors/building/testing.md#test-a-local-server) is a custom connector. |
### Allowed origins in a directory connector
For a directory connector, the Verified listing has to allow each origin. If you [declare a new origin](#declare-the-origins-that-a-view-embeds), it can't load until the listing allows it as well. For how a listing becomes Verified, see [Connector verification](https://claude.com/docs/connectors/verification.md#list-your-own-connector).
When Claude blocks an origin for a directory connector, the view opens and the `<iframe>` doesn't show the site. Claude shows no message, so [show a fallback in the view](#read-the-allowed-origins-in-the-view).
### Allowed origins in a custom connector
Where embedded sites are available for custom connectors, Claude lists the origins that you declare for the view and asks the user to allow them.
For embedded sites, a connector counts as custom when a user added it by URL and its address matches no directory listing. Check these details of your connector:
* **Characters in the path**: use a lower-case path without `%`, `;`, or `//`. If the path contains a capital letter or one of those characters, the connector doesn't count as custom, and Claude blocks its origins.
* **Query string**: to test a new build of a listed connector, serve the build at its own address. Claude doesn't use the query string when it matches an address to a listing.
* **Server URL that matches a listing**: Claude treats the connector as that directory connector. A site that loaded while you tested is blocked under a [Community](https://claude.com/docs/connectors/verification.md#community) listing.
* **Connector added many months ago**: the connector may not count as custom. If Claude doesn't ask you to allow the origins, remove the connector and add it again.
### Availability of embedded sites for custom connectors
For custom connectors, embedded sites are available to a limited set of organizations, and not in every conversation. Where they aren't available, expect this behavior:
* Claude blocks the origins, whatever the user allows
* Claude usually doesn't ask the user to allow the origins
* The view opens without the site
Keep a [fallback in the view](#read-the-allowed-origins-in-the-view) for these conversations.
To check your own organization, [test with a custom connector](#test-embedded-sites-with-a-custom-connector). You can't tell from your own organization's result what people in other organizations see.
## Declare the origins that a view embeds
On your server, declare each origin that you put in an `<iframe>` in the view:
1. Add one entry for each origin to [`_meta.ui.csp.frameDomains`](https://modelcontextprotocol.github.io/ext-apps/api/interfaces/app.McpUiResourceCsp.html#framedomains) on the view's `ui://` resource
2. Put `_meta` on the content item that your `resources/read` handler returns. The quickstart's [`resourceDomains` declaration](https://claude.com/docs/connectors/building/mcp-apps/quickstart#add-the-ui-to-the-tool) is in the same place.
This `_meta` object declares one origin:
```json theme={null}
{
"_meta": {
"ui": {
"csp": {
"frameDomains": ["https://editor.example.com"]
}
}
}
}
```
Put the `_meta.ui` fields `csp`, `domain`, and `permissions` together on the content item. If you set `_meta.ui` on the content item, Claude ignores `_meta.ui` on the `resources/list` entry.
### Accepted forms for a `frameDomains` entry
Write each entry as an `https` origin in one of three forms:
| Form | Example | What the entry matches |
| - | - | - |
| Exact origin | `https://editor.example.com` | That host on the default HTTPS port |
| Origin with a port | `https://editor.example.com:8443` | That host on that port |
| Subdomain wildcard | `https://*.example.com` | Hosts below `example.com`, at any depth |
* You can also add a port to a wildcard entry, such as `https://*.example.com:8443`
* To embed `example.com` itself, add a separate `https://example.com` entry. A wildcard entry doesn't match that host.
* Omit the path and the trailing slash
* Write an internationalized host name in its `xn--` form
If you write an entry in any other form, Claude treats it as undeclared and still applies the remaining entries.
### Hosts that Claude refuses in `frameDomains`
Claude refuses some hosts in any form. Examples are:
* Anthropic's own domains
* IP addresses
* Private-network names, such as `app.internal`
* Wildcards over a suffix that unrelated parties share, such as `https://*.co.uk`
### Wildcard entries in a directory connector
For a directory connector, compare your wildcard entry with the origins that the listing allows:
* **The listing has the same wildcard or a wider one**: your entry matches all the hosts below the domain
* **The listing is narrower**: your entry matches the listed origins that the wildcard includes
## Read the allowed origins in the view
To see which origins Claude allowed for a view, read [`app.getHostCapabilities()?.sandbox?.csp?.frameDomains`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#gethostcapabilities) in the view's script after `app.connect()` resolves.
Choose what the view shows from the list that you read:
* **The list is empty, or it doesn't cover your site's origin**: show a fallback, such as a button that sends [`ui/open-link`](https://claude.com/docs/connectors/building/mcp-apps/external-links)
* **The list is missing**: Claude hasn't reported what it allowed. Load the `<iframe>`, and keep the fallback on screen.
Don't treat your declaration as a limit on what a view can embed. The reported list can include origins that the listing allows and that you didn't declare for the view.
Declare every origin that you embed anyway, because Claude can restrict a view to the origins that you declare for it.
## Allow Claude to frame your site
If your site restricts which pages can frame it, allow two origins in its response header: `https://claude.ai` and your connector's sandbox host.
The sandbox host serves the view and is a subdomain of `claudemcpcontent.com`.
1. On your server, set `ui.domain` on the view's `ui://` resource. Without `ui.domain`, the sandbox host differs between conversations. To compute the value, see [Set `ui.domain` for Claude](https://claude.com/docs/connectors/building/mcp-apps/getting-started#set-ui-domain-for-claude).
2. On your site, add both origins to the `frame-ancestors` directive of the `Content-Security-Policy` header
This response header from the site allows both origins. Replace `c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com` with your connector's own sandbox host:
```text theme={null}
Content-Security-Policy: frame-ancestors https://c3d80a4ed901ee05b21755a88273b4a4.claudemcpcontent.com https://claude.ai
```
The site renders in the view once the [`frameDomains` list that Claude reports to the view](#read-the-allowed-origins-in-the-view) covers the site's origin.
Browsers test `frame-ancestors` against every frame above the site, so the site doesn't load if either entry is missing.
> [!WARNING]
>
> List only the exact sandbox host. If you list a wildcard such as `https://*.claudemcpcontent.com`, the MCP Apps of other connectors can frame your site.
## Test embedded sites with a custom connector
To try a site before your connector has a listing, add your server to Claude as a custom connector.
For custom connectors, embedded sites are available to a [limited set of organizations](#availability-of-embedded-sites-for-custom-connectors), and not in every conversation.
1. [Declare the origin](#declare-the-origins-that-a-view-embeds)
2. [Allow Claude to frame your site](#allow-claude-to-frame-your-site). [Compute the sandbox host](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md#set-ui-domain-for-claude) for the address that you test with, because the host derives from the server URL.
3. [Add your server to Claude as a custom connector](https://claude.com/docs/connectors/building/testing.md#test-in-claude-as-a-custom-connector)
4. In Claude on the web, start a conversation with the [unified Claude experience](https://support.claude.com/en/articles/16761823-claude-cowork-and-chat-are-one-claude), which combines chat and Cowork. If the message box offers a choice of **Chat** or **Cowork**, your account doesn't have it.
5. Ask Claude to open the view, and name your connector in the message
6. Allow the origins. Claude lists them in one of these places:
* In the approval request for the tool that opens the view.
* In a review that you open from a line beside the view. If the permission setting in the message box is **Auto**, Claude can open the view without asking, so use the review.
7. Confirm that the site renders in the view
After you allow the origins, each one appears in `app.getHostCapabilities()?.sandbox?.csp?.frameDomains`. A view that opened before you allowed them receives an empty list.
On Team and Enterprise plans, an Owner or Primary Owner controls the unified Claude experience with the **Chat and Cowork unified** setting in [**Organization settings > Capabilities**](https://claude.ai/admin-settings/capabilities).
### Check whether your organization has access to embedded sites
To see whether embedded sites are available for custom connectors where the view opened, read the [console line that names `frameDomains`](#console-line-names-framedomains).
After you follow the [test steps](#test-embedded-sites-with-a-custom-connector), your organization may not have access if all of these are true:
* Your connector [counts as custom](#allowed-origins-in-a-custom-connector)
* Claude didn't ask about the origins, in the approval request or beside the view
* The view opens without the site
### Limits on origins in a custom connector
* **All or none**: the view receives every origin that you declare in an accepted form, or none of them
* **Repeated requests**: expect Claude to ask again, for example in a new conversation, or when you declare an origin that the user hasn't seen listed. Keep the list of origins short and stable.
* **Claude's own check of the origins**: if the origins don't pass the check, the view doesn't open, and Claude shows a message in its place. If a single origin is refused, the whole view doesn't open. Delete that origin from `frameDomains`.
## Design your site for the iframe sandbox
An embedded site inherits the sandbox of the view around it, and the browser treats the site as a third-party frame.
* **Pop-ups and new tabs**: `window.open()` and `target="_blank"` links don't open. To open a link:
1. Post a message from the site to the view.
2. In the view's script, verify `event.origin` before you send [`ui/open-link`](https://claude.com/docs/connectors/building/mcp-apps/external-links).
* **Dialogs**: `alert()`, `prompt()`, and `print()` have no effect. `confirm()` returns `false`. Build dialogs into the page.
* **Downloads**: a link to a file doesn't download the file
* **Navigation**: the site can navigate its own frame and submit forms to its own origin. The site can't navigate the Claude page.
* **Redirects**: for a redirect in the frame, the destination and each origin that the redirect passes through must be origins that Claude allows
### Sessions and cookies in an embedded site
Claude passes no session to the site, and a call to `document.requestStorageAccess()` is rejected. To keep a session inside the frame:
* Set [`ui.domain`](https://claude.com/docs/connectors/building/mcp-apps/getting-started#set-ui-domain-for-claude) on the view's `ui://` resource, so that the site can use `localStorage` in the frame, and cookies in browsers that allow cookies in embedded sites
* Set the session cookie's attributes to `SameSite=None; Secure; Partitioned`
* Don't depend on a session from another tab
### Sign a user in to an embedded site
To sign a user in, pass a token from your server to the site through the view:
1. On your server, add a tool that returns a short-lived, single-use token
2. Set the tool's `_meta.ui.visibility` to `["app"]`, so that Claude can't call the tool
3. In the view's script, call the tool with [`app.callServerTool()`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#callservertool)
4. From the view, send the token to the frame with `postMessage`, and set the target origin to the site's exact origin
5. On the site, exchange the token for a session
If your sign-in pages can be framed, you can show a sign-in form inside the frame instead.
## Device features in views that embed sites
A view can't use the camera, the microphone, location, or clipboard write when Claude allows an origin for it. The sites that the view embeds can't use them either. The view loses these features even if you request them in `_meta.ui.permissions`.
Plan for other views to lose the same features, including views that embed nothing:
* **Directory connector**: when the listing allows an origin, plan for the connector's other views to lose them
* **Custom connector**: where [embedded sites are available for custom connectors](#availability-of-embedded-sites-for-custom-connectors), plan for every view of the connector to lose them
To see which features Claude granted a view, read `app.getHostCapabilities()?.sandbox?.permissions` in the view's script after `app.connect()` resolves. Each key is a granted feature.
Also plan for `Element.requestFullscreen()` to fail, in views and in embedded sites. Don't depend on a video player's full-screen button.
## Debug an embedded site that fails to load
When a view opens without its site, open the browser's developer console on the Claude page and match the message to its cause. In a Claude app, open the console from the [developer tools](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md#open-developer-tools).
### Content Security Policy error names `frame-src`
Claude didn't allow the origin for this view, and your site receives no request. Check each of these:
* The entry has an [accepted form](#accepted-forms-for-a-framedomains-entry)
* The `_meta` object is [on the content item](#declare-the-origins-that-a-view-embeds)
* Claude [allows origins for your kind of connector](#where-embedded-sites-load)
### Content Security Policy error names `frame-ancestors`
Your site's header doesn't include the sandbox host or `https://claude.ai`, or the view has no fixed sandbox host. Follow the steps in [Allow Claude to frame your site](#allow-claude-to-frame-your-site).
### Console error names `X-Frame-Options`
Your site sends `DENY` or `SAMEORIGIN` in `X-Frame-Options`, and it sends no `frame-ancestors` directive. Send a [`frame-ancestors` directive](#allow-claude-to-frame-your-site) that lists the sandbox host and `https://claude.ai`. When a browser receives both headers, it obeys `frame-ancestors`.
### Console line names `frameDomains`
Look for a console line that begins with `[mcp-ext-apps-host]` and names `frameDomains`. In that line, Claude reports what it did with the origins that you declared. If the console has no such line, don't conclude that Claude allowed the origin.
For a custom connector, use the wording of the line to identify your case:
* `was opened without the sites its resource declares`: embedded sites are available for the connector in that conversation. Either the origins weren't allowed, or they didn't pass Claude's own check.
* `Not among the sites that Claude allows this connector to show`: embedded sites aren't available for custom connectors where the view opened, or the connector [doesn't count as custom](#allowed-origins-in-a-custom-connector)
Ignore errors such as `Unrecognized Content-Security-Policy directive 'webrtc'.`, which come from Claude's own frames.
## Next steps
* [Show your MCP App in the side panel or full screen](https://claude.com/docs/connectors/building/mcp-apps/display-modes.md): give the embedded site more room than the conversation offers
* [Design guidelines](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines.md#content-security-policy): declare the other `_meta.ui.csp` fields, for the requests and assets that the view itself loads
connectors/building/mcp-apps/entry-points New page · 139 lines, new page
# Let people open your MCP App without asking Claude ## Mark a tool as a global entry point ### Confirm that Claude lists your app ### Adapt a tool that declares entry points for another host ## Design the view that people open from an entry point ### Set the title, name, and description of the marked tool ## Refresh the tools list after you change an entry point ## Conditions for showing an app on the Artifacts page ### Check whether your organization has access to entry points ### Entry points that appear while you test and stay hidden for other people ## Next steps
A whole new page. There's nothing to diff it against, so here is what it says.
# Let people open your MCP App without asking Claude
> Mark a tool as a global entry point so people can open your MCP App from the Artifacts page in Claude, and check why it might not appear.
A global entry point is a mark on one of your connector's tools. After you mark a tool, Claude can list your MCP App on the [**Artifacts**](https://claude.ai/artifacts) page. People open the app from that page without asking Claude for it, and they can pin it to the sidebar.
This page is for developers of a remote connector whose MCP App already renders in Claude. Check which organizations, connectors, and users can use entry points before you build:
* **Organizations**: a limited set of organizations has access, so [check whether your organization has access](#check-whether-your-organization-has-access-to-entry-points)
* **Connectors**: [Verified](https://claude.com/docs/connectors/verification.md#verified) directory connectors and [custom connectors](https://claude.com/docs/connectors/verification.md#custom) can have entry points. Connectors installed from a Community listing can't.
* **Users**: a user needs the unified Claude experience, which combines chat and Cowork
Outside these limits, people open your app by asking Claude for it in a conversation.
> [!NOTE]
>
> If your MCP App doesn't render in Claude yet, start with [Get started with MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md).
## Mark a tool as a global entry point
To mark a tool, change its `_meta` on your server:
1. Add the `openai/ui` key
2. Set the key to an object whose `entrypoints` list contains a `global` entry
3. Keep `ui.resourceUri` in `_meta` as well
This example shows a marked tool as it appears in your server's `tools/list` result:
```json theme={null}
{
"name": "open_studio",
"title": "Example Studio",
"description": "Open Example Studio.",
"inputSchema": { "type": "object" },
"_meta": {
"ui": { "resourceUri": "ui://example/home" },
"openai/ui": { "entrypoints": [{ "type": "global" }] }
}
}
```
If you use the MCP Apps SDK, pass the same `_meta` object to [`registerAppTool()`](https://modelcontextprotocol.github.io/ext-apps/api/functions/server-helpers.registerAppTool.html).
You can mark several tools, and Claude can list one app for each marked tool.
### Confirm that Claude lists your app
To test the mark, follow these steps:
1. [Refresh the tools list](#refresh-the-tools-list-after-you-change-an-entry-point) for your connector
2. Open the [**Artifacts**](https://claude.ai/artifacts) page in Claude on the web
A listed app appears as its own tile, or as an item in the list that opens from the page's connectors tile.
If your app isn't listed, check the [conditions for showing an app](#conditions-for-showing-an-app-on-the-artifacts-page).
### Adapt a tool that declares entry points for another host
If your tool already declares the `openai/ui` key for a different host, check that the tool's `_meta` also has `ui.resourceUri`, which Claude needs. The key comes from the OpenAI MCP extensions.
## Design the view that people open from an entry point
A view is one rendered instance of your app's UI in Claude. When a user opens your app from an entry point, the view starts with no request from the user and no existing conversation.
* **Arguments**: make every input optional and show a useful starting state when the arguments are `{}`.
* Claude can choose a value for an optional input, so don't treat a value as proof of the user's choice.
* **Conversation**: on the web, the app opens in a new conversation each time a user opens it
* **Display mode**: on the web, Claude shows the view full screen when the view supports `fullscreen`, so include `fullscreen` if you declare [`availableDisplayModes`](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines#declare-supported-display-modes).
* Without `fullscreen`, the view appears in the conversation.
* **Approval**: if the tool's permission is **Needs approval**, Claude asks the user for approval before the view opens. If the conversation's permission setting is **Auto**, Claude can open the view without asking.
### Set the title, name, and description of the marked tool
* **`title`**: set a `title`, because Claude displays it with the connector's name. Use 60 characters or fewer, and leave out Claude's name and Anthropic's name.
* **`name`**: don't put two underscores in a row, two dots in a row, or a dot next to an underscore in the `name`. With any of these, Claude lists the app but people can't open it from the entry point.
* **`description`**: write it for conversations too, because Claude can also call the same tool in a conversation where the connector is turned on
## Refresh the tools list after you change an entry point
After you add or edit an entry point, the change can take up to about two hours to appear, even after you reload the page.
To see the change immediately, refresh the tools list:
1. Go to [**Customize > Connectors**](https://claude.ai/customize/connectors) and select your connector
2. Open the three-dot menu and click **Refresh tools list**
You refresh the list for your own account. Other people can still wait for the change.
## Conditions for showing an app on the Artifacts page
Claude shows no error when it doesn't list your app on the [**Artifacts**](https://claude.ai/artifacts) page. If your app isn't listed, [refresh the tools list](#refresh-the-tools-list-after-you-change-an-entry-point) first, then check each condition.
In your server's `tools/list` result, check the `_meta` of the tool that you marked:
* **Entry point**: `_meta["openai/ui"].entrypoints` contains `{ "type": "global" }`
* **View**: `_meta.ui.resourceUri` points to a `ui://` resource
* **Visible to Claude**: `_meta.ui.visibility` is omitted or contains `model`, so that Claude can call the tool
Check the tool's connector:
* **Remote**: the connector is remote, not a local server
* **Verified or custom**: the connector has the Verified label in the directory, or it's a [custom connector](https://claude.com/docs/connectors/verification.md#custom).
* If a user installs the connector from a Community listing, Claude doesn't show its apps on the **Artifacts** page or in the sidebar, including pinned apps.
* For what each label means, see [Connector verification](https://claude.com/docs/connectors/verification.md#connector-labels).
For each user, including you while you test, check the account and the organization:
* **Unified Claude experience**: the user has [this experience](https://support.claude.com/en/articles/16761823-claude-cowork-and-chat-are-one-claude), which combines chat and Cowork.
* If the message box offers a choice of **Chat** or **Cowork**, the account doesn't have it.
* On Team and Enterprise plans, an Owner or Primary Owner controls it with the **Chat and Cowork unified** setting in [**Organization settings > Capabilities**](https://claude.ai/admin-settings/capabilities).
* **Connected**: the user has connected the connector
* **Turned on**: the user hasn't turned off the connector or set the tool to **Blocked**
* **Allowed by the organization**: on Team and Enterprise plans, the organization hasn't set the tool or the connector to **Blocked**
* **Organization access**: the user's organization [has access to entry points](#check-whether-your-organization-has-access-to-entry-points)
Claude can still list an app whose tool is set to **Needs approval**.
### Check whether your organization has access to entry points
To check your own organization, follow these steps:
1. [Refresh the tools list](#refresh-the-tools-list-after-you-change-an-entry-point), then reload Claude on the web
2. If your app isn't listed and meets all the other [conditions](#conditions-for-showing-an-app-on-the-artifacts-page), your organization may not have access
3. Keep your app working without an entry point, and test again later
### Entry points that appear while you test and stay hidden for other people
An app that you see on the **Artifacts** page while you test can still be hidden for other people. For example, suppose you add your server by URL and the same server also has a Community listing:
* **You**: you keep the entry point on the connector that you added by URL
* **People who install the Community listing**: they have no entry point
To move from a Community listing to Verified, see [Connector verification](https://claude.com/docs/connectors/verification.md#list-your-own-connector).
## Next steps
* [Show your MCP App in the side panel or full screen](https://claude.com/docs/connectors/building/mcp-apps/display-modes.md): lay out the view for full screen and for the side panel
* [Test your connector](https://claude.com/docs/connectors/building/testing.md): add your server as a custom connector and open the app yourself
* [Submit a connector](https://claude.com/docs/connectors/building/submission.md): list your connector and its MCP App in the directory
connectors/building/mcp-apps/getting-started Changed · +52 / -15 lines
## What an MCP App can do in Claude ### Check what a capability requires
connectors/building/mcp-apps/send-messages New page · 98 lines, new page
# Send messages to Claude from your MCP App ## Send a message from a view ## Write a message that the user can agree to ## Handle the result of `app.sendMessage` ## Messages that Claude accepts ## Next steps
A whole new page. There's nothing to diff it against, so here is what it says.
# Send messages to Claude from your MCP App
> Send a message from your MCP App's view to Claude with app.sendMessage. Handle each result, and keep messages within what Claude accepts.
You can send Claude a message from your [MCP App](https://claude.com/docs/connectors/building/mcp-apps/getting-started.md) when a user acts in the app and needs Claude to do something. For example, in a shopping list app, a user clicks a button to ask Claude to plan dinners from the list.
Claude acts on a message only with the user's agreement. Unless the user has already agreed to your app's messages, they agree by allowing the message or by sending it themselves from their message box.
> [!NOTE]
>
> This page is for developers whose MCP App already renders in Claude. If you don't have an MCP App yet, start with [Add an interactive UI to your MCP server](https://claude.com/docs/connectors/building/mcp-apps/quickstart.md).
## Send a message from a view
A view is one rendered instance of your app's UI in Claude.
To send Claude a message, call [`app.sendMessage`](https://modelcontextprotocol.github.io/ext-apps/api/classes/app.App.html#sendmessage) in the view's script. Claude receives the call as a `ui/message` request. Pass these fields:
* **`role`**: set it to `"user"`
* **`content`**: put the message here as `text` blocks
Send the message when the user clicks or presses a key in the view, not when the view loads, so that the user expects what follows.
This sample is the script for a view that shows a shopping list. It sends a message when the user clicks a button. To run the sample, add `<button id="plan">` and an element with `id="status"` to the view's HTML.
```ts theme={null}
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "Shopping list", version: "1.0.0" });
await app.connect();
const button = document.querySelector("#plan");
const status = document.querySelector("#status");
button?.addEventListener("click", async () => {
let message = "Continue in the conversation.";
try {
const { isError } = await app.sendMessage({
role: "user",
content: [
{ type: "text", text: "Plan three dinners from this shopping list: milk, eggs, rice, and spinach." }
]
});
if (isError) {
message = "Claude didn't take the request.";
}
} catch {
message = "The request couldn't be confirmed.";
}
if (status) {
status.textContent = message;
}
});
```
To test the sample, click the button in Claude. Unless you've already agreed to this app's messages, Claude does one of these once it accepts the message:
* Asks you to allow the message
* Places the text in your message box, ready to send
## Write a message that the user can agree to
Write the message as a request that the user can read and agree to. Put everything that Claude needs in the message itself.
* **Caution**: when Claude fills the message box with your view's text, a caution can appear above the box. The caution opens with "Use caution before running this prompt."
* **Existing draft**: Claude doesn't replace a draft that the user already has in the message box unless they agree
## Handle the result of `app.sendMessage`
In the view's script, handle an error result, a result with no error, and a rejected promise, as the sample does:
* **`isError: true`**: treat the message as not accepted
* **A result with no error**: you can receive this result before the user agrees, and you can't learn their decision from it. Don't treat it as evidence that Claude read the message.
* **A rejected promise**: when the [MCP Apps SDK](https://modelcontextprotocol.github.io/ext-apps/api/index.html) stops waiting, after 60 seconds by default, the promise rejects. Don't tell the user that the message wasn't sent, because Claude can still have it after that time.
If you send a malformed request, the promise rejects or the result has `isError: true`.
Keep the control that sends the message usable, so that the user can try again.
## Messages that Claude accepts
If you break a length or character rule, Claude can refuse the message.
* **Content**: send `text` blocks that aren't blank. Claude refuses a blank message, and can refuse a message that contains any other kind of block.
* **Length**: send at most 8,000 characters, counted across all blocks, plus one for each block after the first
* **Invisible characters**: leave out invisible characters, such as soft hyphens and zero-width spaces, unless Claude recognizes them as part of an emoji or a writing system. Persian, Hindi, and Thai are examples of such writing systems.
* **Line breaks**: use at most three consecutive line breaks
* **Spaces and tabs**: use at most 64 consecutive spaces or tabs
* **Pace**: send a message when the user acts, not on a timer, and don't retry in code.
* If you send messages quickly one after another, Claude can refuse them.
* After the user declines a message, Claude can refuse later messages for a short time.
## Next steps
* [Let Claude call tools in your MCP App](https://claude.com/docs/connectors/building/mcp-apps/app-tools.md): let Claude change what the user has open in the view
* [Supersede older widget instances](https://claude.com/docs/connectors/building/mcp-apps/instance-supersession.md): stop an older copy of your app from sending messages
* [Design guidelines](https://claude.com/docs/connectors/building/mcp-apps/design-guidelines.md#interaction-patterns): decide which interactions belong in the app and which in the conversation
* [Troubleshoot MCP Apps](https://claude.com/docs/connectors/building/mcp-apps/troubleshooting.md): open developer tools to inspect the view's requests
connectors/building/mcp-apps/troubleshooting Changed · +12 / -0 lines
#### App stays invisible with no cause found