MCP Apps
MCP App 是带界面的工具:除了返回数据,这个工具还指向一个 HTML 文档,由宿主渲染成可交互的界面。
两个部分,永远是两个部分:
- 一个工具,负责干活并返回数据,和其他工具一样。
- 一个
ui://资源,包含宿主为它展示的 HTML。
工具通过 _meta.ui.resourceUri 引用这个资源。宿主用 resources/read 获取它,在沙箱化的 iframe 里渲染,再通过 postMessage 把工具的结果推送进 iframe。你的服务器从不收发任何 ui/* 消息:那些流量只在宿主和 iframe 之间往来。你提供一个工具和一份 HTML 文档,展示的事由宿主包办。
SDK 把它作为内置的 Apps 扩展(io.modelcontextprotocol/ui)提供。如果还不熟悉扩展,先浏览一下那一页。一分钟就够,然后回来。
一个带界面的时钟
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T12:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
四步:
Apps():一个实例容纳所有绑定 UI 的工具及其资源。@apps.tool(resource_uri="ui://clock/app.html"):一个普通工具,外加_meta.ui.resourceUri标记。@mcp.tool()接受的所有参数(name、title、description……)都会原样传递。apps.add_html_resource("ui://clock/app.html", CLOCK_HTML):与之对应的资源,以text/html;profile=mcp-app提供。正是这个 MIME 类型告诉宿主“这是一个 app,渲染它”。MCPServer("clock", extensions=[apps]):选择启用。服务器现在会在capabilities.extensions下声明io.modelcontextprotocol/ui。
HTML 本身监听宿主的 postMessage 并显示结果。真正的应用请在 HTML 里使用官方的 @modelcontextprotocol/ext-apps 浏览器 SDK。它提供 ontoolresult、callServerTool、getHostContext 和 onhostcontextchanged,不用再处理原始的 message 事件。
优雅降级
不是每个客户端都能渲染 app。这对你意味着什么,规范说得很直白:
即使有 UI 可用,工具也必须返回有意义的
content数组。
模型读的是 content;iframe 是给人看的。支持 UI 的宿主照样会把文本结果交给模型,而纯文本客户端只拿到那部分。所以标准模式是一个工具、两种回答。再看一遍 get_time:
from mcp import Client
from mcp.client import advertise
from mcp.server.apps import APP_MIME_TYPE, EXTENSION_ID, Apps, client_supports_apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.context import Context
CLOCK_HTML = """\
<!doctype html>
<title>Clock</title>
<h1 id="now">...</h1>
<script>
window.addEventListener("message", (event) => {
const text = event.data?.result?.content?.[0]?.text;
if (text) document.getElementById("now").textContent = text;
});
</script>
"""
apps = Apps()
@apps.tool(resource_uri="ui://clock/app.html", description="The current time.")
def get_time(ctx: Context) -> str:
now = "2026-06-26T12:00:00Z"
if not client_supports_apps(ctx):
return f"The time is {now}."
return now
apps.add_html_resource("ui://clock/app.html", CLOCK_HTML, title="Clock")
mcp = MCPServer("clock", extensions=[apps])
async def main() -> None:
async with Client(mcp, extensions=[advertise(EXTENSION_ID, {"mimeTypes": [APP_MIME_TYPE]})]) as client:
result = await client.call_tool("get_time", {})
print(result.content)
# [TextContent(text='2026-06-26T12:00:00Z')]
只有当客户端声明了 io.modelcontextprotocol/ui 扩展,并且在其 mimeTypes 设置里列出了 text/html;profile=mcp-app 时,client_supports_apps(ctx) 才为 True。这个字段是必填的,省略它的客户端不算数。同一文件里的 main() 声明的正是这些:协商中客户端的那一半,于是富结果就返回了。
Warning
绝不要把 "[Rendered UI]" 这样的占位符当作唯一的内容返回。如果回退文本没用,这个工具对所有纯文本客户端乃至模型本身就都没用。把那句话写出来。
给 iframe 上锁
安全相关的元数据放在资源一侧:iframe 可以加载什么、想要哪些浏览器权限、希望被怎样嵌入:
from mcp.server.apps import Apps, ResourceCsp, ResourcePermissions
from mcp.server.mcpserver import MCPServer
DASHBOARD_HTML = "<!doctype html><title>Dashboard</title><canvas id='chart'></canvas>"
apps = Apps()
@apps.tool(resource_uri="ui://dashboard/app.html", visibility=["app"])
def refresh_dashboard() -> str:
"""Refresh the dashboard data."""
return "refreshed"
apps.add_html_resource(
"ui://dashboard/app.html",
DASHBOARD_HTML,
title="Dashboard",
csp=ResourceCsp(connect_domains=["https://api.example.com"]),
permissions=ResourcePermissions(clipboard_write={}),
domain="dashboard.example.com",
prefers_border=True,
)
mcp = MCPServer("dashboard", extensions=[apps])
csp 和 permissions 是向宿主提出的请求,不是服务器的行为。宿主据此构建 iframe 的 Content-Security-Policy 和 Permissions-Policy,也可以拒绝。在 JS 里做特性检测,不要假定已经获准。
ResourceCsp 逐字段说明(Python 名、线路上的键、宿主拿它做什么):
| Python | 线路(_meta.ui.csp) |
控制 |
|---|---|---|
connect_domains |
connectDomains |
connect-src:fetch/XHR 可以访问哪里 |
resource_domains |
resourceDomains |
img-src、style-src……:静态资源 |
frame_domains |
frameDomains |
frame-src:嵌套的 iframe |
base_uri_domains |
baseUriDomains |
base-uri:<base> 可以指向哪里 |
ResourcePermissions:每个字段为 iframe 请求一项浏览器权限。
| Python | 线路(_meta.ui.permissions) |
|---|---|
camera |
camera |
microphone |
microphone |
geolocation |
geolocation |
clipboard_write |
clipboardWrite |
Note
CSP 和权限放在资源上,绝不放在工具上。规范的工具元数据里没有它们的位置,放在那里宿主也会忽略。SDK 让这个错误根本无从表达:@apps.tool() 压根没有 csp 参数。
可见性
工具上的 visibility=["app"] 表示“这是给 iframe 用的,不是给模型用的”:
"model":模型可以调用它。"app":iframe 可以调用它(通过callServerTool)。- 省略:两者都可以,这也是默认值。
过滤是宿主的事。服务器在 tools/list 里照常列出仅限 app 的工具;宿主负责对模型隐藏它们。不要在服务器端过滤。
SDK 强制执行的规则
这些都会在启动时失败,而不是在生产环境里:
- 不是
ui://...的resource_uri或资源 URI,在装饰/注册时抛出ValueError。 - 绑定到某个 URI 却没有对应的已注册资源的工具,在
MCPServer(extensions=[apps])消费这个扩展时抛出ValueError。一个声明了 HTML、却在resources/read上 404 的工具属于配置错误,所以直接拒绝构造。 - 在
@apps.tool()上传入meta={"ui": ...}会抛出ValueError。_meta["ui"]归装饰器管;用resource_uri=和visibility=来表达。其他meta=键可以正常一并合并。
TypeScript 的 ext-apps SDK 和 FastMCP 目前都不检查这些;我们宁愿你先于宿主发现问题。
内联 HTML 之外
add_html_resource 覆盖常见情况:一段 HTML 字符串。其他情况,比如磁盘上的 HTML 或生成的内容,自己构建资源再交给它:
from pathlib import Path
from mcp.server.apps import Apps
from mcp.server.mcpserver import MCPServer
from mcp.server.mcpserver.resources import FileResource
REPORT_HTML = Path(__file__).parent / "report.html"
apps = Apps()
@apps.tool(resource_uri="ui://report/app.html")
def refresh_report() -> str:
"""Refresh the report data."""
return "report refreshed"
apps.add_resource(FileResource(uri="ui://report/app.html", name="report", path=REPORT_HTML))
mcp = MCPServer("report", extensions=[apps])
资源没有显式设置 MIME 类型时,add_resource 会填上 text/html;profile=mcp-app;显式设置了不匹配的类型则会拒绝:用其他任何 MIME 类型的 ui:// 资源,没有宿主会渲染。
Tip
目标宿主是 GA 之前的版本,还在读取已弃用的扁平键 _meta["ui/resourceUri"]?自己合并进去:@apps.tool(resource_uri="ui://x", meta={"ui/resourceUri": "ui://x"})。嵌套的 ui 对象才是规范规定的形态;扁平键正在退出。
运行看看
examples/stories/ 里的 apps story 就是本页的可运行版本,由一对程序组成:一个带有绑定 UI 的时钟工具的服务器,和一个协商 Apps、读取工具的 _meta.ui.resourceUri、获取 HTML 并调用工具的客户端。
uv run python -m stories.apps.client