Blog

MCP Apps in the registry: 869 servers, and the five ways their UIs break

· 18 min read

MCP Apps let a tool answer with an interactive interface instead of only text: the tool points at an HTML page, and the host renders it inside the conversation. On November 6th we audited, with mcpscore 1.31.0, the 16,852 remote servers in the official MCP Registry that answered MCP in October. Of the 15,680 servers that completed a full session, 869 (5.5%) serve Apps, and 68 of those have at least one finding from mcpscore’s Apps rules. For 66 it is a broken requirement of the MCP Apps specification; the other 2 rely only on a pointer key the spec deprecates. 23 of the 68 belong to one operator, whose servers answer every UI read with the same README file.

The most common problem, on 25 servers, is a UI resource that is an HTML fragment rather than a complete HTML document. The others are UIs served only in the format of OpenAI’s Apps SDK (12 servers), MIME types from older drafts of the spec (6), tools that point at resources that don’t exist (2), and tools that use only a deprecated pointer key (2). Some of these already stop an App from rendering in a host that follows the spec; others work today only because browsers are forgiving.

Below: how an App works, how a host loads one, how we measured, each failure with a real example and its fix, what the data can’t tell you, and the mistakes mcpscore made on the first pass.

What an MCP App is

An MCP App is three ordinary MCP pieces used together: a tool, a resource and a client capability. The MCP Apps extension defines how they connect, and its specification, dated January 26th, 2026, holds the exact requirements quoted in this post.

  • A tool that points at a UI. The tool’s metadata carries _meta.ui.resourceUri, a URI with the ui:// scheme.
  • A resource that holds the UI. Reading that URI with resources/read returns an HTML document with the MIME type text/html;profile=mcp-app.
  • A client that says it can render it. The client declares the extension io.modelcontextprotocol/ui when it connects, with the list of MIME types it supports.

This is the tool from the spec’s own example:

{
  "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"]
    }
  }
}

When a model calls get_weather, a host that supports Apps reads ui://weather-server/dashboard-template, renders it, and passes the tool’s result into it. A host without Apps support ignores _meta and shows the tool’s text result. That second path is why every UI tool still needs a useful text result. The spec’s Graceful Degradation rules say servers “SHOULD provide text-only fallback behavior for all UI-enabled tools” and that tools “MUST return meaningful content array even when UI is available”.

visibility says who may call the tool. The default, ["model", "app"], lets both the model and the UI call it. A tool marked ["app"] is hidden from the model and exists only for the UI, for example to refresh the data a dashboard shows. The spec also asks servers to check the client’s capabilities first, and to register a UI tool only for clients that declare the extension.

How a host loads an App

Most of the failures in this post are in the resource, so it helps to see what a host does with it. The ext-apps repository ships a reference host, basic-host, and its code follows four steps:

  1. Read the resource. It calls resources/read on the tool’s ui:// URI and expects exactly one item in contents.
  2. Check the MIME type. If the type is anything other than text/html;profile=mcp-app, it stops with “Unsupported MIME type” and renders nothing. The comparison is an exact string match.
  3. Build a Content Security Policy from the domains the resource declares in _meta.ui.csp.
  4. Write the HTML into a sandboxed iframe on a separate origin, and from then on talk to it only through postMessage.

A Content Security Policy (CSP) is the browser’s list of where a page may load scripts, styles, images and data from. The spec says a host MUST build it from the resource’s metadata and MUST NOT allow domains the resource didn’t declare. With no ui.csp at all, the Host Behavior rules set this default, which allows nothing outside the page itself:

default-src 'none';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
media-src 'self' data:;
connect-src 'none';

Each declared list opens one part of it. connectDomains adds origins for fetch and WebSocket calls, resourceDomains adds origins for scripts, styles, images and fonts, frameDomains allows nested iframes, and baseUriDomains sets where the document’s base URI may point.

Two things follow for the rest of this post. A host decides whether to render from what the server sends, not from what the page looks like. A wrong MIME type or a missing resource means no App, however good the HTML is. And the HTML arrives as a string that the host writes into an empty frame, so what the server sends is the whole document. No web server sits in between to add a doctype or a header.

Which clients render Apps

The official extension support matrix lists 13 of its 17 clients with MCP Apps support: Claude (web and Desktop), ChatGPT, VS Code GitHub Copilot, Microsoft 365 Copilot, Cursor, Goose, Postman, MCPJam, Glean, Archestra.AI, PostHog Code and Kiro Crew. Claude Code, fast-agent, MCP Inspector and mcpc don’t claim it. Clients add themselves to that table and nobody tests the entries, so read it as “claims support”.

We didn’t open the failing Apps in each of these clients for this post. The findings below are about what servers send, judged against the spec and, where the spec leaves room, against the reference host’s code.

How we measured

The MCP Registry is the public catalog run by the MCP project, where server authors publish their servers with a name, a version and how to connect to them. In October, a sweep of it found 16,852 remote endpoints that answered MCP. On November 6th we audited all of them again with mcpscore 1.31.0, without calling a single tool. 15,680 completed a full session; the rest had gone offline, failed to complete a session, or now require sign-in before they answer at all.

For Apps, mcpscore lists the tools, follows their ui:// pointers, reads each resource and checks what comes back. It reads up to 16 distinct resources per server within 60 seconds and leaves any beyond that unread, so a server that passes may still have a resource we didn’t check. It declares the Apps extension when it connects, with the same mimeTypes setting a host sends, so servers that show UI tools only to Apps-capable clients showed them to us too. A server counts as serving Apps if a tool carries Apps metadata (_meta.ui or the deprecated flat key) or if it lists ui:// resources.

Every failure in this post was then checked by hand: a fresh session, the raw resources/read response, and for fragments the complete document. That step removed 43 failures that were mcpscore’s mistake rather than the server’s, described further down.

The registry is opt-in, so these are servers whose authors chose to publish them, and the audit is a snapshot of a single day.

What Apps servers look like

869 of the 15,680 servers (5.5%) serve Apps, about one remote server in eighteen, nine months after the extension’s specification was published.

They come from many different authors. The 869 servers run on 831 hosts under 717 registry namespaces (the reverse-domain prefix of a server’s registry name, such as io.github.username), and the largest single host has 7 entries. That is unusual for the registry, where a handful of operators publish hundreds of near-identical entries each.

Most are small. 817 have at least one tool that points at a UI. The other 52 list ui:// resources without any tool pointing at them, so the Apps rules have nothing to check. The median server has 2 UI tools, nine in ten have 8 or fewer, and the largest has 119.

The newer parts of the spec are in use. 276 servers set visibility on their UI tools, and 71 have at least one app-only tool. 460 (53%) declare a CSP, 172 ask for a dedicated domain, and 19 request browser permissions such as the camera or microphone. All 1,156 resources we read were sent as text; none used blob.

They speak a recent protocol. 771 of the 869 negotiated the 2025-11-25 revision of MCP, and 19 the 2026-07-28 revision.

And most of them pass. 801 of the 869 (92%) pass every Apps rule that applies to them.

Five ways UI resources break

68 servers have at least one finding: 66 break a requirement of the spec, and 2 use only the deprecated pointer key, which the spec still allows for now. 23 of those are entries from a single operator, whose servers answer every ui:// read with the same README file as plain text. Counting that operator once, 46 different authors are affected. A few servers have more than one problem, so the counts below add up to slightly more than 45:

  • A fragment instead of a document: 25 servers.
  • A UI served only in OpenAI’s Apps SDK format: 12 servers.
  • A MIME type from an older draft, or an invented one: 6 servers.
  • A pointer to a resource that doesn’t exist: 2 servers.
  • Only the deprecated pointer key: 2 servers.

The examples below come from real servers. We replaced their domains and shortened the content, but kept the structure.

Fragments instead of documents

The most common failure is a UI resource that is HTML but not an HTML document. The spec’s Content Requirements are explicit: “Content MUST be valid HTML5 document”. A document starts with <!doctype html> and has an <html> element around a <head> and a <body>. On 25 servers, the resource is only what would go inside the body. This is the start of one, a status card:

<main id="app" aria-live="polite">
  <div class="eyebrow">DECISION READINESS</div>
  <h2 id="title">Reading the signal…</h2>
  <p id="explanation"></p>
  <section><span>NEXT MOVE</span><p id="next"></p></section>

Others open with a <style> block, or with a <div id="root"> and a script tag that loads a bundle. They range from under 1 KB to 350 KB, so this isn’t only quick prototypes: several are complete applications with every asset inlined.

These UIs render today because browsers forgive missing structure. The HTML parser adds the missing <html>, <head> and <body> elements by itself. What it can’t add is the doctype. Without one, the browser renders the page in quirks mode, the compatibility mode for pages written before web standards, which changes some layout rules, such as how percentage heights resolve. The bigger issue is the requirement itself: nothing in the spec asks a host to render a fragment, and a host that validates what it loads is within its rights to refuse one.

11 of the 25 come from one framework. Every resource it produces starts with a script tag that configures the framework, followed by an empty root element and no document around them. We have not named it here; a fix there fixes all 11 servers at once. The other 14 are hand-written.

The fix is a document wrapper. Wrap the fragment where your server returns the resource:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
  </head>
  <body>
    <main id="app" aria-live="polite">…</main>
  </body>
</html>

If a build tool produces the fragment, adding the wrapper in the resource handler is a string concatenation. mcpscore’s apps_ui_resource_html_document rule accepts a resource that opens with an HTML doctype or an <html> root element, and fails one that opens with anything else.

UIs built only for ChatGPT

Before MCP Apps, OpenAI’s Apps SDK defined its own way for an MCP tool to show a UI in ChatGPT, with its own MIME type, text/html+skybridge, and its own metadata keys under openai/. MCP Apps grew out of that work and the MCP-UI project, and ChatGPT now supports both formats.

Many servers serve both. 68 Apps servers serve at least one resource as text/html+skybridge, and 28 of them also serve the UI as text/html;profile=mcp-app. That is the way to reach every host. On 12 servers, though, the tool’s MCP Apps pointer, _meta.ui.resourceUri, leads to a resource served only as text/html+skybridge.

A host that follows the MCP Apps spec won’t render these. The reference host stops at the MIME check before it looks at the HTML. ChatGPT renders them, because it understands its own format. The likely explanation is an innocent one: the servers were built and tested in ChatGPT, and the MCP Apps pointer was added later without a second resource behind it.

The fix is a second resource. Serve the UI under another ui:// URI with the MCP Apps MIME type, point _meta.ui.resourceUri at it, and leave OpenAI’s openai/outputTemplate pointing at the original. Each host then finds the format it expects.

The MIME type gets the resource loaded, but the HTML also has to speak the MCP Apps protocol. A View starts with a ui/initialize handshake, and the spec says the host “MUST NOT send any request or notification to the View before it receives an initialized notification”. Tool results then arrive as ui/notifications/tool-result. A UI that reads its data only from ChatGPT’s window.openai object passes the MIME check in another host and then shows nothing, because it never starts the handshake. Write the UI against the MCP Apps API (the ext-apps SDK’s App class does the handshake for you), or support both APIs; ChatGPT accepts the MCP Apps one too.

MIME types from older drafts

Six servers send the HTML with a type label the spec doesn’t use:

  • text/html (2 servers). The honest description of the content, but not the MCP Apps type.
  • text/html+mcp (1 server). The type of the extension’s draft until December 4th, 2025, when it switched to a MIME profile parameter (ext-apps#75). This server’s HTML is a correct document; only the label is out of date.
  • application/vnd.modelcontextprotocol.app+html (1 server). A type that no version of the spec defines.
  • No MIME type (1 server), and a mix of right and wrong types across one server’s resources (1).

The reference host rejects all six the same way, with “Unsupported MIME type”. Its comparison is exact, so a type with a different suffix, a missing ;profile=mcp-app or an extra space counts as a different type. Other hosts may parse the type more leniently; the spec doesn’t promise that they will. The profile parameter is what lets a host tell an App’s UI apart from any other HTML a server might expose as a resource.

The fix is one string. Use text/html;profile=mcp-app exactly. In TypeScript, the ext-apps SDK exports it as RESOURCE_MIME_TYPE. Its registerAppResource helper sets it on the resource’s listing by default, but the host checks the contents your read callback returns, so set mimeType: RESOURCE_MIME_TYPE there too.

Pointers to resources that don't exist

The spec’s Resource Discovery section says the resource a tool points at “MUST exist on the server”, and that a host “MUST use resources/read to fetch” it. Two servers break this in different ways:

  • One server doesn’t implement resources at all. Its tools point at ui:// resources, but resources/read answers with JSON-RPC error -32601, “Method not found”.
  • The other answers the read with the wrong thing. resources/read returns a list of tools instead of a contents array.

Both look like tool metadata copied from an example, with the resource side never built. A host that tries to render either shows an error where the UI should be, or falls back to the text result if it was written to. The fix is to register the resource, then read it once yourself with resources/read to confirm what comes back.

The deprecated pointer key

Early versions of the extension pointed a tool at its UI with a flat key, _meta["ui/resourceUri"]. The final spec moved the pointer under _meta.ui, next to visibility, and keeps the flat key only for compatibility: “The flat _meta["ui/resourceUri"] format is deprecated. Use _meta.ui.resourceUri instead. The deprecated format will be removed before GA.”

298 of the 869 servers still send the flat key, and 296 of them also send the nested one. Two servers send only the flat key, which the spec still allows; mcpscore reports it as a LOW warning, not a failure of the spec:

{
  "name": "preview",
  "_meta": { "ui/resourceUri": "ui://example/preview.html" }
}

These UIs still render, because hosts read both keys. The ext-apps SDK’s guidance for hosts checks the nested key first and falls back to the flat one. They stop rendering when hosts drop the fallback, which the spec says will happen before the extension reaches general availability. The fix, before GA, is to add _meta.ui.resourceUri with the same URI. Sending both keys isn’t required today, and keeping the flat key next to the nested one costs nothing until then.

Content Security Policy: mostly right

The CSP declarations we found are careful. 460 servers declare ui.csp on at least one resource, 1,170 declarations with 3,477 domain entries in total. None of the entries is a full wildcard (* or https://*), none uses plain http://, and none points at localhost. 13 servers use subdomain wildcards such as https://*.example.com, a form the spec documents as supported and uses in its own examples.

That matters because the CSP is what stands between an App and the rest of the internet. A resource that declared * would have opted out of most of the protection the host gives it, and none did.

The one mistake we saw is quieter: keys the spec doesn’t define. Nine servers use names borrowed from CSP itself or from OpenAI’s metadata: connectSrc, imgSrc, resourceSrc, connect-src, the snake_case connect_domains and resource_domains, and redirectDomains. One server tried three spellings for the same list, and none of them is the one the spec defines:

{
  "connect-src": ["wss://voice.example", "https://voice.example"],
  "connectSrc": ["wss://voice.example", "https://voice.example"],
  "connect_domains": ["voice.example"],
  "resource_domains": ["voice.example"]
}

A host reads only connectDomains, resourceDomains, frameDomains and baseUriDomains, so it ignores all four of these keys and applies its restrictive default. The WebSocket and API calls this App needs are blocked by the browser, and the only trace is an error in the console inside the host’s iframe, which makes it hard to connect to a misnamed key on the server. The snake_case names match the keys of OpenAI’s openai/widgetCSP, so they were most likely copied from there. mcpscore doesn’t check CSP key names yet.

What this data does not show

  • Whether the UIs render. We didn’t open the failing Apps in a host. The findings describe what servers send, judged against the spec and the reference host’s code. Some hosts are more lenient than the reference host.
  • Whether the text fallback works. mcpscore never calls tools during an audit, so it can’t see the content a UI tool returns. A UI tool with an empty text result fails every client without Apps support, and nothing in this post would catch it.
  • How the Apps behave. Accessibility, theming and what happens when the host resizes the frame are out of scope.
  • Servers added since October. We re-audited the endpoints an October sweep found live; servers published to the registry after it aren’t included.
  • Servers outside the registry. Many Apps, including most built into commercial products, aren’t published there.
  • Change over time. This is one day, November 6th, 2026.

What we got wrong on the first pass

Our first pass reported 45 servers failing apps_ui_resource_resolves, the rule that checks a UI resource can be read. 43 of the 45 hadn’t failed: mcpscore had misread their answers.

  • 32 servers were rate-limiting us. The UI read comes late in an audit, after the protocol checks, and by then those servers answered with HTTP 429 Too Many Requests.
  • 11 servers require sign-in to read a resource. They let anyone connect and list tools, but answered resources/read with HTTP 401 or 403.

In both cases the server never said whether the resource exists. mcpscore 1.31.0 took the error as an answer and failed the rule. 1.31.1, released on November 7th, treats a refused or rate-limited read as no observation: the rule skips with the reason instead of failing, and the same change covers the Skills rules, which read resources the same way. Re-running the 45 servers on 1.31.1 leaves exactly the two described above. If you ran 1.31.0 against your server and saw this rule fail with “Server returned an error response”, upgrade and run it again.

How to check your server

We build mcpscore, the tool that found these problems, so take this as the obvious recommendation it is. It is open source, needs no account, and runs from your terminal:

uvx mcpscore 'https://your-server.example/mcp'

The Apps rules run only when a tool carries Apps metadata (_meta.ui or the deprecated flat key), so a server without Apps keeps its score. Malformed metadata or a pointer that isn’t a ui:// URI still counts, and fails. There are six, and the rules reference has the spec citation for each:

  • apps_tool_ui_meta_well_formed (MEDIUM): _meta.ui has the shape the spec defines.
  • apps_ui_resource_uri_scheme (MEDIUM): every pointer names a ui:// URI.
  • apps_ui_resource_resolves (MEDIUM): resources/read returns each resource a tool points at.
  • apps_ui_resource_mime_type (MEDIUM): each one is served as text/html;profile=mcp-app.
  • apps_ui_resource_html_document (MEDIUM): each one is an HTML document, not a fragment.
  • apps_tool_ui_pointer_nested (LOW): tools use _meta.ui.resourceUri, not only the deprecated flat key.

The one check to do by hand is the text fallback. Connect with a client that doesn’t render Apps, such as MCP Inspector or Claude Code, call each UI tool, and read the result as a user without the UI would.

Every fix in this post is small: a document wrapper, a MIME string, a registered resource, a metadata key. Today most hosts still tolerate several of these mistakes; a server that follows the spec doesn’t depend on that.