对话端遵循 MCP io.modelcontextprotocol/ui 协议扩展(ext-apps,specification 2026-01-26)渲染交互式界面。
二、1.1 UI Resource
MCP Apps 的交互式界面是一份 HTML,作为 MCP Resource 暴露:
·URI:建议使用 ui:// 前缀(与规范一致),例如 ui://my-server/dashboard;该 URI 由平台侧从 tool 的 _meta.ui.resourceUri 取到并下发到对话端,用于拉取 HTML。
·MIME 必须 是 text/html;profile=mcp-app(对话端按 mime_type 以 text/html 开头选取 HTML 资源,避免误取同响应里的非 HTML 资源)。
·资源的 _meta.ui.csp 用于声明 CSP 域名白名单;平台侧会映射为 ReadMCPResource 响应的 resource_meta,对话端据此生成内层 iframe 的 CSP。
三、1.2 App Tool
让 Host 知道某个 tool 关联了交互式界面,需要在 tool 定义里加 _meta.ui.resourceUri 指向 UI Resource。模型调用这个 tool 时,Host 自动渲染对应交互式界面。
四、1.3 Sandbox iframe
交互式界面运行在隔离的 iframe 中:界面拿不到主页面的数据,主页面也不会被界面影响。
·代理页与业务域天然跨域,配合 iframe sandbox 与 postMessage 的 targetOrigin 锁定,实现 UI 隔离与权限边界;
·代理页只转发消息、不理解语义,真正的资源拉取与工具调用由业务页 Host 完成后端调用;
·iframe sandbox 属性:allow-scripts allow-same-origin allow-forms allow-popups;
·CSP 由资源 resource_meta.ui.csp 生成并注入内层 iframe;
·HTML 含 <script type="module"> 时改用 srcdoc 注入(与代理页同源,module 可正常执行),普通 script 走 document.write 注入。
五、1.4 消息通信
界面 HTML 通过 ext-apps 库拿到一个 App 实例,与 Host 之间走 JSON-RPC over postMessage。Host 与界面之间的通信都是异步消息,没有任何 DOM/全局变量泄漏到主页面。
六、二、接入步骤
1. Server 端注册 tool:tool 定义里加 _meta.ui.resourceUri = 'ui:///'。
2. Server 端注册 resource handler:返回 mimeType: 'text/html;profile=mcp-app',text 字段塞 HTML。
3. Server 端 tool 实现:在 CallToolResult 里也带 _meta.ui.resourceUri,并把交互式界面需要的数据放在 structuredContent。
4. HTML 模板:head 用 color-scheme: light dark + light-dark() 兜底;script 里 new App({ autoResize: true }).connect(),挂 app.ontoolresult 处理工具结果、app.ontoolinput 处理数据未就绪场景。
5. 联调:在支持的入口触发该 tool,看交互式界面渲染与反向调用效果。
七、三、完整的加载 & 交互流程
以"用户输入 查看有哪些 todo → 模型调用 show_todos → 用户在交互式界面里点添加"为例:
要点:
1. HTML 由 Host 预取:拿到 ResourceUri 后 Host 主动拉取 HTML(与 iframe 装载并发),两者都就绪时才把 HTML 下发给代理页注入,交互式界面启动后无需再回拉资源。
2. toolResult 由 Host 推送、不是交互式界面拉取:握手完成后 Host 主动重放工具结果,保证交互式界面状态与对话上下文同步。
3. 数据可能分步到达:structuredContent 未就绪时先推 tool-input,补齐后再补推 tool-result,交互式界面需幂等处理。
4. 反向工具调用不经过模型:由 Host 调后端接口,可在原对话结束之后的历史会话里继续发生;ThreadId / PluginId 由 Host 从会话上下文补齐,交互式界面无需感知。
5. isError: true 是业务失败(接口层仍为成功响应),界面需自行判断并给出提示。
八、四、Server 端接入示例
以下示例用于说明字段契约。服务端用 @modelcontextprotocol/sdk,_meta.ui 字段定义见 MCP Apps spec types。
const { Server } = require('@modelcontextprotocol/sdk/server/index.js');const { CallToolRequestSchema, ListToolsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema,} = require('@modelcontextprotocol/sdk/types.js');const UI_MIME = 'text/html;profile=mcp-app';const TODO_URI = 'ui://my-todo/list';const server = new Server( { name: 'my-todo', version: '0.1.0' }, { capabilities: { tools: {}, resources: {} } },);const todos = [];server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ { name: 'show_todos', description: '展示待办列表交互式界面', inputSchema: { type: 'object', properties: {} }, // 关键:声明 UI Resource,Host 见到此字段就渲染交互式界面 _meta: { ui: { resourceUri: TODO_URI } }, }, { name: 'add_todo', description: 'Add a new todo', inputSchema: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'], }, }, ],}));server.setRequestHandler(CallToolRequestSchema, async req => { if (req.params.name === 'show_todos') { return { content: [{ type: 'text', text: `${todos.length} todos` }], // structuredContent 会作为 toolResult 推送给交互式界面 structuredContent: { items: todos }, // 关键:tool result 也带 _meta.ui,让 Host 关联到交互式界面 _meta: { ui: { resourceUri: TODO_URI } }, }; } if (req.params.name === 'add_todo') { todos.push({ id: todos.length + 1, title: req.params.arguments.title }); return { content: [{ type: 'text', text: 'Added' }], structuredContent: { items: todos }, }; }});server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [{ uri: TODO_URI, name: 'todo-list', mimeType: UI_MIME }],}));server.setRequestHandler(ReadResourceRequestSchema, async req => { if (req.params.uri === TODO_URI) { return { contents: [{ uri: TODO_URI, text: HTML, // 见「五、App(HTML)开发」 mimeType: UI_MIME, _meta: { ui: { // CSP:声明交互式界面运行时需要的外部域名。平台侧会把它映射为 // ReadMCPResource 响应的 resource_meta,对话端据此生成内层 iframe 的 CSP。 // **未列出的域名将被浏览器拦截。** csp: { // 允许通过 ESM 远端引入 ext-apps(script-src / connect-src 都需要 esm.sh); // 离线/内网改为自托管 SDK 时,把 esm.sh 换成自托管域名 resourceDomains: ['https://esm.sh'], // 如交互式界面还会调外部 API(fetch / WebSocket),把对应域名加这里 connectDomains: ['https://esm.sh'], }, }, }, }], }; }});
structuredContent 是交互式界面的数据来源,Host 在握手完成后把完整 CallToolResult 推给交互式界面。
注意:structuredContent 为空、或只含 viewUUID / _meta 等标识字段时,被视为数据残缺:交互式界面先加载外壳,等 ToolResult 补齐后再补推一次 tool-result。因此交互式界面必须同时挂载 ontoolinput 与 ontoolresult,并按 _meta.viewUUID 做幂等去重。
十、5.1 HTML 模板
<!DOCTYPE html><html><head> <meta charset="utf-8" /> <style> /* 主题跟随宿主:light-dark() 兜底 + data-theme 显式锁定 */ :root { color-scheme: light dark; –bg: light-dark(#fff, #1e1e1e); –fg: light-dark(#1a1a1a, #e6e6e6); } html[data-theme="light"] { color-scheme: light } html[data-theme="dark"] { color-scheme: dark } body { margin: 0; padding: 12px; background: var(–bg); color: var(–fg); } </style></head><body> <ul id="list"></ul> <button id="add">添加</button> <script type="module"> // 远端 ESM 引入 ext-apps(自带依赖的 bundle,浏览器直接 import) // 锁版本到 1.x;生产环境建议改成具体 patch 版(如 @1.7.4) import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1/app-with-deps'; const app = new App({ name: 'todo-app', version: '1.0.0', autoResize: true, // 自动按内容上报 size-changed }); function render(items) { const list = document.getElementById('list'); list.textContent = ''; for (const t of items) { const li = document.createElement('li'); li.textContent = t.title; list.appendChild(li); } } // 数据主通道:Host 在握手后推送完整 CallToolResult(可能延后到达,需幂等) let lastUuid = ''; app.ontoolresult = (r) => { const uuid = (r && r._meta && r._meta.viewUUID) || ''; if (uuid && uuid === lastUuid) return; // 去重 lastUuid = uuid; if (r?.structuredContent?.items) render(r.structuredContent.items); }; // 数据未就绪时先收到 tool-input(仅含上下文标识),渲染骨架屏即可 app.ontoolinput = (input) => { console.log('[mcp-app] tool-input', input); // { toolInput: { thread_id, plugin_id, resource_uri } } }; // 主题:从 initialize 结果取 hostContext.theme(当前版本后续不再推送变更) function applyTheme(theme) { if (!theme) return; document.documentElement.setAttribute('data-theme', theme); document.documentElement.style.colorScheme = theme; } document.getElementById('add').onclick = async () => { // 反向调用同一插件下的工具:服务端按 plugin 工具列表校验 + CAM 鉴权 const res = await app.callServerTool({ name: 'add_todo', arguments: { title: `todo ${Date.now()}` }, }); if (res?.isError) { console.warn('调用失败', res); return; } if (res?.structuredContent?.items) render(res.structuredContent.items); }; await app.connect(); applyTheme(app.hostContext?.theme); </script></body></html>
十一、六、Host 侧 iframe 模板
以下为装载交互式界面的 iframe 实现模板。
十二、6.1 外层 iframe(业务页)
<iframe src="https://example.com/proxy" sandbox="allow-scripts allow-forms" referrerpolicy="no-referrer" title="代理页" style="width: 100%; height: 50px; border: 0"></iframe>
·src 指向自供的代理页,与业务页不同域(本地联调可用同源副本);
·iframe 必须有正常高度:height: 0 / display: none 会导致代理页不加载 → 收不到 sandbox-proxy-ready → HTML 永不注入(死锁)。初始给 50px 占位,随后按交互式界面上报的 size-changed 撑开,上限 600px,超出后交互式界面内部滚动;
·实例标识用 ResourceUri:同一条 mcp_app 会随 content.added / message.done / response.completed 多次更新,标识不稳定会导致 iframe 重建、重复拉取 HTML。
·Host 需要下发与应答:向代理页下发 sandbox-resource-ready(HTML + sandbox + CSP),并应答交互式界面上抛的 ui/initialize、tools/call 等 JSON-RPC 请求(完整时序见第三章)。
十三、6.2 代理页(sandbox 代理)
<!doctype html><html> <head> <meta charset="utf-8" /> <meta name="color-scheme" content="light dark" /> <title>MCP-App Proxy</title> <style> html, body { margin: 0; height: 100%; width: 100%; } body { display: flex; flex-direction: column; background: #fff; } iframe { border: 0; padding: 0; width: 100%; height: 100%; overflow: auto; /* 内容超过外层高度上限时内部滚动 */ } </style> </head> <body> <script> var PROXY_READY_NOTIFICATION = 'ui/notifications/sandbox-proxy-ready'; var RESOURCE_READY_NOTIFICATION = 'ui/notifications/sandbox-resource-ready'; var OWN_ORIGIN = location.origin; var hostOrigin = ''; // 握手后锁定的业务页 origin // 允许的业务页 origin 白名单(新增域名在此追加) var ALLOWED_ORIGINS = ['https://adp.cloud.tencent.com']; var inner = document.createElement('iframe'); inner.setAttribute('sandbox', 'allow-scripts allow-same-origin allow-forms'); document.body.appendChild(inner); function isAllowedOrigin (origin) { return !!origin && ALLOWED_ORIGINS.indexOf(origin) !== -1; } // 注入界面 HTML:module script 用 srcdoc(document.write 注入的 module 不执行) function renderHtml (html) { if (typeof html !== 'string') return; var isModuleScript = /<script[^>]*type\\s*=\\s*["']module["']/i.test(html); var doc = inner.contentDocument || (inner.contentWindow && inner.contentWindow.document); if (isModuleScript || !doc) { inner.srcdoc = html; } else { doc.open(); doc.write(html); doc.close(); } } // 承接 Host 下发的资源:设置权限 + CSP,再注入 HTML function handleResourceReady (params) { params = params || {}; if (typeof params.sandbox === 'string') inner.setAttribute('sandbox', params.sandbox); if (typeof params.allow === 'string') inner.setAttribute('allow', params.allow); if (typeof params.csp === 'string' && params.csp) inner.setAttribute('csp', params.csp); renderHtml(params.html); } window.addEventListener('message', function (event) { var data = event.data; // 方向 A:Host(业务页)→ 本代理页 if (event.source === window.parent) { if (!isAllowedOrigin(event.origin)) return; // 非白名单来源直接拒绝 hostOrigin = event.origin; if (data && data.method === RESOURCE_READY_NOTIFICATION) { handleResourceReady(data.params); // 本地消费,不下发给交互式界面 return; } // sandbox-* 前缀通知由代理页自身消费,不转发 if (data && typeof data.method === 'string' && data.method.indexOf('ui/notifications/sandbox-') === 0) return; // 其余 Host → 交互式界面的 JSON-RPC 透明转发 if (inner && inner.contentWindow) inner.contentWindow.postMessage(data, OWN_ORIGIN); return; } // 方向 B:交互式界面 → 本代理页 → Host(initialize / tools / call / size-changed 等原样上抛) if (event.source === (inner && inner.contentWindow)) { if (event.origin !== OWN_ORIGIN) return; if (hostOrigin) window.parent.postMessage(data, hostOrigin); // 不用 '*' } }); // 通知 Host:代理页已就绪,可以下发交互式界面资源 window.parent.postMessage( { jsonrpc: '2.0', method: PROXY_READY_NOTIFICATION, params: {} }, '*' ); </script> </body></html>
要点:
1. 只转发不理解:代理页不解析业务语义,ui/initialize、tools/call 等均由业务页 Host 处理;sandbox-* 前缀通知由代理页本地消费。
2. origin 双向锁定:入向校验业务域白名单(本地联调时可额外放行 localhost / 127.0.0.1),出向用握手锁定的 hostOrigin,不用 *;内层交互式界面与代理页同源,但与业务域跨域,因此对业务域仍隔离。
3. module script 用 srcdoc:document.write 注入的内联 <script type="module"> 不会执行,检测到 module 时改用 srcdoc(同源,module 可正常执行)。
十四、七、实时消息(WS)链路对接
Host 通过 WebSocket 接收实时消息,mcp_app 作为消息的一个 Content 下推。下面是 Host(对话端)侧的对接方式。
十五、7.1 事件与消息结构
WS 下行事件字段为 PascalCase:
| 事件 | payload | 对话端处理 |
| content.added | { MessageId, ContentIndex, Content } | 预置该 Content(预热,此时可能只有 ResourceUri,ToolResult 未到位) |
| message.added / message.done | { Message: { MessageId, Type, Contents } } | 按 MessageId 合并到对应消息;message.done 会补齐 ToolResult |
| response.completed | { Response: { RecordId, Messages } } | 全量重放本轮消息 |
mcp_app Content 的结构:
{ "Type": "mcp_app", "McpApp": { "ThreadId": "thread_xxx", "PluginId": "plugin_xxx", "ResourceUri": "ui://my-todo/list", "ToolResult": "{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"3 todos\\"}],\\"structuredContent\\":{\\"items\\":[{\\"id\\":1,\\"title\\":\\"写周报\\"}]},\\"_meta\\":{\\"viewUUID\\":\\"v1\\"}}" }}
·ToolResult 是完整 CallToolResult 的 JSON 字符串,可随 message.done 才补齐;
·ThreadId / PluginId / ResourceUri 是后续拉取 HTML 与调用工具的必需参数。
十六、7.2 一个实时链路的事件序列
// 1) 预热:只有资源标识,无数据 → 交互式界面先加载外壳{ "Type": "content.added", "MessageId": "rpl_001", "ContentIndex": 0, "Content": { "Type": "mcp_app", "McpApp": { "ThreadId": "thread_xxx", "PluginId": "plugin_xxx", "ResourceUri": "ui://my-todo/list" } } }// 2) 工具执行完:补 ToolResult → 对话端补推 ui/notifications/tool-result{ "Type": "message.done", "Message": { "MessageId": "rpl_001", "Type": "tool_call", "Contents": [ { "Type": "mcp_app", "McpApp": { "ThreadId": "thread_xxx", "PluginId": "plugin_xxx", "ResourceUri": "ui://my-todo/list", "ToolResult": "{…structuredContent…}" } } ] } }// 3) 本轮结束:全量重放(同一条消息再次到达){ "Type": "response.completed", "Response": { "RecordId": "rec_001", "Messages": [ … ] } }
十七、7.3 对话端消费要点
1. 两种消息承载:mcp_app 既可挂在 tool_call 消息的 Contents 里,也可挂在 reply 消息里,两者渲染逻辑一致;只在开头列出的入口生效。
2. 实例标识用 ResourceUri:同一条 mcp_app 消息会经历 content.added → message.done → response.completed 多次更新,若标识依赖 MessageId,会导致 iframe 重建并重复拉取 HTML。
3. HTML 按 {ThreadId}:{PluginId}:{Uri} 缓存:Host 会缓存拉取结果,同一资源在多次重放中只请求一次。
4. 结果按 viewUUID 幂等:ToolResult 补齐后会再补推一次 tool-result,交互式界面侧需按 _meta.viewUUID 去重(见第四节注意事项)。
十九、(1)ReadMCPResource —— 拉取界面 HTML
Host 拿到 ResourceUri 后立即请求;HTML 到手后等代理页就绪再下发,交互式界面启动后无需再回拉。
import jsonreq = models.ReadMCPResourceRequest()req.ThreadId = thread_id # 来自 mcp_app 内容req.PluginId = plugin_idreq.Uri = "ui://my-todo/list"resp = client.ReadMCPResource(req)# 取 HTML:只认 MimeType 以 text/html 开头的资源html_content = next(c for c in resp.Contents if c.MimeType.startswith("text/html"))html = html_content.Textresource_meta = resp.ResourceMeta # 用于生成内层 iframe 的 CSP
{ "Contents": [ { "Uri": "ui://my-todo/list", "MimeType": "text/html;profile=mcp-app", "Text": "<!DOCTYPE html>…" } ], "ResourceMeta": "{\\"ui\\":{\\"csp\\":{\\"connectDomains\\":[\\"https://esm.sh\\"],\\"resourceDomains\\":[\\"https://esm.sh\\"]}}}"}
·ThreadId / PluginId 缺失时 Host 直接判失败,不发请求;
·只取 MimeType 以 text/html 开头的资源,避免误取同响应里的非 HTML 资源;
·交互式界面的 HTML 由 {ThreadId}:{PluginId}:{Uri} 唯一决定,可自行缓存,多次重放无需重复请求。
20、(2)CallMCPTool —— 调用 MCP 工具
用于交互式界面反向调用工具,也可由接入方在服务端主动发起(该调用不经过模型,可在原对话结束之后的历史会话里继续发生)。
req = models.CallMCPToolRequest()req.ThreadId = thread_idreq.PluginId = plugin_idreq.ToolName = "add_todo"req.Arguments = json.dumps({"title": "写周报"}) # Arguments 为 JSON 字符串resp = client.CallMCPTool(req)result = json.loads(resp.Result) # 完整 CallToolResult
{ "Result": "{\\"content\\":[{\\"type\\":\\"text\\",\\"text\\":\\"Added\\"}],\\"structuredContent\\":{\\"items\\":[]}}"}
·Host 把 Result 解析后按原 JSON-RPC id 回给交互式界面,结构原样透传(含 structuredContent / isError / _meta);
·ThreadId / PluginId 缺失 → 回错误码 -32001;接口异常 → 回 -32000,server 不会被实际调用。
21、(3)DescribeConversationMessageList —— 历史消息与交互式界面重建
刷新页面、重新进入会话等场景走历史消息接口,mcp_app 内容与其 ToolResult 一并返回,用于重建交互式界面。
req = models.DescribeConversationMessageListRequest()req.ConversationId = conversation_idreq.RecordId = record_id or "" # 翻页游标,空表示从最新开始req.Limit = 50 # 单次最多 50resp = client.DescribeConversationMessageList(req)
{ "MessageList": [ { "MessageId": "rpl_001", "Type": "tool_call", "Contents": [ { "Type": "mcp_app", "McpApp": { "ThreadId": "thread_xxx", "PluginId": "plugin_xxx", "ResourceUri": "ui://my-todo/list", "ToolResult": "{\\"structuredContent\\":{\\"items\\":[{\\"id\\":1,\\"title\\":\\"写周报\\"}]},\\"_meta\\":{\\"viewUUID\\":\\"v1\\"}}" } } ] } ], "FirstRecordId": "rec_001", "LastRecordId": "rec_050", "HasMoreBefore": true, "HasMoreAfter": false}
历史场景要点:
1. 数据已内联:历史消息里 ToolResult 是完整的 CallToolResult,握手完成后直接重放 ui/notifications/tool-result,交互式界面立即渲染,无需等实时补齐。
2. 仍需幂等:ToolResult 可能在翻页/重放中重复到达,交互式界面按 _meta.viewUUID 去重。
3. 与实时链路同构:历史消息与实时推送携带的 mcp_app 结构一致(均含 ThreadId / PluginId / ResourceUri / ToolResult),渲染逻辑复用一套。
23、8.1 Sandbox 隔离
·第三方 HTML 跑在隔离的 sandbox iframe 中:业务页 → 代理页 → 界面。
·iframe sandbox 属性:allow-scripts allow-same-origin allow-forms allow-popups。
·资源 _meta.ui.csp 生成 CSP 后注入内层 iframe,未声明的域名一律被浏览器拦截。
·代理页只接受白名单业务域的消息;自建宿主或使用私有化 / 自定义域名时,需把业务域名加入代理页白名单。
24、8.2 授权机制
反向 tools/call 无前端授权弹窗,改为服务端校验:
| 校验 | 说明 |
| 能力边界 | ToolName 必须属于当前 PluginId 的工具列表,不在列表内直接拒绝。 |
| 归属校验 | 后端按 ThreadId 校验会话归属,不一致直接拒绝。 |
| 鉴权 | 由服务端用对应 ToolId 装配 CAM 鉴权。 |
| 身份字段 | 应用 / 用户 / Agent 等身份信息由后端从会话态补齐,前端不接受也不提交。 |
ThreadId / PluginId 缺失时,Host 直接拒绝调用并回错误码 -32001,server 不会被实际调用。
25、8.3 外链与日志
·ui/open-link:Host 用 window.open 打开,当前版本不做 scheme 校验,建议只传 http:// 或 https://。
·notifications/message(sendLog):Host 只上抛给宿主页面,当前未做界面展示。
26、九、机制小结
Server 契约:UI Resource(text/html;profile=mcp-app) + tool _meta.ui.resourceUri + CallToolResult.structuredContent + resource_meta.ui.csp ↓对话触发:模型调该 tool → 消息 Content.Type = 'mcp_app' ↓ (WS:content.added 预热 → message.done 补 ToolResult → response.completed 重放)Host 渲染:ReadMCPResource → 双层沙箱 iframe → ui/initialize → 重放 tool-input / tool-result ↓反向交互:交互式界面 tools/call → CallMCPTool → 结果回传交互式界面
| 环节 | 责任方 | 关键点 |
| 声明 UI Resource | MCP Server | MIME = text/html;profile=mcp-app |
| 关联 tool 与交互式界面 | MCP Server | _meta.ui.resourceUri |
| 提供渲染数据 | MCP Server | CallToolResult.structuredContent |
| 声明 CSP 域名 | MCP Server | resource_meta.ui.csp.{resourceDomains,connectDomains} |
| 交互式界面开发 | MCP Server | ext-apps SDK 1.x;同时处理 ontoolinput 与 ontoolresult 并按 viewUUID 幂等 |
| 渲染与鉴权 | 对话端 | 双层沙箱 iframe;tools/call 服务端按 plugin 工具列表校验 + CAM 鉴权 |
28、协议与 SDK
·MCP Apps spec & 参考实现:modelcontextprotocol/ext-apps(npm:@modelcontextprotocol/ext-apps)
·MCP Apps protocol types:spec.types.ts
·MCP TypeScript SDK:modelcontextprotocol/typescript-sdk(npm:@modelcontextprotocol/sdk)
·MCP 主协议规范:Model Context Protocol
·示例 server:ext-apps/examples
29、Web 标准
·light-dark() CSS function
·Window.postMessage()
·JSON-RPC 2.0 规范
·Content Security Policy
·<iframe sandbox> 属性