MCP Apps 实战:接线、协商与测试
官方公告 MCP Apps Now Official 解释了 MCP Apps 是什么、为什么存在。本文处理的是决定你的应用能不能真正渲染出来的部分:各个部件怎样接线、客户端与服务器怎样协商这个扩展、实际会在哪里失效,以及怎样用脚本检查一台真实运行的服务器。
文中的事实来自 MCP 官方文档里关于 Apps、扩展和缓存的页面,以及官方 Python SDK 2.3.0 的源码与文档。实验在本地完成,并在文中标明。
一、接线:四个必须一致的部件
| 部件 | 所在位置 | 规则 | 来源 |
|---|---|---|---|
| UI 绑定 | tools/list 里工具的 _meta.ui.resourceUri | 指向 UI 资源 | 官方公告与文档 |
| URI 方案 | resourceUri 的值 | 必须是 ui:// | 官方文档;SDK 对其他方案直接报错 |
| 资源 | 对该 URI 的 resources/read | HTML 以 text/html;profile=mcp-app 提供;SDK 文档写明宿主不会渲染其他 MIME 类型 | SDK 文档、扩展文档里的协商示例 |
| 资源元数据 | 资源上的 _meta.ui | csp(connectDomains、resourceDomains、frameDomains、baseUriDomains)、permissions(摄像头、麦克风、地理位置、剪贴板写入)、domain、prefersBorder | 官方文档点名了 csp 与 permissions;字段名取自 SDK 的模型 |
工具上还可以带 _meta.ui.visibility,在 SDK 里取值来自 model 与 app,用来说明工具出现在哪里。
调试时有两点要紧。宿主可以在调用工具之前预加载 UI 资源,所以资源必须能被单独读取,不能只是工具调用的副产品。其次,iframe 的 CSP 是默认拒绝:来自其他源的脚本和样式只有在 _meta.ui.csp 声明后才会加载,这也是官方构建指南用 vite-plugin-singlefile 把所有内容打进一个 HTML 文件的原因。
二、协商
扩展在双方都是显式开启(扩展概览):
| 一方 | 在哪里声明支持 | 内容 |
|---|---|---|
| 客户端,2026-07-28 | 每个请求里的 _meta["io.modelcontextprotocol/clientCapabilities"] | extensions["io.modelcontextprotocol/ui"].mimeTypes,列出 text/html;profile=mcp-app |
| 客户端,2026-07-28 之前(SDK 行为) | 在 initialize 中一次性发送的 capabilities | 同样的 extensions 对象 |
| 服务器 | server/discover 结果里的 capabilities.extensions["io.modelcontextprotocol/ui"] | 没有设置项时为空对象 |
SDK 的 client_supports_apps 只有在客户端既列出该扩展、又在 mimeTypes 中包含应用 MIME 类型时才返回 true。只声明扩展而不带 MIME 类型的客户端,会被视为不支持 Apps。
现代时代的能力声明是逐请求的,所以客户端能否得到 UI 是按请求决定的,这也是 SDK 把这个判断放在请求上下文上的原因。扩展文档里的“优雅降级”规则在此之上同样适用:一方不支持扩展时,另一方必须回退到核心行为;如果该扩展是必需的,则拒绝请求。
我在本地观察到一点:无论客户端是否声明了 Apps,SDK 服务器返回的 tools/list 结果都一样,都带有 _meta.ui。不要假设服务器会对用不了 UI 的客户端隐藏 UI 绑定。降级的责任落在工具的返回值上,它必须仍然带有文本。
三、用应用还是用文本?
官方文档列出了应用值得做的场景,同时也说:如果你的场景并不受益于“在对话里”这一性质,普通网页应用可能更简单。
| 场景 | 选择 | 原因 |
|---|---|---|
| 对大结果做排序、过滤、下钻 | 应用 | 纯文本每一步都要再发一轮提示 |
| 大量相互依赖的选项(部署表单) | 应用 | 用户一次看到所有选项和校验 |
| 查看 PDF、3D 模型或可缩放平移的图片 | 应用 | 查看器本身就是内容 |
| 实时指标或日志 | 应用 | 显示自动更新,无需反复询问 |
| 单个值或一个短列表 | 只用文本(我的判断,不是官方规则) | UI 会增加失败面,却没有收益 |
| 有自己账号体系和后端的长期产品 | 独立网页应用 | 官方文档说明:对话内的这些优势用不上时,普通网页更简单 |
四、什么会让你只剩纯文本
| 现象 | 可能原因 | 检查项 | 处理 |
|---|---|---|---|
| 工具能用,没有 UI,也不报错 | 资源以普通 text/html 提供 | bad-mime | 改为 text/html;profile=mcp-app |
| 工具能用,没有 UI,也不报错 | resourceUri 指向不存在的资源,或不是 ui:// | unreadable-resource、bad-scheme | 绑定与资源一起注册。SDK 的 Apps 辅助器在启动时就拒绝悬空绑定(我验证过),手工接线的服务器和其他 SDK 不一定会 |
| iframe 空白、脚本被拦截 | HTML 加载了 _meta.ui.csp 没有声明的源 | origin-not-in-csp(启发式) | 声明该源,或把内容打包成一个文件 |
| 不支持 Apps 的客户端看到空结果 | UI 工具没有返回文本 | no-text-fallback | 始终返回有意义的文本 |
| 宿主始终不把该服务器当作支持 UI | 服务器没有声明该扩展 | extension-not-advertised | 声明 io.modelcontextprotocol/ui |
| 一个宿主能用,另一个不行 | 各宿主支持情况不同 | 逐个宿主测试 | 查看官方客户端矩阵,其中有些客户端不支持 Apps |
| 改了 HTML 却没生效 | resources/read 是可缓存操作,ttlMs 只是提示 | 对照 ttlMs 与你的发布节奏 | 有意识地设定 ttlMs |
五、检查一台真实的服务器
check_mcp_app.py 只用标准库,并从 Server Instructions 实战 里的 check_instructions.py 导入 stdio 对端、时代探测和工具列表,所以两个文件要放在同一目录。它按第二节的方式声明 Apps 支持,列出工具,对每个带 _meta.ui.resourceUri 的工具读取资源并执行上面的检查。加上 --call NAME=JSON 时,它还会再连一次,以没有协商 Apps 的客户端身份调用该工具,期望拿到文本。退出码 0 表示没有发现,1 表示有警告,2 表示有错误。CSP 与降级规则是启发式检查,不是规范要求。
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())演示服务器使用 SDK 的 Apps 辅助器(pip install mcp)。APPS_DEMO 选择一个变体,复现第四节中的某个失效情形。
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")我本地的运行输出(Python 3.14、mcp 2.3.0、macOS)。每次运行是 APPS_DEMO=<变体> python3 check_mcp_app.py --call 'get_time={}' -- python3 apps_demo_server.py;下面每个块先写变体名,再列出检查结果:
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每次运行的 era: 与 tools: 两行都与第一个块相同,其余块省略了它们。手工接线的变体(bad-mime、missing、http-uri)还会触发 extension-not-advertised,因为它们绕过了负责声明该扩展的辅助器。
这个脚本不能证明什么。它不会渲染 HTML,也不使用 postMessage 协议,所以无法告诉你某个宿主是否真的把应用显示出来。它只在现代时代下跑过;旧版 initialize 路径没有用 Apps 服务器验证过,请视为未测试。各宿主表现不同,这里通过只是在每个宿主里测试的前提,不能替代那一步。
六、逐宿主测试
- 先运行检查脚本,在打开任何宿主之前修掉所有 ERROR。
- 在每个目标宿主里调用该工具,记录应用是否渲染。
- 打开宿主的开发者控制台,查找 CSP 违规。
- 关闭该扩展,或换一个不支持它的宿主,确认文本回答仍然有用。
- 修改 HTML 之后,确认新版本能加载,并把
ttlMs考虑在内。
七、上线前自查
- 工具绑定和资源在同一处注册,不会各改各的。
- 资源以
text/html;profile=mcp-app提供,且不调用工具也能读取。 - 每个外部源都在
_meta.ui.csp里声明,或者 HTML 已打包成一个文件。 - 工具的文本结果本身就有用。
- 你清楚自己测过哪些宿主,没测过哪些。
参考来源
- MCP Apps Now Official(MCP 官方博客)
- MCP 文档中的 MCP Apps 概览、构建指南、扩展概览与客户端矩阵
- 2026-07-28 规范中的缓存
- ext-apps 仓库中的 MCP Apps 规范
- 官方 Python SDK 2.3.0(
mcp.server.apps) - 本站相关阅读:Server Instructions 实战