MCP Apps in Practice: Wiring, Negotiation and Testing
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
| Piece | Where it lives | Rule | Source |
|---|---|---|---|
| UI binding | _meta.ui.resourceUri on the tool, in tools/list | Points at the UI resource | Official announcement and docs |
| URI scheme | The value of resourceUri | Must use ui:// | Official docs; the SDK raises an error otherwise |
| Resource | resources/read on that URI | HTML served as text/html;profile=mcp-app; the SDK documents that hosts will not render it under any other MIME type | SDK documentation, negotiation example in the extensions docs |
| Resource metadata | _meta.ui on the resource | csp (connectDomains, resourceDomains, frameDomains, baseUriDomains), permissions (camera, microphone, geolocation, clipboard write), domain, prefersBorder | Official 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):
| Side | Where it declares support | Content |
|---|---|---|
| Client, 2026-07-28 | _meta["io.modelcontextprotocol/clientCapabilities"] on every request | extensions["io.modelcontextprotocol/ui"].mimeTypes listing text/html;profile=mcp-app |
| Client, before 2026-07-28 (SDK behavior) | The capabilities sent once in initialize | The same extensions object |
| Server | capabilities.extensions["io.modelcontextprotocol/ui"] in the server/discover result | An 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.
| Situation | Use | Why |
|---|---|---|
| Sorting, filtering and drilling into a large result | App | Text needs another prompt for every step |
| Many interdependent options (a deployment form) | App | The user sees all options and validation at once |
| Viewing PDFs, 3D models or images with pan and zoom | App | The viewer is the content |
| Live metrics or logs | App | The display updates without re-asking |
| One value or a short list | Text 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 backend | A standalone web app | The official docs say so when the in-conversation properties do not help |
4. What leaves you with plain text
| Symptom | Likely cause | Check | Fix |
|---|---|---|---|
| Tool works, no UI, no error | The resource is served as plain text/html | bad-mime | Serve it as text/html;profile=mcp-app |
| Tool works, no UI, no error | resourceUri points at nothing, or is not ui:// | unreadable-resource, bad-scheme | Register 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 scripts | HTML loads an origin that _meta.ui.csp does not declare | origin-not-in-csp (heuristic) | Declare the origin or bundle into one file |
| A client without Apps sees an empty result | The UI tool returned no text | no-text-fallback | Always return meaningful text |
| The host never treats the server as UI-capable | Server did not advertise the extension | extension-not-advertised | Advertise io.modelcontextprotocol/ui |
| Works in one host, not another | Support varies by host | Test each host | Check the official client matrix, which lists some clients without Apps support |
| Edited HTML does not show up | resources/read is a cacheable operation and ttlMs is only a hint | Compare ttlMs with your release cadence | Set 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 supportThe 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
- Run the checker and fix every ERROR before opening any host.
- In each host you target, call the tool and note whether the app renders.
- Open the host’s developer console and look for CSP violations.
- Disable the extension, or use a host without it, and confirm the text answer is useful.
- After changing the HTML, confirm the new version loads, taking
ttlMsinto 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-appand 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
- MCP Apps Now Official (official MCP blog)
- MCP Apps overview, build guide, Extensions Overview and client matrix in the MCP documentation
- Caching in the 2026-07-28 specification
- The MCP Apps specification in the ext-apps repository
- The official Python SDK, version 2.3.0 (
mcp.server.apps) - Related reading on this site: Server Instructions in Practice