MCP Apps Host 开发
Overview
MCP Apps(io.modelcontextprotocol/ui 扩展)让 MCP Server 在工具返回值里嵌入交互式 HTML 卡片。这张卡片是一个沙箱 iframe,通过 postMessage 与宿主应用通信,可以调用 MCP 工具、上报状态、发送对话消息。
本文档覆盖任何 Agent 应用作为 Host(宿主端) 的完整开发知识——从卡片渲染、桥接通信、安全边界到常见踩坑。
这不是 MCP Server 端开发指南(用 fastmcp skill 构建 MCP Server),而是 Host 端:接收卡片、渲染 iframe、管理沙箱安全、桥接消息。
参考实现:本文以 Hermes Agent 桌面端为参考实现。架构模式和安全模型适用于任何 Agent 应用;附录 Reference Implementation: Hermes Desktop 提供角色→文件的映射。
When to Use
- 在 Agent 应用中新增或调试 MCP Apps 卡片的渲染逻辑
- 排查卡片不显示、iframe 空白、postMessage 不通的问题
- 修改卡片渲染组件、桥接处理器、UI 缓存等 MCP Apps 相关代码
- 给桥接层加安全校验(新增工具白名单、追溯审计等)
- 理解 MCP Server → 屏幕上那张卡片的完整数据流
不要用这个 skill 来做:
- 构建 MCP Server 本身 → 用
fastmcp - 配置 MCP Server 连接 → 用你所用 Agent 的 MCP 配置
- 与卡片无关的纯前端 UI 调整 → 用通用前端调试 skill
架构全景
数据流:从 MCP Server 到屏幕上的卡片
任何 MCP Apps Host 实现都包含以下 7 层。每层的角色名称是通用的;括号内标注 Hermes 参考实现的具体位置。
┌────────────────────────────────────────────────────────────┐
│ 1. MCP Server (Python, 你的服务) │
│ tool 返回 _meta.ui.resource = {uri, mimeType, html} │
│ + _meta.ui.csp = {scriptSrc, connectDomains, ...} │
└──────────────────────┬─────────────────────────────────────┘
│ MCP 协议 (stdio / HTTP)
┌──────────────────────▼─────────────────────────────────────┐
│ 2. Extraction Layer (Hermes: tools/mcp_tool.py) │
│ _extract_mcp_ui() 从 CallToolResult._meta.ui 提取卡片 │
│ _stash_mcp_ui_payload() 按 tool_call_id 暂存 │
│ → 卡片 HTML 不进模型上下文(保护 prompt cache) │
└──────────────────────┬─────────────────────────────────────┘
│ pop_mcp_ui_payload(tool_call_id)
┌──────────────────────▼─────────────────────────────────────┐
│ 3. Event Bridge (Hermes: tui_gateway/server.py) │
│ pop_mcp_ui_payload() → payload["ui"] = {server, uri, │
│ mimeType, html, csp} │
│ → 作为 tool.complete 事件发送给渲染端 │
└──────────────────────┬─────────────────────────────────────┘
│ 事件 / JSON-RPC (tool.complete)
┌──────────────────────▼─────────────────────────────────────┐
│ 4. Renderer (Hermes: message-parts.tsx::ChainToolFallback)│
│ → hasMcpUi(result) 检测 result.ui 字段 │
│ → <CardComponent toolCallId={...} result={...} /> │
└──────────────────────┬─────────────────────────────────────┘
│ 渲染
┌──────────────────────▼─────────────────────────────────────┐
│ 5. Card Component (Hermes: mcp-app-card.tsx) │
│ ┌──────────────────────────────────────────────┐ │
│ │ sandbox iframe (allow-scripts allow-forms) │ │
│ │ │ │
│ │ [搜索结果] [商品 ¥14] [加购] │ │
│ │ │ │
│ │ 用户点击 → postMessage({jsonrpc, id, │ │
│ │ method: "tools/call", │ │
│ │ params: {name: "cart_add", ...}}) │ │
│ └──────────────────┬───────────────────────────┘ │
│ │ window.addEventListener('message') │
│ ▼ │
│ Card 桥接逻辑: │
│ ui/* 方法 → 本地处理(初始化、resize、model-context) │
│ tools/call、resources/* → 转发到 Bridge Handler │
└──────────────────────┬─────────────────────────────────────┘
│ 事件 / JSON-RPC
┌──────────────────────▼─────────────────────────────────────┐
│ 6. Bridge Handler (Hermes: tui_gateway mcp.app.request) │
│ 解析 {server, toolCallId, message} │
│ → 转发到 Security Gate │
└──────────────────────┬─────────────────────────────────────┘
│
┌──────────────────────▼─────────────────────────────────────┐
│ 7. Security Gate + Execution (Hermes: mcp_tool.py │
│ ::call_mcp_app_request) │
│ 校验 tool_call_id + tool name whitelist │
│ → 执行实际的 MCP 调用 (session.call_tool / read_resource) │
│ → 返回 JSON-RPC response │
└──────────────────────┬─────────────────────────────────────┘
│
▼ 原路返回 postMessage → iframe
两张卡片形态
MCP Server 可以通过两种方式提供卡片 HTML:
| 形态 | 来源 | 何时解析 | 示例 |
|---|---|---|---|
| inline form | 工具 RESULT 的 _meta.ui.resource 直接嵌 HTML |
每次工具调用 | login / checkout / address |
| referenced form | 工具 DEFINITION 的 _meta.ui.resourceUri(ui:// URI) |
工具定义发现时缓存 HTML,调用时复用 | catalog_search / cart_list |
referenced form 的 HTML 是静态的(每个 ui:// URI 只 fetch 一次,缓存在内存中),卡片通过 ui/initialize 的 lastToolResult 拿到工具数据来渲染。
ui/initialize 握手
卡片 iframe 加载后,向 host 发送 ui/initialize 请求。Host 响应中携带卡片初始化所需的全部上下文:
// Card Component — ui/initialize 响应
reply({
result: {
protocolVersion: ...,
hostInfo: HOST_INFO,
hostCapabilities: { updateModelContext: { text: {} }, message: { text: {} } },
lastToolResult: buildLastToolResult(resultRef.current), // 工具原始 structuredContent
sessionId: readSessionId(resultRef.current) // 从 structuredContent.session_id 提取
}
})
lastToolResult:从原始工具结果的structuredContent构建。referenced-form 卡片用它渲染初始视图(如商品列表),而非空白搜索页。当structuredContent不存在或error时返回undefined。sessionId:从structuredContent.session_id提取。某些 MCP Server 要求每次tools/call都带session_id。卡片通过ui/initialize拿到后,理论上应在后续tools/call中带上——但旧版卡片 SDK 不一定会这样做(见踩坑 #11)。
Implementation Roles
任何 MCP Apps Host 实现都需要以下角色。每行列出通用职责和 Hermes 参考实现的具体位置。
Extraction Layer (Hermes: tools/mcp_tool.py)
├── _extract_mcp_ui() # 从 CallToolResult._meta.ui 提取 inline 卡片
├── _resolve_referenced_mcp_ui() # 从 tool definition 解析 referenced 卡片
├── _stash_mcp_ui_payload() # 按 tool_call_id 暂存(不入模型上下文)
├── pop_mcp_ui_payload() # 一次性取出,供 Event Bridge 消费
├── call_mcp_app_request() # ★ 安全入口:校验 + 执行 MCP 调用
├── _capture_ui_tool_resources()# 工具发现时记录 referenced-form 元数据
└── _initialize_declaring_ui() # 握手时声明 host 支持 UI 扩展
Event Bridge (Hermes: tui_gateway/server.py)
├── _on_tool_complete() # pop_mcp_ui_payload → payload["ui"]
└── mcp.app.request handler # iframe 桥接请求的网关入口
Renderer (Hermes: apps/desktop/src/components/assistant-ui/)
├── message-parts.tsx # ★ 工具结果派发器 → hasMcpUi → CardComponent
└── mcp-app-card.tsx # ★ 卡片组件:iframe + postMessage 桥接 + session_id 注入
UI State Cache (Hermes: apps/desktop/src/lib/chat-messages.ts)
├── cacheMcpUi() # 写内存 Map + localStorage
├── preserveMcpUiCards() # 读内存 → 回退 localStorage
└── clearCachedMcpUi() # 清两个位置
Model Communication Channel (Hermes: apps/desktop/src/store/mcp-app.ts)
├── ui/update-model-context # 静默状态快照
└── ui/message # 触发对话回合
Session Controller (Hermes: apps/desktop/src/app/contrib/wiring.tsx)
└── $mcpAppUserMessage 订阅 # ui/message → 提交路径
渲染管线
卡片如何被选中渲染
Renderer 层的派发器是所有工具结果的入口。MCP Apps 卡片不按 toolName 匹配(工具名是动态的),而是检查 result 是否携带 ui 字段:
// 派发器逻辑
if (hasMcpUi(props.result)) {
return <CardComponent {...props} />
}
hasMcpUi() 检查 result.ui 是否存在且包含必需的 server、html、uri 字段。
检查顺序
派发器中的检查顺序很重要(以 Hermes 为例):
todo— 提升到专用面板react_to_message(非错误)— 隐藏hasMcpUi(result)— MCP Apps 卡片 ← 我们的位置delegate_task— 子代理委派image_generate— 图片生成clarify— 澄清工具- 其他 → 通用 fallback 行
⚠️ 退役渲染器陷阱
大型 Agent 应用可能有多个版本的渲染管线共存。以 Hermes 为例:thread.tsx 有自己的 ChainToolFallback,但这是退役渲染器。当前主力渲染路径是 message-parts.tsx。不要在退役渲染器里加 MCP Apps 代码——只改当前活跃的渲染器,否则卡片不显示。
安全边界
问题:沙箱 iframe 可以越权调用工具
卡片跑在 sandbox="allow-scripts allow-forms" 的 iframe 里,通过 postMessage 桥接到宿主。如果 Bridge Handler 不加检查,卡片发的任何 tools/call 都会被直接执行。一张搜索卡片理论上可以调用结账、删除订单等任意工具。
两层防护
第一层 — 前端传 toolCallId(Card Component):
// 每次桥接请求都带上原始工具调用的 ID,用于审计追溯
const res = await requestBridge('mcp.app.request', {
server,
toolCallId, // ← 追溯:这个调用来自哪个卡片
message: msg
})
第二层 — 后端校验工具白名单(Security Gate):
if method == "tools/call":
name = params.get("name")
# 校验:iframe 要调用的工具必须在 MCP Server 注册的工具列表中
registered = getattr(server, "_registered_tool_names", None)
if name and (registered is None or name not in registered):
return {"error": {"code": -32601,
"message": f"tool '{name}' not registered on server '{server_name}'"}}
_registered_tool_names 在 MCP 握手时从 tools/list 的结果中自动填充,记录该 Server 实际暴露的所有工具名。
注意:校验条件用 registered is None or name not in registered,不要用 registered and name not in registered。旧代码用 or [] 把空列表转为 falsy 值,导致 [] and ... 短路跳过校验——在 tools 尚未发现或已重置为空列表时白名单形同虚设。None(未初始化)和 [](已初始化但无工具)均应阻断所有桥接调用。
resources/read 白名单 — ui:// 前缀检查(同层):
if method == "resources/read":
uri = params.get("uri")
# 安全:只有 ui:// 资源可通过桥接访问。非 ui:// URI
# (db://、file:// 等)是给模型用的,不是给沙箱 iframe 的。
if not isinstance(uri, str) or not uri.startswith("ui://"):
return {"error": {"code": -32601,
"message": f"resource not accessible from a card: {uri}"}}
和 tools/call 白名单是同一类防御——桥接层对每个出站方法都要校验参数合法性,不能无条件透传。
新增 MCP Server 时
如果新增了一个 MCP Server 并希望它的卡片能通过桥接调用工具,不需要额外配置——只要 Server 在 tools/list 中声明了工具,_registered_tool_names 就会自动包含它。
CSP 策略:卡片资源加载
卡片在 sandbox iframe 中运行,服务端通过 _meta.ui.csp 声明其需要的资源权限。Host 端的 buildCsp() 根据此配置构建 Content-Security-Policy:
function buildCsp(csp: McpUiCsp): string {
const res = (csp.resourceDomains ?? []).join(' ').trim()
const conn = (csp.connectDomains ?? []).join(' ').trim()
const script = (csp.scriptSrc || "'unsafe-inline' 'unsafe-eval'").trim()
return [
"default-src 'none'",
`script-src ${script}`,
`style-src 'unsafe-inline' ${res}`.trim(),
`img-src data: blob: ${res}`.trim(),
`font-src data: ${res}`.trim(),
`media-src ${res || "'none"'}`.trim(),
`connect-src ${conn || "'none'"}`.trim(),
"base-uri 'none'",
'form-action *'
].join('; ')
}
关键约束:
img-src放行data: blob: https:和resourceDomains中的域名。DSH 默认包含https:以支持 CDN 商品图片(参见坑 #17)。如果需要更严格的限制,在_meta.ui.csp.resourceDomains中声明具体域名。connect-src默认为'none'(sandbox iframe 无allow-same-origin,'self'无意义)。卡片需要发起网络请求(fetch/WebSocket)时,其域名必须在connectDomains中声明。script-src默认'unsafe-inline' 'unsafe-eval'——卡片 HTML 中的内联脚本可以执行,但外部脚本文件需要scriptSrc额外声明。
MCP Server 端最佳实践:在构造 _meta.ui.csp 时,列出卡片 HTML 中所有引用到的外部资源域名:
"_meta": {
"ui": {
"csp": {
"resourceDomains": ["img.alicdn.com", "gw.alicdn.com"],
"connectDomains": ["api.example.com"],
"scriptSrc": "'unsafe-inline' 'unsafe-eval' https://cdn.example.com",
}
}
}
卡片→模型通信
MCP Apps 规范定义了两个卡片到模型的通道:
ui/update-model-context(静默状态快照)
卡片把当前视图状态(如搜索关键词、筛选条件)发给模型,作为下一条用户消息的不可见前缀:
// 规范:每次更新覆盖同一 view 的上一个快照
stageModelContext(viewId, extractTextContent(params))
// 发送下一条用户消息时:
buildOutgoingUserText(userText)
// → "卡片状态:\n搜索: 蓝牙耳机 价格<100\n\n用户输入的文本"
ui/message(触发对话回合)
卡片请求模型发起一个新的对话回合。
requestMcpAppUserMessage(text)
// → 通过消息通道 → submitText 发送路径
安全:per-card debounce。恶意/有问题的卡片在循环中连续发 ui/message 会触发无限次模型对话,每次消耗 API 往返 + token。requestMcpAppUserMessage(text, debounceKey) 按 toolCallId 隔离限流窗口——同一张卡片在 debounce 窗口内只放行一次,不同卡片互不阻塞。
常见踩坑
1. 卡片不显示 → 检查渲染器
症状:工具调用成功但看不到卡片,只看到普通的 tool fallback 行。
排查:
- 确认活跃的渲染器(不是退役的)里有
hasMcpUi检查 - 打开 DevTools,在派发器里加
console.log看result.ui是否存在 - 检查
hasMcpUi()的校验逻辑——需要server、html、uri三个字段都存在
2. 改了渲染器却没生效 → 退役渲染器陷阱
症状:修改了渲染逻辑并保存,但卡片行为没变。
原因:大型 Agent 应用可能有多个版本的渲染管线共存。你改的那个可能已经退役,当前走的是另一个。
排查:确认哪个渲染器是活跃路径。以 Hermes 为例:thread.tsx 是退役渲染器,message-parts.tsx 才是当前主力。两个文件各有一个 ChainToolFallback,只改退役的会导致卡片不显示。
3. iframe 空白 / postMessage 不通
检查清单:
- iframe 的
sandbox属性是否包含allow-scripts - CSP 策略是否阻止了卡片需要的脚本/连接域名(
buildCsp()从_meta.ui.csp构建) - postMessage 的
event.source !== iframe.contentWindow校验是否误杀(其他 iframe 也会发 message) - DevTools Console 搜
[mcp-app<-card]/[mcp-app->card]看消息流(仅 DEV 模式输出)
4. 桥接调用失败"tool not registered"
原因:卡片试图调用的工具名不在 _registered_tool_names 中。
排查:
- 确认 MCP Server 的
tools/list确实声明了该工具 - 确认 Server 在你的 Agent 应用中已成功连接
- 重启后首次连接会重新 populate
_registered_tool_names
5. referenced-form 卡片始终不渲染
referenced form 需要两步:
- 工具定义发现时 Extraction Layer 记录
_meta.ui.resourceUri - 工具调用时解析
ui://资源获取 HTML
排查:
- 确认 Host 在 MCP
initialize握手时声明了io.modelcontextprotocol/ui扩展,否则 Server 不会在工具定义中带_meta.ui - 检查 HTML 缓存是否已有内容(key 通常是
(server_name, resourceUri)) - 检查
resources/read调用是否成功
6. 大 HTML 撑爆模型上下文
MCP Apps 卡片的 HTML 完全不进模型上下文。Extraction Layer 把 HTML 暂存在进程内存中(按 tool_call_id 索引),只通过事件发送给渲染端。模型看到的是不含 HTML 的工具结果。
如果实现新 Host 时发现模型回复里出现了大段 HTML,说明 stash 逻辑没生效——HTML 泄漏到了模型上下文,prompt cache 会失效。
7. 新增桥接方法时忘记加安全校验
这是最危险的模式。 Bridge Handler 中新增的任何 if method == "xxx": 分支,都必须检查参数合法性再透传。
tools/call: 校验 name 在_registered_tool_names中 ✅resources/read: 校验 uri 以ui://开头 ✅- 如果新增其他 bridge 方法,必须有对应的参数校验
不加校验 = 沙箱 iframe 可以调用任意 MCP Server 能力,绕过所有权限控制。
8. 图片 / 外部资源加载不出来
症状:卡片正常渲染,但图片显示空白/裂图,或 CSS 字体/媒体资源加载失败。
原因:CSP img-src / font-src / media-src 只放行了 resourceDomains 中声明的域名。如果 MCP Server 在 _meta.ui.csp.resourceDomains 里遗漏了卡片 HTML 中引用到的 CDN/图片域名,CSP 会静默阻挡这些资源。
排查:
- 打开 DevTools Console,搜
Content-Security-Policy违规报告(Chrome 会打印具体被挡的 URL 和指令) - 确认卡片 HTML 中所有
<img src="...">、<link href="...">的域名都在resourceDomains中 - 注意
connect-src默认是'none'(不是'self')——sandbox iframe 无同源概念,'self'无意义 - 确认 MCP Server 在构造
_meta.ui.csp时正确填入了所需域名
修复:在 MCP Server 端补充 _meta.ui.csp.resourceDomains 和/或 connectDomains。
9. _registered_tool_names 为空时桥接白名单被绕过
历史坑:旧代码用 registered = getattr(...) or [] + if name and registered and ... 做校验。当 _registered_tool_names 为 [](尚未填充、或 disconnect 后重置)时,[] 是 falsy,[] and ... 短路跳过整个校验块——白名单形同虚设。
已修复:改为 registered = getattr(...) + if name and (registered is None or name not in registered)。None(未初始化)和 [](已填充但为空)均阻断所有桥接调用。新增测试覆盖这两种情况。
10. CSP 排查技巧
在 DevTools Console 中执行以下检查:
// 查看 iframe 实际应用的 CSP
const iframe = document.querySelector('iframe[title*="ui://"]')
console.log(iframe?.contentDocument?.querySelector('meta[http-equiv="Content-Security-Policy"]')?.content)
// 监听 CSP 违规(在卡片 iframe 的 console 中)
document.addEventListener('securitypolicyviolation', e => {
console.warn('CSP blocked:', e.blockedURI, '→ directive:', e.violatedDirective)
})
11. 卡片 tools/call 缺少 session_id → 数据不渲染
症状:卡片渲染了但内容空白(如结账卡片"应付金额 -",无商品信息),agent 文本回复里有完整数据。
根因:卡片初始化后主动通过 tools/call 再次调用 MCP 工具刷新数据(如 checkout_get、address_form),但旧版卡片 SDK 不会把 ui/initialize 响应中的 sessionId 转发到 tools/call 的 arguments 里。某些 MCP Server 要求每次调用必须带 session_id,缺少时返回 isError: true,卡片拿不到数据。
修复:Host 在代理 tools/call 时自动注入 session_id(Card Component):
if (method === 'tools/call') {
const params = (msg.params ?? {}) as Record<string, unknown>
const args = (params.arguments ?? {}) as Record<string, unknown>
if (args.session_id === undefined) {
const sid = readSessionId(resultRef.current)
if (sid) {
outgoingMsg = { ...msg, params: { ...params, arguments: { ...args, session_id: sid } } }
}
}
}
调试技巧:在 ui/initialize handler 和 tools/call proxy 中加 console.log 输出 hasLastToolResult、sessionId、tools/call 的请求/响应 isError 和 hasSC,通过 CDP evaluate 在 iframe 重载后捕获。
12. HMR 重载后卡片消失 → localStorage 持久化
症状:开发模式下 HMR 触发页面重载后,已渲染的 MCP App 卡片全部消失。
根因:UI State Cache 是进程内 Map,HMR 重载清空内存。ui payload 只在 tool.complete 事件中一次性传递(server 端 pop_mcp_ui_payload 是单次消费),不会被持久化到数据库。
修复(UI State Cache 层):
cacheMcpUi()同时写内存 Map 和localStorage(key:mcp-ui:<sessionId>)preserveMcpUiCards()在内存 Map 未命中时回退读localStorageclearCachedMcpUi()同时清内存和localStorage- 跨 session ID 匹配:当 runtime session ID 与存储时的 event session ID 不一致时,扫描所有
mcp-ui:*localStorage 条目按 tool-call ID 匹配
注意:用 localStorage 而非 sessionStorage——localStorage 跨 HMR 重载和 app 重启都存活。
13. Gateway 进程挂了但 App 壳还活着 → 消息无回复
症状:在应用中发消息,完全没有回复,也不报错。
根因:如果 Agent 应用采用前后端分离架构(如 Electron + Python gateway),前端进程和后端 gateway 是两个独立进程。Gateway 可能因 API 端点超时(stream_interrupt_abort + tcp_force_closed=1)、内存耗尽或其他原因崩溃,但前端壳不会自动重启它。用户看到的是"活着但没反应"的 app。
排查:
# 1. 检查 gateway/backend 进程是否存活
# Hermes: ps aux | grep "hermes_cli.main serve" | grep -v grep
# 2. 检查最新 agent 日志(是否有新 turn 记录)
# 3. 如果日志停在几分钟前且没有新 turn → gateway 挂了
# 4. 检查 API 端点是否可达
curl -s --max-time 10 "<base_url>/chat/completions" \
-H "Authorization: Bearer <key>" \
-d '{"model":"<model>","messages":[{"role":"user","content":"hi"}],"max_tokens":5}'
修复:杀掉残留前端进程后重启。如果 API 端点本身不稳定,考虑配置 request_timeout 或使用更稳定的 provider。
14. MCP 客户端未声明 mimeTypes → 工具定义中 _meta 被清除
症状:tools/list 返回的工具定义中 _meta.ui 字段为空,导致 referenced-form 卡片无法渲染(iframe 不出现)。Agent 回复正常(文本工具结果不受影响),但看不到任何 MCP Apps 卡片。
根因:某些 MCP Server(如 UTP CLI v0.6.15)的 supportsMCPApps() 函数不仅检查客户端是否声明了 io.modelcontextprotocol/ui 扩展键,还要求扩展数据中包含 mimeTypes 数组且含 "text/html;profile=mcp-app"。只发送空对象 {} 不够——Server 会认为客户端不支持 MCP Apps,清除所有工具定义的 _meta。
修复:MCP 客户端初始化时,扩展声明中必须包含 mimeTypes:
const client = new Client(
{ name: 'my-host', version: '0.0.1' },
{ capabilities: {
extensions: {
'io.modelcontextprotocol/ui': {
mimeTypes: ['text/html;profile=mcp-app'] // ← 必须
}
}
}
},
)
排查方法:写一个独立的 MCP 客户端测试脚本,直接通过 stdio 连接 MCP Server,发 tools/list,检查返回的工具是否含 _meta。如果 0 个工具含 _meta,说明 Server 认为客户端不支持 UI 扩展——检查 mimeTypes 是否声明。UTP Server 源码参考:internal/mcp/server.go 的 supportsMCPApps() 函数。
15. 卡片渲染成功但工具结果未在卡片内展示
症状:iframe 正常渲染了 MCP Apps 卡片的 UI(如搜索表单),但 Agent 工具调用的结果(如搜索到的商品列表)未在卡片内部展示。卡片只显示空白表单或搜索入口,用户在卡片内看不到 Agent 已搜索到的数据。
根因:lastToolResult 的数据格式与卡片 React 应用期望的格式不匹配。卡片 React 应用期望 CallToolResult 形状的对象(含 .structuredContent 属性),但 Host 传递的是 content(render 输出的文本数组)或裸 structuredContent 值,不含 .structuredContent 属性包装。
UTP React 应用("UCP Catalog v5.0.0")的数据提取逻辑:
// 先检查 t 本身是否有 products/product/line_items/orders
if ("products" in t || ...) return t;
// 再检查 t.structuredContent
const n = t.structuredContent; // ← 期望 .structuredContent 属性
return n ? ("products" in n ? n : n.body ? n.body : ...) : null;
与坑 #11 的区别:坑 #11 是 tools/call 缺少 session_id 导致后续刷新失败;本坑是初始渲染就缺失——ui/initialize 的 lastToolResult 未被卡片 React 应用消费。
修复:presentationMeta() 必须将工具结果包装为 CallToolResult 形状对象传递:
// presentationMeta 返回值
return {
mcpApp: ui,
lastToolResult: {
content: result.content,
...(result.structuredContent !== undefined
? { structuredContent: result.structuredContent }
: {}),
},
}
然后 ui/initialize handler 从 meta.lastToolResult 读取并传给 iframe:
const meta = currentBlock.meta as { lastToolResult?: unknown } | undefined
result.lastToolResult = meta?.lastToolResult ?? currentBlock.content
已解决:DSH 适配中已修复此问题(2026-08-20)。修复后卡片内成功渲染商品列表(名称、价格、销量、好评率等),不再是空表单。
坑 #16:readSessionId 未使用 structuredContent 路径
现象:P1 修复后 meta.lastToolResult.structuredContent 已可用,但 readSessionId() 仍仅从 block.content 文本解析 JSON 查找 session_id。当 content 文本不是纯 JSON(如含可读前缀),parse 失败 → session_id 注入失败 → 卡片内加购物车/结算等操作断掉。
根因:P1 修复改了数据通道(presentationMeta → meta.lastToolResult),但 readSessionId 没跟着更新读取路径。
修复:readSessionId() 优先从 meta.lastToolResult.structuredContent.session_id 读取,回退到 content 文本解析:
const meta = block.meta as { lastToolResult?: { structuredContent?: Record<string, unknown> } } | undefined
const sc = meta?.lastToolResult?.structuredContent
if (sc && typeof sc === 'object') {
if (typeof sc.session_id === 'string') return sc.session_id
if (typeof sc.sessionId === 'string') return sc.sessionId
}
// Fallback: parse JSON from content text blocks...
已解决:DSH 适配中已修复此问题(2026-08-20)。
坑 #17:CSP img-src 阻断外部商品图片
现象:iframe CSP img-src data: blob: 仅允许 data/blob URI,阻断所有 CDN 外部商品图片(如 alicdn.com 上的商品图)。
根因:buildCsp() 默认 img-src 不包含 https: 协议,且 MCP Server 可能不在 _meta.ui.csp.resourceDomains 中声明图片域名。
修复:buildCsp() 默认 img-src 添加 https::img-src data: blob: https:。
已解决:DSH 适配中已修复此问题(2026-08-20)。
坑 #18:ui/update-model-context 未实现(TODO 桁留)
现象:ui/update-model-context handler 仅 console.debug 后丢弃上下文文本,从未暂存或注入到后续消息中。卡片发送的上下文更新(如搜索关键词、筛选条件)被完全丢弃。
根因:初始实现标记为 TODO,未完成暂存逻辑。
修复:
- 新增
_stagedContextMap,按 callId 暂存上下文文本 ui/update-model-contexthandler 写入_stagedContext.set(callId, text)ui/messagehandler 读取并前置到消息文本前发送,发送后清除- 组件卸载时清理
_stagedContext条目
已解决:DSH 适配中已修复此问题(2026-08-20)。
验证清单
修改 MCP Apps Host 代码后,检查以下项目:
- 活跃的渲染派发器中有
hasMcpUi检查,位于其他工具类型检查的正确位置(以 Hermes 为例:react_to_message之后、delegate_task之前) - 没有在退役渲染器里添加 MCP Apps 代码
-
ui/message订阅在实际的 Session Controller 中(以 Hermes 为例:ContribWiring,不是DesktopController) - 如果改了桥接层,
toolCallId从 Card Component → Event Bridge → Security Gate 完整传递 - Bridge Handler 中对
tools/call做了工具名白名单校验(registered is None or name not in registered,不依赖空列表的 falsy 短路) - Bridge Handler 中对
resources/read做了ui://前缀校验 -
tools/call代理路径在卡片参数缺少session_id时自动注入 - UI State Cache 同时写内存 Map 和
localStorage;读取时内存未命中回退localStorage -
requestMcpAppUserMessage有 per-card debounce,不会在紧循环中无限触发模型回合 - 单元测试通过(前端 + 后端)
- 在应用中实际渲染一张 MCP Apps 卡片,确认 iframe 显示、点击按钮能触发工具调用、卡片内数据完整(非空白)
- 开发模式下触发 HMR 重载后,已渲染的卡片仍然存活
- MCP 客户端声明了
mimeTypes: ['text/html;profile=mcp-app'](不只是扩展键) -
ui/initialize响应中的lastToolResult是CallToolResult形状对象(含.structuredContent属性),卡片 React 应用内被消费渲染(非空白表单) -
readSessionId优先从meta.lastToolResult.structuredContent.session_id读取,回退到 content 文本解析 - CSP
img-src包含https:以放行外部 CDN 图片 -
ui/update-model-context上下文暂存到_stagedContextMap,在下次ui/message时前置注入并清除
Reference Implementation: Hermes Desktop
Hermes Agent 桌面端是本文描述架构的参考实现。以下是角色→文件映射:
| 角色 | 文件 | 语言 |
|---|---|---|
| Extraction Layer | tools/mcp_tool.py |
Python |
| Event Bridge | tui_gateway/server.py |
Python |
| Renderer | apps/desktop/src/components/assistant-ui/thread/message-parts.tsx |
TSX |
| Card Component | apps/desktop/src/components/assistant-ui/mcp-app-card.tsx |
TSX |
| UI State Cache | apps/desktop/src/lib/chat-messages.ts |
TS |
| Model Communication Channel | apps/desktop/src/store/mcp-app.ts |
TS |
| Session Controller | apps/desktop/src/app/contrib/wiring.tsx |
TSX |
测试命令(Hermes 仓库根目录):
cd apps/desktop && npx vitest run src/components/assistant-ui/mcp-app-card.test.tsx src/store/mcp-app-security.test.ts
python -m pytest tests/tools/test_mcp_apps_ui.py -x
在其他 Agent 应用中实现 MCP Apps Host 时,按以上角色创建对应模块,分层思路和安全边界直接复用。
Reference Implementation: DeepSeek Harness (DSH)
DeepSeek Harness 是一个 Cordis 插件化运行时的 Agent 应用。MCP Apps Host 通过插件包 @deepseek-ai/dsh-mcp-apps-host 实现,不修改 DSH 核心代码。
| 角色 | 文件 | 语言 |
|---|---|---|
| Extraction Layer + Bridge Handler | packages/mcp/mcp-apps-host/src/index.ts |
TypeScript |
| Card Component | packages/mcp/mcp-apps-host/src/client/McpAppCard.tsx |
TSX |
| Slot Registration + sendUserMessage | packages/mcp/mcp-apps-host/src/client/index.ts |
TypeScript |
| Profile Overlay (连接配置) | mcp-apps-utp.patch.yml |
YAML |
DSH 特有的扩展点:
ctx.webServer.register(): 注册 HTTP 路由 (/mcp-apps/<server>/bridge),用于 postMessage 桥接presentationMeta()函数: 从工具执行结果的_meta.ui投射到result.meta.mcpApp,流经ToolResultNode.meta到客户端 React 组件tool.call.toolviewslot: 按mcp__<serverName>__<toolName>key 注册自定义 React 视图
DSH 适配中踩过的坑:#14(mimeTypes 未声明)、#15(工具结果未在卡片内渲染)、#16(readSessionId 未用 structuredContent 路径)、#17(CSP img-src 阻断外部图片)、#18(ui/update-model-context 未实现)
