Skip to main content

How to Test and Debug MCP Apps

Short answer

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.

Tested withMCP Apps extension spec 2026-01-26 (stable)@modelcontextprotocol/ext-apps 2.0.3MCP Inspector 2.11.0Checked 20 Oct 2026

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.resourceUri set to a ui:// URI.
  • The view resource reads back. Read it with resources/read, and check that uri matches the tool's link and that mimeType is text/html;profile=mcp-app.
  • The probe agrees. --app-info reads 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:

ParameterWhat it changes
contentTypeReturns text, image, audio, resource, resourceLink or mixed content. Defaults to text.
includeStructuredContentLeaves structuredContent out of the result when set to false. Defaults to true.
simulateErrorReturns isError: true. Defaults to false.
delayMsWaits before responding, so you can see the loading state.
largeInputSends 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:

  1. Open Help > Troubleshooting and click Enable Developer Mode. A Developer menu appears.
  2. Ask Claude to use your tool. Claude asks for permission to display the app. Click Allow.
  3. 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.
  4. 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 seeUsual causeSection
Tool result appears, but no viewThe tool has no UI link, or the link points at the wrong URIThe view doesn't open
View is empty or has no heightZero height, the view never connected, or a blocked scriptThe view is blank
A request from the view failsThe view's request isn't in the declared CSPContent Security Policy blocks a request
basic-host shows Unsupported MIME typeThe resource's MIME type isn't text/html;profile=mcp-appUnsupported MIME type
The view loads, but no data arrivesThe handlers were registered after connect()The view never gets its data
The view's tool call is refusedThe tool's visibility leaves out appThe view can't call a tool
Claude reports Invalid ui.domain formatThe domain value doesn't match your server URLInvalid ui.domain format and ui.domain mismatch
A large result never displays in ClaudeThe result is over Claude's size limitLarge 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 sendSizeChanged with a height of 0.
  • 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:

  • connectDomains for fetch, XHR and WebSocket calls to an API
  • resourceDomains for scripts, images, styles, fonts and media from a CDN
  • frameDomains for nested iframes, such as a video player
  • baseUriDomains for 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_app with exit code 2: the tool has no _meta.ui.resourceUri. Check the tool's _meta in tools/list.
  • tool_not_found with exit code 5: the tool name is wrong. A typo is reported separately from a missing view on purpose.
  • A resourceError field: the probe found the link but couldn't read the view resource, or the resourceUri isn't a valid ui:// URI. Read the resource with resources/read to 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.