How to Test and Debug MCP Apps
Test an MCP App in three steps. Check the server with the MCP Inspector CLI, render the view in the ext-apps basic-host or the Inspector's Apps tab, then try it in a real host such as Claude Desktop and open its developer tools. Most failures come from four causes: the tool doesn't link to the view, the view is served with the wrong MIME type, the view's Content Security Policy blocks a request, or the view registers its handlers too late to receive data.
Check the server first
Start with the server, because a server mistake looks like a broken view. The Inspector CLI checks the server without a browser. Replace <your server command> with the command that starts your server, such as node build/index.js.
npx @modelcontextprotocol/inspector@2.11.0 --cli <your server command> --protocol-era modern --method tools/list
Look at three things in the output:
- The tool links to a view. Each app tool needs
_meta.ui.resourceUriset to aui://URI. - The view resource reads back. Read it with
resources/read, and check thaturimatches the tool's link and thatmimeTypeistext/html;profile=mcp-app. - The probe agrees.
--app-inforeads the tool and its resource and prints what a host would see. It doesn't call the tool.
npx @modelcontextprotocol/inspector@2.11.0 --cli <your server command> --protocol-era modern --method tools/call --tool-name <tool-name> --app-info
The probe exits with 0 when the tool has an app, 2 when it doesn't, and 5 when the tool name doesn't exist. For a tool with no UI link, the probe prints an error like this one:
{"error":{"code":"no_app","message":"Tool 'save_bookmark' has no MCP App UI resource (_meta.ui.resourceUri)."}}
To read the view itself, use resources/read with the ui:// URI from the tool's link:
npx @modelcontextprotocol/inspector@2.11.0 --cli <your server command> --protocol-era modern --method resources/read --uri ui://<your-view>
Render the view in basic-host
basic-host is the reference host in the ext-apps repository. It renders a tool's view in a sandboxed iframe, the same way a real host does. It takes server URLs rather than commands, so a server that only speaks stdio needs an HTTP endpoint first. Deploy a remote MCP server shows how to serve one over Streamable HTTP.
Clone the repository, install the dependencies at the root, then start basic-host with your server's URL:
git clone https://github.com/modelcontextprotocol/ext-apps.git
cd ext-apps
npm install
cd examples/basic-host
SERVERS='["http://localhost:3001/mcp"]' npm start
The root npm install also builds the packages and type-checks the documentation snippets. If it fails, fix that first. The ext-apps testing guide says not to bypass a failed build before testing the host.
Open http://localhost:8080, choose your server from the dropdown, choose a tool with UI support, enter the tool's input as JSON and click "Call Tool". The page has four collapsible panels that help you debug:
- Tool Input, the JSON the tool received
- Tool Result, what the tool returned
- Messages, messages the view sent to the model
- Model Context, context updates the view sent
Open your browser's developer console too. basic-host logs key events with a [HOST] prefix, including server connections, tool calls, view initialization and requests from the view.
Try the debug server
The ext-apps repository also includes debug-server, a test server that exercises the SDK's callbacks and result shapes. Run npm start from the repository root, open http://localhost:8080/basic-host/ and select "Debug MCP App Server". Its debug-tool takes these parameters:
| Parameter | What it changes |
|---|---|
contentType | Returns text, image, audio, resource, resourceLink or mixed content. Defaults to text. |
includeStructuredContent | Leaves structuredContent out of the result when set to false. Defaults to true. |
simulateError | Returns isError: true. Defaults to false. |
delayMs | Waits before responding, so you can see the loading state. |
largeInput | Sends a large text input, for testing partial tool input. |
If your view works with debug-server but not with your server, the problem is in what your server returns. Change one parameter at a time to find which result shape breaks the view.
Use the MCP Inspector's Apps tab
The MCP Inspector's web UI has an Apps tab that lists only the tools with a view. Start it with npx @modelcontextprotocol/inspector, open the page on port 6274, and select your tool in the Apps tab. Fill in its input form and click "Open App". The Inspector reads the view with resources/read and runs it in a sandbox.
The sandbox runs on port 6275. Open the Inspector at localhost or 127.0.0.1. A bracketed IPv6 address such as http://[::1]:6274 blocks the sandbox frame, so the view never shows. If you run the Inspector on another machine, forward port 6275 as well as 6274.
Test in Claude Desktop
Claude Desktop shows the view in a real chat, so use it as the final check. Add your server to claude_desktop_config.json (the Claude Desktop guide covers the file), then:
- Open Help > Troubleshooting and click Enable Developer Mode. A Developer menu appears.
- Ask Claude to use your tool. Claude asks for permission to display the app. Click Allow.
- Press Cmd+Option+I on a Mac, or Ctrl+Shift+I on Windows, to open Developer Tools. Find the tool call. Your view is an iframe inside another iframe.
- After you change the config, choose Reload MCP Configuration from the Developer menu. You don't need to restart the app.
On iOS, the Claude app shows the view in a web view. You can inspect it from a Mac with Safari's Web Inspector.
Fix common problems
Find the symptom in this table, then read its section below.
| What you see | Usual cause | Section |
|---|---|---|
| Tool result appears, but no view | The tool has no UI link, or the link points at the wrong URI | The view doesn't open |
| View is empty or has no height | Zero height, the view never connected, or a blocked script | The view is blank |
| A request from the view fails | The view's request isn't in the declared CSP | Content Security Policy blocks a request |
basic-host shows Unsupported MIME type | The resource's MIME type isn't text/html;profile=mcp-app | Unsupported MIME type |
| The view loads, but no data arrives | The handlers were registered after connect() | The view never gets its data |
| The view's tool call is refused | The tool's visibility leaves out app | The view can't call a tool |
Claude reports Invalid ui.domain format | The domain value doesn't match your server URL | Invalid ui.domain format and ui.domain mismatch |
| A large result never displays in Claude | The result is over Claude's size limit | Large results don't display in Claude |
The view doesn't open
The tool call succeeds, the text result appears, and no view shows up. The host renders a view only when the tool points at one. The spec says that when _meta.ui.resourceUri is present and the host supports MCP Apps, the host renders the tool's results with that resource. Without the link, the tool behaves as an ordinary text tool.
Add the link under _meta.ui on the tool. The visibility field is optional here, and it defaults to both model and app:
{
"name": "get_weather",
"description": "Get current weather for a location",
"inputSchema": {
"type": "object",
"properties": {
"location": { "type": "string" }
}
},
"_meta": {
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["model", "app"]
}
}
}
The spec deprecates the flat key _meta["ui/resourceUri"] and says it will be removed before GA. Use the nested _meta.ui.resourceUri form. Our host testing notes say we still send the flat key as well, for Claude Desktop compatibility. Treat the nested form as the one to write.
The view is blank
A blank view has three usual causes, and you can check each one:
- Zero height. Claude's guide says a view with zero height is invisible. Check that the view's root element has explicit dimensions or content, and that the view doesn't call
sendSizeChangedwith a height of0. - The view never connected. Claude's guide says an invisible view usually means
app.connect()was never called. See The view never gets its data. - A blocked script or request. The view's Content Security Policy blocks it. See the next section.
Content Security Policy blocks a request
The spec sets a restrictive default for every view. When a resource declares no csp settings, the host uses a policy with connect-src 'none', so the view can't make network requests. The default still allows scripts and styles from the view's own origin, plus inline ones.
To allow an outside origin, declare it on the resource in _meta.ui.csp. That's on the resources/read response, not on the tool:
connectDomainsforfetch, XHR and WebSocket calls to an APIresourceDomainsfor scripts, images, styles, fonts and media from a CDNframeDomainsfor nested iframes, such as a video playerbaseUriDomainsfor a<base>element pointing somewhere else
{
"contents": [
{
"uri": "ui://weather-server/dashboard-template",
"mimeType": "text/html;profile=mcp-app",
"text": "<!DOCTYPE html><html>...</html>",
"_meta": {
"ui": {
"csp": {
"connectDomains": ["https://api.openweathermap.org"],
"resourceDomains": ["https://cdn.jsdelivr.net"]
},
"prefersBorder": true
}
}
}
]
}
Wildcard subdomains work, as in https://*.example.com. If you've declared the origin and it's still blocked, the host has tightened the policy further. The spec allows a host to do that, but it doesn't allow a host to add origins you didn't declare. Check the host's developer tools to see which policy it applied.
Unsupported MIME type
basic-host throws Unsupported MIME type: followed by the type it received, and the view never loads. The spec requires the MIME type to be exactly text/html;profile=mcp-app. A plain text/html fails this check.
Read the resource with resources/read and look at the mimeType field of the content item. Set it to the exact value:
{
"contents": [
{
"uri": "ui://weather-server/dashboard-template",
"mimeType": "text/html;profile=mcp-app",
"text": "<!DOCTYPE html><html>...</html>"
}
]
}
Unexpected contents count
basic-host throws Unexpected contents count: followed by a number when the resources/read response for the view doesn't contain exactly one content item. Return one content item for the view's ui:// URI, with the HTML in its text field, or in blob as base64.
A view resource is missing
The spec says the resource a tool links to must exist on the server, and the host must read it with resources/read. Servers may leave UI-only resources out of resources/list, so an empty list doesn't prove the resource is missing. Read the URI from the tool's link directly. If the read fails, check that the URI in _meta.ui.resourceUri matches the URI you register exactly, including the ui:// scheme.
Could not connect with any transport
basic-host prints this when it can't connect with Streamable HTTP or with SSE. It tries Streamable HTTP first and falls back to SSE, which the basic-host source describes as deprecated but still needed for older servers. Start your server first, and check the URL in SERVERS, including the endpoint path such as /mcp. Test the endpoint with the Inspector before you test the view:
npx @modelcontextprotocol/inspector@2.11.0 --cli --transport http --server-url http://localhost:3001/mcp --protocol-era modern --method tools/list
The view never gets its data
The view renders, but it shows no data, or your handlers never run. The spec requires the view to send ui/initialize and then ui/notifications/initialized before the host sends anything else, and the host sends the tool input and then the tool result after that. The SDK does the handshake in connect(), so register your handlers before you call it. Claude's guide says handlers don't run until the app is connected:
import { App } from "@modelcontextprotocol/ext-apps";
const app = new App({ name: "My App", version: "1.0.0" });
// Add listeners before connecting
app.addEventListener("toolresult", (params) => {
// Handle tool results
});
await app.connect();
If you write your own message listener, skip any message that doesn't have jsonrpc: "2.0" rather than throwing on it. Our host testing notes list a guard like this for Claude.
The tool result arrives as the whole CallToolResult. Its structuredContent field holds the data for the view, and the model never sees it. If your tool returns only text, the view has no structured data to draw. Return structuredContent alongside content for any tool whose view depends on it.
The view can't call a tool
A view can call tools on the same server, but only those whose visibility includes app. The spec sets the default to ["model", "app"] when the field is omitted. If you set visibility to ["model"], the host refuses the view's tools/call. Calls to tools on another server are always blocked.
Use ["app"] for a tool the model shouldn't see, such as a refresh button:
{
"name": "refresh_dashboard",
"description": "Refresh dashboard data",
"inputSchema": { "type": "object" },
"_meta": {
"ui": {
"resourceUri": "ui://weather-server/dashboard-template",
"visibility": ["app"]
}
}
}
In our testing, a data tool that should update an app that's already open needs the same _meta.ui.resourceUri as the app. Without it, the tool's result doesn't reach the open view.
Invalid ui.domain format and ui.domain mismatch
Claude reports these when _meta.ui.domain on the resource doesn't match your server. For Claude, the value must be the first 32 hex characters of the SHA-256 of your full connector URL, followed by .claudemcpcontent.com. Compute it with the URL you set in Customize > Connectors:
node -e 'const yourServerUrl = "https://example.com/mcp"; console.log(require("crypto").createHash("sha256").update(yourServerUrl).digest("hex").slice(0,32) + ".claudemcpcontent.com")'
A mismatch usually comes from one of two causes. The hash covers the whole URL, so https://example.com/mcp and https://example.com/mcp/ give different values. Or the connector is a local stdio server, which has no URL to hash, so remove the domain field. Set domain only when your view needs a stable origin, such as its own sign-in flow.
Large results don't display in Claude
Claude's guide says that on claude.ai and Claude Desktop, a tool result over about 150,000 characters goes to the code execution sandbox when the sandbox is active. The view then gets a pointer to a file instead of the structured content, and it never draws. Claude Code uses a separate default limit of 25,000 tokens, which you can change with MAX_MCP_OUTPUT_TOKENS.
Keep the first result small. Return a summary or the first page, let the user ask for more, and return identifiers or previews rather than large blobs. Load the rest from the view with a tool call.
Assets fail only on iOS
If the view loads on desktop but its scripts, images or API calls fail on iOS, your server may check the Referer header. Claude's guide says WebKit on iOS omits Referer on cross-origin subresource requests, and that it still sends Origin. Allow requests whose Origin ends in .claudemcpcontent.com instead, and return a matching Access-Control-Allow-Origin header.
no_app, tool_not_found and resourceError
The Inspector's --app-info probe reports these three results:
no_appwith exit code2: the tool has no_meta.ui.resourceUri. Check the tool's_metaintools/list.tool_not_foundwith exit code5: the tool name is wrong. A typo is reported separately from a missing view on purpose.- A
resourceErrorfield: the probe found the link but couldn't read the view resource, or theresourceUriisn't a validui://URI. Read the resource withresources/readto see the error in full.
If the probe still says no_app but your tool has a link, the server may only register view tools for clients that advertise MCP Apps support, as the spec's capability check allows. The CLI doesn't advertise that support by default. Add --advertise-apps to the probe so the CLI claims it.
The Inspector's app frame stays blank
The Apps tab loads the view in a sandbox on port 6275, and the sandbox has to be reachable from your browser. Use localhost or 127.0.0.1 and not [::1]. If you reach the Inspector through a tunnel or a container, forward port 6275 too. If the view declares _meta.ui.domain, it's served from a third origin on port 6278, so forward that port too.
FAQ
Why does my tool show a text result but no view?
The tool most likely has no link to a view, or the view resource can't be read. Check that the tool has a _meta.ui.resourceUri value that points at a ui:// resource, and that resources/read returns that resource with the MIME type text/html;profile=mcp-app.
Can I test an MCP App without a browser?
Partly. The MCP Inspector CLI checks the tool link, the view resource and its MIME type, so you can catch most server mistakes in a script. You still need a host to check that the view draws and that its requests are allowed.
Why does my view work in basic-host but not in Claude?
Each host can apply its own rules. The spec lets a host tighten the Content Security Policy beyond what you declared, and Claude has its own limits, such as the size of a tool result and the format of the ui.domain value. Compare what the host receives with what the debug server sends, then check the host developer tools.
Do I need a stable ui.domain?
Only if your view needs a fixed origin, such as its own sign-in flow or an allowlist on an API. The spec leaves the format to each host, and Claude checks the value against your server URL.
- Build an MCP AppMake a working tool with an interactive view using the official SDK.
- What are MCP AppsHow the view, the tool link and the host bridge fit together.
- How an MCP App gets its dataHow tool results reach the view and how the view calls tools.
- MCP Apps host compatibilityWhich AI apps show MCP Apps views and what each one supports.
- Test with MCP InspectorUse the Inspector CLI and web UI to test any MCP server.