show_weather,对话气泡里直接渲染了一个天气仪表盘:可以点选城市牙牌切换、底部展示当前选中城市的温度与天气状况。show_todos,气泡里出现了一个 Todo 列表 widget:可以勾选已完成、在输入框里直接添加新条目。
场景 | 纯文本方案的问题 |
让用户确认一组配置 | 模型只能把选项全列出来让用户复述选哪个,对话冗长 |
可视化地图 / 图表 / 3D / PDF | 文本描述损耗严重,"东京坐标 35.6°N, 139.7°E"远不如一张地图 |
多步表单(预算分配、参数微调) | 来回打字让模型改参数,体验割裂 |
持续状态的 Widget(Todo、播放器) | 每次操作都得让模型重新"打印一次列表" |
io.modelcontextprotocol/ui 协议扩展(spec 2026-01-26):让 MCP server 把 HTML widget 作为资源暴露,由 host 在沙箱中渲染,并通过结构化消息双向通信。add_todo),结果再推回 widget 局部刷新。_meta.ui.resourceUri → 这个工具关联了 widget。text/html;profile=mcp-app → 这个资源就是 widget 的 HTML 源码。src 指向 host 自供的 sandbox proxy 页面(与主页面不同 origin),HTML 由 host postMessage 注入sandbox 属性收紧到只允许执行脚本和提交表单<meta http-equiv="Content-Security-Policy"> 由 server 在资源元信息里声明,host 自动注入@modelcontextprotocol/ext-apps(GitHub)客户端库与 host 通信——所有跨边界调用都是 JSON-RPC over postMessage,没有任何 DOM/全局变量泄漏到主页面。方向 | 典型用途 | 是否需要授权 |
Host → Widget:推送工具入参 / 工具结果 / 主题切换 / 显示模式变化 | widget 实时更新视图 | 不涉及 |
Widget → Host:调用同一 server 的其他工具 | 点按钮触发 add_todo 之类 | 每次弹框授权 |
Widget → Host:读取 server 资源 | widget 拉取额外只读数据 | 无需(视为安全 GET) |
Widget → Host:打开外链 / 触发下载 / 回写消息 / 注入 model context | 把 widget 操作"喂"给主对话 | 链接限 http(s);其他无需 |
{ isError: true },server 根本不会被实际调用。color-scheme: light dark + light-dark(),首屏跟系统主题,避免白屏闪烁host-context-changed 推送 theme 后,widget 在 <html> 上写 data-theme 显式锁定,覆盖 CSS 自动取值查看 Tokyo 的天气 → 模型调用 show_weather 工具 → 用户在 widget 里改选 San Francisco"为例:用户 Web UI Host Widget(iframe) MCP Server│ │ │ │ ││ 输入"查看Tokyo天气" │ │ │ ││──────────────────────▶│ │ │ ││ │ 把 prompt 转给模型 │ │ ││ │───────────────────▶│ │ ││ │ │ 模型决定调 show_weather│ ││ │ │─────────────────────────────────────────▶ ││ │ │ │ server 执行 ││ │ │ │ 回 toolResult ││ │ │◀──────────────────────────────────────────││ │ │ 看到 _meta.ui │ ││ │ │ 预取 widget HTML │ ││ │ │──────────────────────────────────────────▶││ │ │◀──────────────────────────────────────────││ │ │ 在工具调用消息里附带 │ ││ │ │ widget 元信息 │ ││ │◀───────────────────│ │ ││ │ 渲染 sandbox iframe│ │ ││ │ 注入 HTML + CSP │ │ ││ │──────────────────────────────────────────▶│ ││ │ │ │ initialize ││ │ │◀─────────────────────│ ││ │ │ 推送 toolResult │ ││ │ │─────────────────────▶│ ││ │ │ 推送 hostContext.theme │ ││ │ │─────────────────────▶│ ││ 看到天气仪表盘 ✓ │ │ │ 应用主题、渲染 UI ││ │ │ │ ││ 点击 "San Francisco" │ │ │ ││───────────────────────────────────────────────────────────────────▶│ ││ │ │ │ 调 tools/call ││ │ │◀──────────────────────│ get_weather(SF) ││ │ ⚠ 弹框:是否允许? │ │ ││ 点 "允许" │ │ │ ││──────────────────────▶│ │ │ ││ │ │ 转发给 server │ ││ │ │─────────────────────────────────────────▶ ││ │ │◀──────────────────────────────────────────││ │ │ 把结果回给 widget │ ││ │ │─────────────────────▶│ ││ 看到 SF 的天气 ✓ │ │ │ 局部刷新 │
host-context-changed,widget 自己换样式。tools/call 不会直达 server,会被 host 拦截弹授权;只有读资源是免授权的。场景 | 是否生效 |
Web UI( --serve 模式打开浏览器) | ✔ |
IDE 插件中嵌入的 Web UI(VSCode / Fusion / JetBrains) | ✔ |
终端 TUI( codebuddy 默认交互模式) | ✖ 自动文本降级 |
Print 模式( -p) | ✖ 自动文本降级 |
content 文本本来就是给非可视化场景兜底的。ui:// 开头,例如 ui://my-server/dashboardtext/html;profile=mcp-app_meta.ui 可声明 CSP / 权限 / 边框偏好_meta.ui.resourceUri 指向 UI Resource。模型调用这个 tool 时,host 自动渲染对应 widget。sandbox_proxy.html 中转,只允许 allow-scripts allow-same-origin allow-forms。CSP 由资源 _meta.ui.csp 控制。@modelcontextprotocol/ext-apps 库(推荐远端 ESM import,也支持自托管 ESM 或 IIFE 内联)拿到一个 App 实例,与 host 之间走 JSON-RPC over postMessage。所有 host ↔ guest 通信都是异步消息。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: 'Show interactive todo list widget',inputSchema: { type: 'object', properties: {} },// 关键:声明 UI Resource,host 见到此字段就渲染 widget_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 推送给 widgetstructuredContent: { items: todos },// 关键:tool result 也带 _meta.ui,让 host 关联到 widget_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, // 见下面 HTML 模板mimeType: UI_MIME,_meta: {ui: {// CSP:声明 widget 运行时需要的外部域名,host 会注入到 iframe 的// <meta http-equiv="Content-Security-Policy">。**未列出的域名将被浏览器拦截。**csp: {// 允许通过 ESM 远端引入 ext-apps(script-src / connect-src 都需要 esm.sh)resourceDomains: ['https://esm.sh'],// 如 widget 还会调外部 API(fetch / WebSocket),把对应域名加这里connectDomains: ['https://esm.sh'],},permissions: {}, // 默认 sandbox:allow-scripts allow-same-origin allow-formsprefersBorder: true, // host 给 iframe 加 1px 描边,便于视觉区分},},}],};}});
<!DOCTYPE html><html><head><meta charset="utf-8" /><!--CSP:与资源 _meta.ui.csp 中声明的域名保持一致。host 会按 _meta.ui.csp 自动注入 script-src / connect-src 的白名单,但写在 HTML 里的 <meta> 优先生效,建议同步声明,方便本地直接预览。--><meta http-equiv="Content-Security-Policy"content="default-src 'self' 'unsafe-inline';script-src 'self' 'unsafe-inline' https://esm.sh;connect-src 'self' https://esm.sh;img-src 'self' data: blob:;style-src 'self' 'unsafe-inline';"><style>/* 主题双层适配(详见“主题适配”一节) */: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><script type="module">// 远端 ESM 引入 ext-apps(自带依赖的 bundle,浏览器直接 import)// 锁版本到 1.x 以避免上游 breaking change;生产环境建议改成具体 patch 版(如 @1.7.4)import { App } from 'https://esm.sh/@modelcontextprotocol/ext-apps@1/app-with-deps';const app = new App({name: 'todo-widget',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);}}// 模型调 show_todos 时把 toolResult 推送过来app.ontoolresult = (r) => {if (r?.structuredContent?.items) render(r.structuredContent.items);};// 主题:拿到 hostContext.theme 后写 data-theme + style.colorSchemefunction applyTheme(theme) {if (!theme) return;document.documentElement.setAttribute('data-theme', theme);document.documentElement.style.colorScheme = theme;}app.onhostcontextchanged = (ctx) => applyTheme(ctx?.theme);await app.connect();applyTheme(app.hostContext?.theme);</script></body></html>
app-with-deps 子路径是 ext-apps 自带依赖的浏览器友好 bundle,无需 bundler,直接由 esm.sh CDN 分发https://esm.sh/@modelcontextprotocol/ext-apps@1 也能用,但 esm.sh 会代为解析 peerDependencies,多一次 RTTnode_modules/@modelcontextprotocol/ext-apps/dist/src/app-with-deps.js 复制到自己 server 的静态目录,HTML 里改成 import { App } from '/static/app-with-deps.js',CSP 的 script-src / connect-src 把 esm.sh 替换成 'self'app.iife.js 全文塞进 <script>)—— HTML 体积大,命中 256 KB 阈值后 host 会退化为传 URI、首屏多一次 RTT{"mcpServers": {"my-todo": {"command": "node","args": ["/path/to/server.js"]}}}
{"mcpServers": {"my-todo": {"type": "http","url": "http://127.0.0.1:8801/mcp"}}}
@modelcontextprotocol/ext-apps 库暴露的 app.* 方法调 host,下表给出协议方法、对应的 app.* 调用入口、host 是否支持及关键行为。协议方法 | 库方法( app.*) | 用途 | host 是否支持 | 关键行为 |
tools/call | app.callServerTool(params) | 反向调用本 server 的其他 tool | ✔ | 默认弹框授权(host 走 _codebuddy.ai/mcpUiCallTool),用户拒绝则回 { isError: true };-y / BypassPermissions 或本 session 选过"始终允许"时直通 |
resources/read | app.readServerResource(params) | 反向读 server 资源 | ✔ | 只读,无需授权 |
resources/list | app.listServerResources(params?) | 列出 server 资源 | ✔ | 转发到 server |
tools/list / prompts/list / resources/templates/list | 通用 app.request({ method, params }) | 列出 server 工具 / prompt / 资源模板 | ✔ | ext-apps 未提供专用 wrapper,自行用基类 request() |
sampling/createMessage | app.createSamplingMessage(params) | 让 host 帮忙调一次模型 | ✔ | 走 host 的模型配置 |
ui/open-link | app.openLink({ url }) | 在 host 浏览器打开 URL | ✔ | 仅放行 http:// / https://,其他 scheme 静默拒绝 |
ui/message | app.sendMessage({ role, content, _meta? }) | 把消息回写到 host 对话 | ✔ | 支持 text / image / text+image 混合 content。**默认 _meta['codebuddy.ai/sendMessageMode'] = 'send'**:注入主对话作为用户气泡并立即触发 agent 响应(等价于用户手动按发送);置为 'fill' 时只回填到输入框(text 进 textarea,image 累加到 ImageAttachment)等用户确认后再发,不触发 agent |
ui/download-file | app.downloadFile({ ... }) | 触发浏览器下载 | ✔ | 纯前端 Blob + <a download>,host 后端不参与 |
ui/update-model-context | app.updateModelContext({ context }) | 给 agent 注入新 context | ✔ | 经 ACP 写入 system reminder,下次模型调用看见 |
ui/request-display-mode | app.requestDisplayMode({ mode }) | 请求切 inline / fullscreen / pip | ✔ | |
ui/notifications/size-changed | app.sendSizeChanged({ height, width? }) | 上报 widget 内容尺寸 | ✔ | 与 autoResize: true 等价,host 据此撑开 inline 容器避免截断 |
ui/notifications/request-teardown | app.requestTeardown() | 主动通知 host 资源已回收 | ✔ | 经 ACP 通知后端清理 |
notifications/message(log) | app.sendLog({ level, logger, data }) | 打日志 | ✔ | 转发到 host devtools console,前缀 [McpUi guest:<logger>];level 支持 debug / info / notice / warning / error / critical / alert / emergency |
app.on* 系列回调接收。通知 | 触发时机 | widget 接收方式 | 内容 |
ui/notifications/sandbox-resource-ready | host 装载 widget HTML 到 inner iframe | 库内自动处理,widget 无感 | { html, sandbox?, csp?, permissions? } |
ui/notifications/host-context-changed | host 主题切换 / displayMode 变化 | app.onhostcontextchanged = (ctx) => ... | partial hostContext,仅含变化字段 |
ui/notifications/tool-input | 模型调 tool 时把入参推给 widget | app.ontoolinput = (input) => ... | tool input 对象 |
ui/notifications/tool-result | 模型调 tool 完成后把结果推给 widget | app.ontoolresult = (result) => ... | CallToolResult,含 structuredContent |
ui/notifications/tool-cancelled | tool 被取消 | app.ontoolcancelled = () => ... | 无 payload |
McpUiHostContext 字段(spec 允许的字段全部下发):字段 | 类型 | 何时变化 | 备注 |
theme | 'light'|'dark' | host 用户切主题 | 见“主题适配”一节 |
displayMode | 'inline'|'fullscreen'|'pip' | guest 调 requestDisplayMode 或 host 用户切换布局 | widget 用于决定布局 |
availableDisplayModes | ('inline'|'fullscreen'|'pip')[] | 不变 | host 固定 ['inline', 'fullscreen', 'pip'] |
styles | McpUiHostStyles | 主题切换时 variables 子对象更新 | 把 host 解析后的 CSS 变量集( --cb-* 一族)下发,方便 widget useHostStyles() 直接套样式 |
containerDimensions | { width, height } 或 { maxWidth, maxHeight } | viewport / displayMode 变化 | inline 给 max 上限(外层 contents 拿不到精确父盒);fullscreen / pip 给精确尺寸 |
safeAreaInsets | { top, right, bottom, left } | 不变 | 桌面 / Web 全 0;移动端真实接入时按 env(safe-area-inset-*) 填 |
deviceCapabilities | { pointer, hover, ... } | 不变 | 由 (pointer: fine) / (hover: hover) matchMedia 算出 |
locale | BCP 47 字符串 | 不变 | 取自 navigator.language |
timeZone | IANA 名 | 不变 | 取自 Intl.DateTimeFormat().resolvedOptions().timeZone |
userAgent / platform | 字符串 | 不变 | 直接透传浏览器值 |
toolInfo | { name, description?, ... } | 每次新工具结果到达 | 让 widget 知道当前关联的是哪个 tool |
_meta.ui.resourceUri = 'ui://<your-server>/<id>'mimeType: 'text/html;profile=mcp-app',text 字段塞 HTMLCallToolResult 里也带 _meta.ui.resourceUri,并把 widget 需要的数据放在 structuredContentcolor-scheme: light dark + light-dark() 兜底;script 里 new App({ autoResize: true }).connect(),挂 app.onhostcontextchanged 处理主题、app.ontoolresult 处理 tool 结果sandbox_proxy.html,与主页面不同 origin)allow-scripts allow-same-origin allow-forms(host 可经资源 _meta.ui.permissions 调整)_meta.ui.csp 注入到 iframe 的 <meta http-equiv="Content-Security-Policy">tools/call)tools/call 默认弹框授权,但有两条短路路径会跳过弹窗:-y / BypassPermissions 启动模式:用户已在启动时显式声明"全程跳过权限",所有工具(包括 MCP Apps 反向调用)一律自动放行。(server, tool) 时直接放行;/clear 或重启后失效。Default / AcceptEdits / Plan 三种模式仍强制弹窗——用户没有显式声明无脑放行,第三方 widget 反向调用必须当面确认。授权拒绝时,widget 拿到的是 { isError: true },server 不会被实际调用。resources/read:只读,无需授权(视为安全的 GET)ui/open-link:只放行 http:// / https://,避免 javascript: / data: 等危险 schemeui/download-file:纯前端 Blob + <a download>,host 后端不参与AcceptEdits 之类按工具类型自动放行的 PermissionMode 不覆盖 MCP 工具,因为 host 无法静态识别一个第三方 MCP 工具是"改本地文件"还是"调远端 API/扣费"。如果您想让某些信任的 MCP 工具不再每次都弹,用 allow 规则显式声明。// ~/.codebuddy/settings.json{"permissions": {"allow": ["mcp__my-todo", // 整个 server 的所有工具放行"mcp__github__list_issues", // 仅放行 github server 的 list_issues"mcp__github__get_pr_diff"],"deny": ["mcp__github__delete_repo" // 即便 server 整体放行,也能精准 deny 单个工具]}}
mcp__<server> — 该 server 下所有工具放行mcp__<server>__<tool> — 仅该工具放行deny 优先级高于 allow,可以"放行整个 server,单独拉黑某个危险工具"codebuddy --allowed-tools "mcp__my-todo,mcp__github__list_issues" "..."
/clear 或重启失效。想持久化用上面的 settings 方案。permissions.allow / --allowed-tools 规则只对模型主动调用 MCP 工具生效,不影响 MCP Apps widget 的反向调用。 widget 反向调用走独立的沙箱审批通道,只识别两条短路:-y / BypassPermissions 启动模式,或上一次在反向调用弹窗里选过"始终允许"(写入当前 session,/clear 后失效)。这样设计是为了避免"为了让模型用某个 MCP 工具配的 allow 规则,被一个第三方 widget 顺手拿来无声调用"。resourceUri,guest 端经 onReadResource 回拉@mcp-ui/client@7.1.1 的 AppRenderer 在 bridge 构造时不接受 hostContext 初值,guest 首次 ui/initialize 拿到的 hostContext 是 {}。后续 host 经 host-context-changed 推送主题。所以 widget 必须双层兜底::root {color-scheme: light dark;--bg: light-dark(#fff, #1e1e1e);--fg: light-dark(#1a1a1a, #e6e6e6);--border: light-dark(#ddd, #3a3a3a);}/* 当 JS 锁定 data-theme 后,覆盖 light-dark() 自动取值 */html[data-theme="light"] { color-scheme: light }html[data-theme="dark"] { color-scheme: dark }body { background: var(--bg); color: var(--fg); }
host-context-changed 推送 theme 后,写 data-theme + style.colorScheme 显式锁定。function applyTheme(theme) {if (!theme) return; // 没拿到就别动 DOM,让 CSS light-dark() 兜底接管document.documentElement.setAttribute('data-theme', theme);document.documentElement.style.colorScheme = theme;}applyTheme(app.hostContext?.theme);app.onhostcontextchanged = (ctx) => applyTheme(ctx?.theme);
@modelcontextprotocol/ext-apps 的 useHostStyles() React hook 等价(见 useHostStyles.d.ts 注释:*"Apply theme via color-scheme CSS property, enabling light-dark() CSS function support"*)。如果您用 React 写 widget,可直接调 useHostStyles(app, app?.getHostContext()),省去手写 CSS + applyTheme。app.log({ level: 'info', logger: 'my-widget', data: { foo: 'bar' } });
[McpUi guest:my-widget] 前缀打印。level 支持 debug / info / notice / warning / error / critical / alert / emergency,host 按级别映射到 console.debug / info / warn / error。console / Sources 都能用,与主页面 devtools 隔离。tool_call_update._meta['codebuddy.ai'].toolMetaData.mcpUi:acp 请求 → Response 里看 SSE 事件window.__DEBUG_ACP__ = true(如果代码里启用)@modelcontextprotocol/ext-apps)spec.types.ts(npm 包内 dist/src/spec.types.d.ts)@modelcontextprotocol/sdk)@mcp-ui/client · @mcp-ui/server)mcp.json / mcpServers 字段说明包 | 仓库示例目录 | 演示内容 |
CesiumJS 3D 地图(OpenStreetMap tiles) | ||
Three.js 3D 场景 | ||
PDF 查看器 | ||
视频播放 | ||
交互式预算分配 | ||
GLSL 着色器编辑器 | ||
ABC 乐谱渲染 | ||
Wikipedia 知识图谱 | ||
懒 OAuth 演示 |
文档反馈