MCP Apps in Practice: Wiring, Negotiation and Testing

MCP Apps in Practice: Wiring, Negotiation and Testing

October 25, 2026·MCP Community·Technical Analysis·MCP, MCP Apps, Extensions, Testing, UI

The official announcement MCP Apps Now Official explains what MCP Apps are and why they exist. This note covers the part that decides whether your app actually renders: how the pieces are wired, how client and server negotiate the extension, what fails in practice, and how to check a live server with a script.

Facts below come from the official MCP docs pages for Apps, extensions and caching, plus the source and documentation of the official Python SDK 2.3.0. The experiments were run locally and are labeled as such.

1. The wiring: four pieces that must agree

PieceWhere it livesRuleSource
UI binding_meta.ui.resourceUri on the tool, in tools/listPoints at the UI resourceOfficial announcement and docs
URI schemeThe value of resourceUriMust use ui://Official docs; the SDK raises an error otherwise
Resourceresources/read on that URIHTML served as text/html;profile=mcp-app; the SDK documents that hosts will not render it under any other MIME typeSDK documentation, negotiation example in the extensions docs
Resource metadata_meta.ui on the resourcecsp (connectDomains, resourceDomains, frameDomains, baseUriDomains), permissions (camera, microphone, geolocation, clipboard write), domain, prefersBorderOfficial docs name csp and permissions; field names from the SDK models

A tool may also carry _meta.ui.visibility, a list drawn from model and app in the SDK, which says where the tool is surfaced.

Two details matter for debugging. The host can preload the UI resource before the tool is called, so the resource must be readable on its own, not only as a side effect of a tool call. And the iframe’s CSP is deny-by-default: scripts and styles from other origins load only if _meta.ui.csp declares them, which is why the official build guide bundles everything into one HTML file with vite-plugin-singlefile.

2. Negotiation

Extensions are opt-in on both sides (Extensions Overview):

SideWhere it declares supportContent
Client, 2026-07-28_meta["io.modelcontextprotocol/clientCapabilities"] on every requestextensions["io.modelcontextprotocol/ui"].mimeTypes listing text/html;profile=mcp-app
Client, before 2026-07-28 (SDK behavior)The capabilities sent once in initializeThe same extensions object
Servercapabilities.extensions["io.modelcontextprotocol/ui"] in the server/discover resultAn empty object when there are no settings

The SDK’s client_supports_apps helper returns true only when the client both lists the extension and includes the app MIME type in mimeTypes. A client that declares the extension without the MIME type counts as not supporting Apps.

Because the modern era declares capabilities per request, whether a client gets UI is a per-request decision, which is why the SDK exposes the check on the request context. The graceful-degradation rule in the extensions docs applies on top: if one side lacks the extension, the other must fall back to core behavior, or reject the request if the extension is mandatory.

One thing I observed locally: the SDK server returned the same tools/list result, with _meta.ui included, whether or not the client declared Apps. Do not assume the server hides UI bindings from clients that cannot use them. The burden of degrading falls on the tool’s result, which must still carry text.

3. App or text?

The official docs list the cases where an app pays off, and also say a regular web app may be simpler when your use case does not benefit from living inside the conversation.

SituationUseWhy
Sorting, filtering and drilling into a large resultAppText needs another prompt for every step
Many interdependent options (a deployment form)AppThe user sees all options and validation at once
Viewing PDFs, 3D models or images with pan and zoomAppThe viewer is the content
Live metrics or logsAppThe display updates without re-asking
One value or a short listText only (my judgment, not an official rule)A UI adds a failure surface for no gain
A long-lived product with its own accounts and backendA standalone web appThe official docs say so when the in-conversation properties do not help

4. What leaves you with plain text

SymptomLikely causeCheckFix
Tool works, no UI, no errorThe resource is served as plain text/htmlbad-mimeServe it as text/html;profile=mcp-app
Tool works, no UI, no errorresourceUri points at nothing, or is not ui://unreadable-resource, bad-schemeRegister the binding and the resource together. The SDK’s Apps helper refuses a dangling binding at startup (I verified this), hand-wired servers and other SDKs may not
Blank iframe, blocked scriptsHTML loads an origin that _meta.ui.csp does not declareorigin-not-in-csp (heuristic)Declare the origin or bundle into one file
A client without Apps sees an empty resultThe UI tool returned no textno-text-fallbackAlways return meaningful text
The host never treats the server as UI-capableServer did not advertise the extensionextension-not-advertisedAdvertise io.modelcontextprotocol/ui
Works in one host, not anotherSupport varies by hostTest each hostCheck the official client matrix, which lists some clients without Apps support
Edited HTML does not show upresources/read is a cacheable operation and ttlMs is only a hintCompare ttlMs with your release cadenceSet ttlMs deliberately

5. Check a live server

check_mcp_app.py is stdlib-only and imports the stdio peer, the era probe and the tool listing from check_instructions.py in Server Instructions in Practice; keep both files in one directory. It declares Apps support the way section 2 describes, lists tools, and for every tool with _meta.ui.resourceUri reads the resource and applies the checks above. With --call NAME=JSON it also connects a second time as a client that did not negotiate Apps and calls the tool, expecting text. Exit code 0 means no findings, 1 warnings, 2 errors. The CSP and fallback rules are heuristics, not requirements of the specification.

check_mcp_app.py
#!/usr/bin/env python3
"""Check the MCP Apps wiring of a stdio MCP server (stdlib only).

Needs check_instructions.py (from the Server Instructions article) in the same
directory: it provides the stdio peer, the era probe and the tool listing.

For every tool whose `_meta.ui.resourceUri` is set, the script checks that the
URI uses `ui://`, that `resources/read` returns an HTML document served as
`text/html;profile=mcp-app`, and that external origins loaded by the HTML are
declared in the resource's `_meta.ui.csp`. With `--call NAME=JSON` it also
calls the tool as a client that did NOT negotiate Apps and expects text back.
CSP and fallback checks are heuristics, not requirements of the specification.

Usage: check_mcp_app.py [--timeout S] [--call NAME=JSON]... -- <server command>
Exit code: 0 = no findings, 1 = warnings, 2 = errors.
"""
import argparse
import json
import re
import sys

from check_instructions import Peer, handshake, list_tools, meta

EXT = "io.modelcontextprotocol/ui"
APP_MIME = "text/html;profile=mcp-app"
APPS_CAPS = {"extensions": {EXT: {"mimeTypes": [APP_MIME]}}}
ORIGIN = r"""(https?://[^/"'\s)>]+)"""
LOADS = [r"""\bsrc\s*=\s*["']?""" + ORIGIN, r"""<link[^>]+href\s*=\s*["']?""" + ORIGIN,
         r"""fetch\(\s*["']""" + ORIGIN, r"""url\(\s*["']?""" + ORIGIN]


def read_resource(peer, era, version, uri):
    params = {"uri": uri}
    if era == "modern":
        params["_meta"] = meta(version, APPS_CAPS)
    return peer.call("resources/read", params)


def declared_origins(csp):
    return {o.rstrip("/") for key in ("connectDomains", "resourceDomains", "frameDomains", "baseUriDomains")
            for o in (csp or {}).get(key, [])}


def check_tool(peer, era, version, name, ui):
    findings, uri = [], ui.get("resourceUri")
    if not isinstance(uri, str) or not uri.startswith("ui://"):
        return [("ERROR", "bad-scheme", "%s: resourceUri %r must be a ui:// URI" % (name, uri))]
    bad = [v for v in ui.get("visibility", []) if v not in ("model", "app")]
    if bad:
        findings.append(("WARN", "bad-visibility", "%s: unknown visibility value(s) %s" % (name, bad)))
    reply = read_resource(peer, era, version, uri)
    contents = (reply.get("result") or {}).get("contents") or []
    if not contents:
        return findings + [("ERROR", "unreadable-resource", "%s: resources/read %s failed: %s"
                            % (name, uri, json.dumps(reply.get("error") or reply)[:120]))]
    item = contents[0]
    if item.get("mimeType") != APP_MIME:
        findings.append(("ERROR", "bad-mime", "%s: %s is served as %r, hosts only render %s"
                         % (name, uri, item.get("mimeType"), APP_MIME)))
    html = item.get("text") or ""
    if not html.strip():
        findings.append(("ERROR", "empty-html", "%s: %s has no text content" % (name, uri)))
    csp = ((item.get("_meta") or {}).get("ui") or {}).get("csp")
    used = {o.rstrip("/") for pattern in LOADS for o in re.findall(pattern, html, re.I)}
    for origin in sorted(used - declared_origins(csp)):
        findings.append(("WARN", "origin-not-in-csp", "%s: HTML loads %s but _meta.ui.csp does not declare it"
                         % (name, origin)))
    return findings


def check_fallback(cmd, timeout, name, arguments):
    """Call a UI tool as a client without the Apps extension and expect text."""
    peer = Peer(cmd, timeout)
    try:
        era, version, _ = handshake(peer)
        params = {"name": name, "arguments": arguments}
        if era == "modern":
            params["_meta"] = meta(version)
        result = peer.call("tools/call", params).get("result") or {}
    finally:
        peer.close()
    texts = [c.get("text", "") for c in result.get("content", []) if c.get("type") == "text"]
    if result.get("isError") or not any(t.strip() for t in texts):
        return [("WARN", "no-text-fallback", "%s: no usable text for a client without Apps support" % name)]
    return []


def main():
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("--timeout", type=float, default=5.0)
    ap.add_argument("--call", action="append", default=[], metavar="NAME=JSON")
    ap.add_argument("cmd", nargs=argparse.REMAINDER)
    args = ap.parse_args()
    cmd = args.cmd[1:] if args.cmd[:1] == ["--"] else args.cmd
    if not cmd:
        ap.error("missing server command after --")
    peer = Peer(cmd, args.timeout)
    findings = []
    try:
        era, version, result = handshake(peer, APPS_CAPS)
        if era is None:
            print("[ERROR] %s" % result)
            return 2
        tools = list_tools(peer, era, version, APPS_CAPS) or {}
        bound = {n: (t.get("_meta") or {}).get("ui") for n, t in tools.items() if (t.get("_meta") or {}).get("ui")}
        print("era: %s (protocol %s)" % (era, version))
        print("tools: %d, bound to a UI: %s" % (len(tools), ", ".join(sorted(bound)) or "none"))
        advertised = EXT in (result.get("capabilities") or {}).get("extensions", {})
        if bound and era == "modern" and not advertised:
            findings.append(("WARN", "extension-not-advertised", "server/discover does not list %s" % EXT))
        for name in sorted(bound):
            findings += check_tool(peer, era, version, name, bound[name])
    finally:
        peer.close()
    for spec in args.call:
        name, _, raw = spec.partition("=")
        findings += check_fallback(cmd, args.timeout, name, json.loads(raw or "{}"))
    for level, code, message in findings:
        print("[%s] %s: %s" % (level, code, message))
    if not findings:
        print("[OK] no findings")
    return 2 if any(f[0] == "ERROR" for f in findings) else 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())

The demo server uses the SDK’s Apps helper (pip install mcp). APPS_DEMO selects a variant that reproduces one failure from section 4.

apps_demo_server.py
"""Demo MCP Apps server on the official Python SDK (pip install mcp).

APPS_DEMO selects a wiring variant:
  ok          tool bound to a ui:// resource served with the app MIME type (default)
  bad-mime    resource served as plain text/html, which hosts will not render
  missing     tool points at a ui:// resource that nothing serves (the SDK's Apps
              helper refuses such a binding, so this variant wires it by hand)
  http-uri    tool points at an https:// URI instead of ui://
  external    HTML loads a CDN script but declares no CSP domains
  csp-ok      same HTML, CSP declares the CDN origin
  no-fallback the tool returns no text, so clients without Apps support see nothing
"""
import os
from datetime import datetime, timezone

from mcp.server.apps import Apps, ResourceCsp
from mcp.server.mcpserver import MCPServer

MODE = os.environ.get("APPS_DEMO", "ok")
URI = "ui://clock/app.html"
HTML = "<!doctype html><title>Clock</title><p id=t>loading</p><script>document.getElementById('t').textContent='clock';</script>"
if MODE in ("external", "csp-ok"):
    HTML = HTML.replace("<script>", '<script src="https://cdn.example.com/chart.js"></script><script>')


def now() -> str:
    """Return the current UTC time as an ISO 8601 string (readable without any UI)."""
    return datetime.now(timezone.utc).isoformat()


if MODE in ("ok", "external", "csp-ok", "no-fallback"):
    apps = Apps()
    handler = (lambda: "") if MODE == "no-fallback" else now
    apps.tool(resource_uri=URI, name="get_time", description="Current UTC time")(handler)
    csp = ResourceCsp(resource_domains=["https://cdn.example.com"]) if MODE == "csp-ok" else None
    apps.add_html_resource(URI, HTML, name="clock", csp=csp)
    server = MCPServer("demo-clock", extensions=[apps])
else:  # hand-wired variants: plain tool metadata plus a plain resource
    server = MCPServer("demo-clock")
    ui_uri = {"missing": "ui://clock/gone.html", "http-uri": "https://example.com/app.html"}.get(MODE, URI)
    server.tool(name="get_time", description="Current UTC time", meta={"ui": {"resourceUri": ui_uri}})(now)
    mime = "text/html" if MODE == "bad-mime" else "text/html;profile=mcp-app"
    server.resource(URI, name="clock", mime_type=mime)(lambda: HTML)

if __name__ == "__main__":
    server.run("stdio")

Output from my runs (Python 3.14, mcp 2.3.0, macOS). Each run is APPS_DEMO=<variant> python3 check_mcp_app.py --call 'get_time={}' -- python3 apps_demo_server.py; each block below starts with the variant name and then shows the findings:

ok
era: modern (protocol 2026-07-28)
tools: 1, bound to a UI: get_time
[OK] no findings

bad-mime
[WARN] extension-not-advertised: server/discover does not list io.modelcontextprotocol/ui
[ERROR] bad-mime: get_time: ui://clock/app.html is served as 'text/html', hosts only render text/html;profile=mcp-app

missing
[WARN] extension-not-advertised: server/discover does not list io.modelcontextprotocol/ui
[ERROR] unreadable-resource: get_time: resources/read ui://clock/gone.html failed: {"code": -32602, "message": "Unknown resource: ui://clock/gone.html", "data": {"uri": "ui://clock/gone.html"}}

http-uri
[WARN] extension-not-advertised: server/discover does not list io.modelcontextprotocol/ui
[ERROR] bad-scheme: get_time: resourceUri 'https://example.com/app.html' must be a ui:// URI

external
[WARN] origin-not-in-csp: get_time: HTML loads https://cdn.example.com but _meta.ui.csp does not declare it

csp-ok
[OK] no findings

no-fallback
[WARN] no-text-fallback: get_time: no usable text for a client without Apps support

The era: and tools: lines of every run are identical to the first block and are omitted from the others. The hand-wired variants (bad-mime, missing, http-uri) also trigger extension-not-advertised, because they bypass the helper that advertises the extension.

What this does not prove. The script never renders the HTML and does not speak the postMessage protocol, so it cannot tell you that a host displays the app. It ran against the modern era only; the legacy initialize path was not exercised with an Apps server, so treat it as untested. Hosts differ, so a pass here is a precondition for testing in each host, not a substitute.

6. A per-host test pass

  1. Run the checker and fix every ERROR before opening any host.
  2. In each host you target, call the tool and note whether the app renders.
  3. Open the host’s developer console and look for CSP violations.
  4. Disable the extension, or use a host without it, and confirm the text answer is useful.
  5. After changing the HTML, confirm the new version loads, taking ttlMs into account.

7. Before you ship

  • Tool binding and resource are registered in one place, so they cannot drift apart.
  • The resource is served as text/html;profile=mcp-app and is readable without calling the tool.
  • Every external origin is declared in _meta.ui.csp, or the HTML is bundled into one file.
  • The tool’s text result is useful on its own.
  • You know which hosts you tested and which you did not.

Sources