用 goose 构建真正可用的 MCP Apps:渲染机制与五个实战技巧
【免费下载链接】goosean open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM项目地址: https://gitcode.com/GitHub_Trending/goose3/goose
MCP Apps 允许你直接在 AI Agent 的聊天界面里渲染交互式 UI——图表、结账表单、视频播放器都不再是一段枯燥文本。goose 正是最早采用并持续实现 MCP Apps(前身 MCP-UI)的开源 AI Agent 之一。读完本文,你将理解 MCP Apps 在 goose 中的渲染链路,掌握适配宿主环境(hostContext)、分层控制模型可见数据、处理加载与错误态、保持模型上下文同步以及用工具可见性守住高权限操作的五个核心实践,并结合 goose 仓库源码(reply_parts.rs、extension_manager.rs)看清其背后的实现逻辑。
MCP Apps 是什么:Agent 里的可交互界面
MCP Apps(官方 MCP 规范中用于交互式 UI 的扩展)允许你在任何支持 Model Context Protocol 的 Agent 中直接渲染交互式 UI。它并非「传统 Web 应用」的搬运:你的 UI 运行在一个你无法完全掌控的 Agent 里,与一个看不见用户交互的模型通信,并且要跨多个宿主(Host)保持原生体验。MCP Apps 起源于实验性项目 MCP-UI,在被 goose 等早期客户端采用后,由 MCP 维护者纳入了官方扩展,如今 goose、MCPJam、Claude、ChatGPT、Postman 等客户端均已支持。
在 goose 中,这项能力的入口与使用方式在 Using MCP Apps 中有完整说明:扩展可以通过 MCP Apps 提供可交互体验,App 既可以在独立沙箱窗口中启动(侧边栏Apps页、点击Launch),也可以由 goose 调用到带 UI 的工具时直接内嵌渲染在对话流中。需要提醒的是,goose 文档将其标记为experimental、仍在积极演进,行为可能随版本变化。
渲染总览:工具 → 资源 → iFrame
从高层看,支持 MCP Apps 的客户端通过 iFrame 加载你的 UI:你的 MCP App 暴露一个带有工具(tools)和资源(resources)的 MCP server;当客户端想加载 App 的 UI 时,先调用关联的 MCP 工具,再加载包含 HTML 的资源,最后把 HTML 装进 iFrame 显示在聊天界面里。
以 goose 渲染一份鸡尾酒配方为例,完整流程为:
- 你向 LLM 提问「给我一份玛格丽特配方」(Show me a margarita recipe);
- LLM 以正确参数调用
get-cocktail工具——该工具通过_meta.ui.resourceUri声明指向 HTML 资源的 UI 链接; - 客户端用该 URI 获取 MCP resource,其中包含该视图(View)的 HTML 内容;
- HTML 被加载进聊天界面内的 iFrame 中,渲染出鸡尾酒配方。
goose 的 Rust 端把这一链路落到了工具调用的后处理阶段:在 extension_manager.rs 中,get_tool_resource_uri负责从工具的meta.0.get("ui")里提取resourceUri字符串;而host_supports_mcp_apps(见 extension_manager.rs)通过宿主声明的 capabilities 判断当前客户端是否支持 MCP Apps(依据capabilities.mcpui或 host_info 中的mcpui_enabled())。当支持时,hydrate_mcp_app_attachment(见 extension_manager.rs)会在工具执行结果返回后,立即以该resourceUri调用read_resource预取 HTML 内容,构造出携带resource_uri、resource_result的GooseMcpAppToolAttachment,交给 UI 层注入 iFrame 完成渲染——若资源读取失败,错误也会被记录进read_error,供客户端展示异常态。
视角之外还有不少幕后工作:视图水合(hydration)、能力协商(capability negotiation)、CSP 策略等。goose 在 goose_apps/resource.rs 中就为 App 资源维护了CspMetadata、PermissionsMetadata、UiMetadata等元数据结构,并在 goose_apps/cache.rs 中通过McpAppCache管理已安装与内置(bundled)App 的持久化缓存,ui://协议由此获得资源寻址能力。建议通读 MCP Apps 官方规范了解完整实现。
Tip 1:适配宿主环境(Adapt to the Host Environment)
构建 MCP App 时,你要让它像 Agent 体验的自然组成部分,而不是生硬拼装上去的组件。视觉落差是破坏这种错觉最快的因素——用户在一个深色模式的 Agent 里启动 MCP App,App 却以刺眼的浅色渲染,即使功能完全正常,体验也立刻「出戏」。
默认情况下,你的 MCP App 对周边 Agent 环境一无所知,因为它运行在沙箱 iFrame 中:无法判断 Agent 处于深色还是浅色模式、视口多大、用户偏好哪种 locale。
解决办法是宿主(Host,即 Agent)与视图(View,即你的 App)之间的环境共享:
- 当 View 连接时,它发送
ui/initialize请求; - Host 以
hostContext对象响应,描述当前环境; - 当主题、视口或 locale 等发生变化时,Host 发送只含变更字段的
ui/notifications/host-context-changed通知。
两者间的对话大致如下:
View:「我在初始化。你的环境长什么样?」
Host:「我们处于深色模式,视口 400×300,locale 是 en-US,运行在桌面端。」
用户切换到浅色主题
Host:「更新:现在是浅色模式了。」
作为开发者,你的职责就是让 MCP App 真正消费hostContext并据此适配环境。
在 MCP App 中使用 hostContext
import { useState } from "react"; import { useApp } from "@modelcontextprotocol/ext-apps/react"; import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; function MyApp() { const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>(undefined); const { app, isConnected, error } = useApp({ appInfo: { name: "MyApp", version: "1.0.0" }, capabilities: {}, onAppCreated: (app) => { app.onhostcontextchanged = (ctx) => { setHostContext((prev) => ({ ...prev, ...ctx })); }; }, }); if (error) return <div>Error: {error.message}</div>; if (!isConnected) return <div>Connecting...</div>; return ( <div> <p>Theme: {hostContext?.theme}</p> <p>Locale: {hostContext?.locale}</p> <p>Viewport: {hostContext?.containerDimensions?.width} x {hostContext?.containerDimensions?.height}</p> <p>Platform: {hostContext?.platform}</p> </div> ); }:::tip 使用useApphook 时,它会提供onhostcontextchanged监听器。配合 React 的useState即可更新 App 上下文。宿主给出的是它的环境描述,至于要拿它做什么,由作为 App 开发者的你决定:例如用 theme 渲染浅/深色模式、用 locale 切换显示语言、用 containerDimensions 自适应尺寸。 :::
Tip 2:控制模型看到什么、View 看到什么
有些场景下,你需要粒度化地控制 LLM 能访问哪些数据、View 能展示哪些数据。MCP Apps 规范为此规定了三种工具返回值,宿主对它们各不相同的处理方式实现了数据流的隔离:
content:暴露给模型的信息,为模型提供上下文;structuredContent:对模型上下文隐藏,专门用于把数据发给 View 做水合(hydration);_meta:对模型上下文隐藏,用于携带时间戳、版本信息等附加信息。
用一个鸡尾酒 App 的实践案例说明三者如何协同:
server.registerTool( "view-cocktail", { title: "Get Cocktail", description: "Fetch a cocktail by id with ingredients and images...", inputSchema: z.object({ id: z.string().describe("The id of the cocktail to fetch.") }), _meta: { ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html" }, }, }, async ({ id }: { id: string }): Promise<CallToolResult> => { const cocktail = await convexClient.query(api.cocktails.getCocktailById, { id, }); return { content: [ { type: "text", text: `Loaded cocktail "${cocktail.name}".` }, { type: "text", text: `Cocktail ingredients: ${cocktail.ingredients}.` }, { type: "text", text: `Cocktail instructions: ${cocktail.instructions}.` }, ], structuredContent: { cocktail }, _meta: { timestamp: new Date().toString() } }; }, );这个工具渲染一份鸡尾酒配方视图。数据从后端数据库(Convex)取出,View 需要完整的鸡尾酒数据,因此通过structuredContent传递;而模型不需要图片 URL 这类完整数据,只需知道名称、配料与步骤等要点,这部分经content给出。
需要特别注意:ChatGPT apps SDK 当前的实现不同——它把structuredContent同时暴露给模型与 View:
content:暴露给模型,提供上下文;structuredContent:同时暴露给模型和 View;_meta:对模型上下文隐藏。
如果你的 App 需要同时支持 MCP Apps 与 ChatGPT apps SDK,这个差异很关键——你可能需要按返回值条件分支,或根据客户端是 MCP App 支持还是 ChatGPT App 来条件渲染工具。
Tip 3:妥善处理加载态与错误态
iFrame 通常先于工具执行完成、View 水合之前渲染出来。此时你应该用漂亮的加载态告诉用户 App 正在加载。
这里有一个值得注意的强力特性:toolInputs会先于工具执行结束被发送并流式进入 View。这让你能做出很酷的「部分加载态」——数据仍在抓取时,就能向用户展示正在请求的内容。
还是以鸡尾酒 App 为例:MCP 工具抓取鸡尾酒数据并经structuredContent传给 View,但抓取耗时未知,运气差时从几毫秒到几秒都有可能:
server.registerTool( "view-cocktail", { title: "Get Cocktail", description: "Fetch a cocktail by id with ingredients and images...", inputSchema: z.object({ id: z.string().describe("The id of the cocktail to fetch.") }), _meta: { ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html", visibility: ["model", "app"], }, }, }, async ({ id }: { id: string }): Promise<CallToolResult> => { const cocktail = await convexClient.query(api.cocktails.getCocktailById, { id, }); return { content: [ { type: "text", text: `Loaded cocktail "${cocktail.name}".` }, ], structuredContent: { cocktail }, }; }, );在 View 侧(React),useAppAppBridge hook 提供app.ontoolresult监听器,负责接收工具返回结果并水合 View。在onToolResult尚未到达、数据为空时,就可以渲染漂亮的加载态:
import { useApp } from "@modelcontextprotocol/ext-apps/react"; function CocktailApp() { const [cocktail, setCocktail] = useState<CocktailData | null>(null); useApp({ appInfo: IMPLEMENTATION, capabilities: {}, onAppCreated: (app) => { app.ontoolresult = async (result) => { const data = extractCocktail(result); setCocktail(data); }; }, }); return cocktail ? <CocktailView cocktail={cocktail} /> : <CocktailViewLoading />; }错误态处理
错误同样要优雅处理。当工具内部出错(例如鸡尾酒数据加载失败)时,LLM 和 View 都应当收到错误通知。在 MCP 工具中,你应该在工具结果里返回error字段——它既暴露给模型,也会传给 View:
server.registerTool( "view-cocktail", { title: "Get Cocktail", description: "Fetch a cocktail by id with ingredients and images...", inputSchema: z.object({ id: z.string().describe("The id of the cocktail to fetch.") }), _meta: { ui: { resourceUri: "ui://cocktail/cocktail-recipe-widget.html" }, visibility: ["model", "app"], }, }, async ({ id }: { id: string }): Promise<CallToolResult> => { try { const cocktail = await convexClient.query(api.cocktails.getCocktailById, { id, }); return { content: [ { type: "text", text: `Loaded cocktail "${cocktail.name}".` }, ], structuredContent: { cocktail }, }; } catch (error) { return { content: [ { type: "text", text: `Could not load cocktail` }, ], error }; } }, );随后在 React 客户端一侧的useApp里,只需检查工具结果中是否存在error字段即可感知错误。这条链路与 goose 的预取设计互为印证:如前所述,goose 在hydrate_mcp_app_attachment中若资源读取失败会填充read_error,UI 侧据此同样可以进入错误分支。
Tip 4:让模型保持在环(Keep the Model in the Loop)
你的 MCP App 运行在沙箱 iFrame 里,默认情况下,驱动 Agent 的模型看不到 App 内部发生的一切——它不知道用户是否填了表单、点了按钮、完成了购买。
没有反馈回路,模型就会丢失上下文。用户买完一双鞋后问「什么时候发货?」,模型甚至不知道交易发生过。
为此,SDK 提供两个方法让模型与用户的旅程保持同步:sendMessage与updateModelContext。
sendMessage()
用于主动触发。它像用户亲自输入那样给模型发送一条消息,促使模型立即响应。非常适合在用户点击「购买」后立即确认,或在一个动作完成后立刻推荐相关商品:
// 用户点击 "Buy" —— 模型立即响应 await app.sendMessage({ role: "user", content: [{ type: "text", text: "I just purchased Nike Air Max for $129" }], }); // 结果:模型回复 "Great choice! Want me to track your order?"updateModelContext()
用于后台感知。它安静地把信息保存下来供模型稍后使用,不打断当前流程。适合追踪浏览历史或购物车变化,而不必每次都触发一条聊天回复:
// 用户在浏览 —— 无需立即响应 await app.updateModelContext({ content: [{ type: "text", text: "User is viewing: Nike Air Max, Size 10, $129" }], }); // 结果:无响应。但如果用户稍后问 "我刚才在看什么?",模型知道答案。Tip 5:控制谁能触发工具(Control Who Can Trigger Tools)
在标准 MCP server 中,模型看到你的工具、解读用户提示、再调用正确的工具。用户说「删掉那封邮件」,由模型决定这句话的含义并调用删除工具。
而在 MCP App 中,工具存在两种触发途径:模型解读用户提示后调用,或者用户直接在 UI 上交互触发。
默认情况下,两者都能调用任意工具。举例:假设你构建了一个把邮件收件箱可视化呈现、允许用户直接操作邮件的 MCP App。现在你的工具有两个潜在触发者:模型响应「删除我的旧邮件」这类提示而调用删除;用户直接在 App 界面点击删除按钮。
模型的本质是解读意图。用户说「删除我的旧邮件」时,模型必须自行判断「旧」指什么、哪些邮件符合条件。对于删邮件这类动作,这种歧义可能带来风险;而用户在你的 MCP App 中点开某封具体邮件旁边的「删除」按钮时,则毫无歧义——他们做了明确选择。
要防止模型基于误解误执行高利害动作,你可以用tool visibility把某些工具限制为只能由 MCP App 的 UI 调用。模型负责展示界面,最终动作必须由真人点击确认。visibility 有三种配置:
["model", "app"](默认)——模型与 UI 均可调用;["model"]——只有模型能调用,UI 不能;["app"]——只有 UI 能调用,对模型隐藏。
实现示例如下:
// 模型调用它以展示收件箱 registerAppTool(server, "show-inbox", { description: "Display the user's inbox", _meta: { ui: { resourceUri: "ui://email/inbox.html", visibility: ["model"], }, }, }, async () => { const emails = await getEmails(); return { content: [{ type: "text", text: JSON.stringify(emails) }] }; }); // 用户在 UI 中点击删除按钮 registerAppTool(server, "delete-email", { description: "Delete an email", inputSchema: { emailId: z.string() }, _meta: { ui: { resourceUri: "ui://email/inbox.html", visibility: ["app"], }, }, }, async ({ emailId }) => { await deleteEmail(emailId); return { content: [{ type: "text", text: "Email deleted" }] }; });delete-email因为visibility: ["app"]而不会被模型感知,从而杜绝了「删除」语义被模型猜错的风险。这一设计在 goose 源码中有直接的执行印证:在 reply_parts.rs 中,is_tool_visible_to_model与is_tool_visible_to_app按 2026-01-26 版 MCP Apps 规范实现了判断逻辑——_meta.ui.visibility缺失时默认双端可见;不含"model"即视为 app-only,模型不可见;不含"app"即 model-only,UI 不可调用。工具在发往 LLM 前会经过tools.retain(is_tool_visible_to_model)过滤(见 reply_parts.rs),被过滤掉(app-only)的工具根本不会出现在模型面前,从源头规避了误触发。仓库中还有大量围绕这一机制的单元测试,例如 extension_manager.rs 用get_tool_resource_uri分区验证 MCP App 工具与普通工具,测试注释直接写明「non-MCP-app tools have no resourceUri」——它同时确认了:判定一个工具是否属于 MCP App,正是看其_meta.ui里是否存在resourceUri。
在 goose 中开始构建与测试
MCP Apps 为 Agent 交互打开了新维度。goose 这一侧,你既能测试也能运行自己的 App:
- 本地调试:可以先用 MCPJam(面向 MCP Apps、ChatGPT apps SDK 与 MCP server 的开源本地 inspector)反复调试、迭代,再正式发布——它非常适合在交付前打磨你的 App;
- 在 goose 中运行:goose 作为开源 AI Agent,会把 MCP Apps 直接渲染到聊天界面中,让你在真实的 Agent 环境里看到 App 活起来。按 Using MCP Apps 的描述,装有支持的扩展后,既可以从侧边栏
Apps页把 App 启动到独立沙箱窗口,也可以让 goose 在调用到带 UI 的工具时把界面内嵌进对话流; - 参考内置范例:如果你需要一份「goose 内真实的 MCP App 扩展」作为模板,可以研读 autovisualiser 扩展的注册与前端桥接实现(crates/goose-mcp/src/autovisualiser/mod.rs),其工具即通过
_meta.ui中的resourceUri(形如ui://autovisualiser/chart,见 extension_manager.rs)向宿主声明可渲染的图表视图,并附带mcp-app-bridge.js这类前端桥接资源,是理解规范落地的绝佳样本; - 官方教程:goose 文档提供了从零开始的完整实战引导——Building MCP Apps,按步骤即可构建你的第一个 MCP App,也可以访问 Custom Extensions 了解扩展体系,并在 autovisualiser 的 MCP 说明 中看到交互式 UI 扩展的完整使用姿势。
动手时请始终把「最终运行在你不控制的 Agent 里」作为默认假设:适配hostContext、分层掌控模型与视图的数据边界、用心打磨加载与错误态、用sendMessage/updateModelContext把用户每一步操作同步回模型,并用visibility把高风险动作锁在人类点击之后——这五点正是让 MCP App 从一个「能渲染的页面」进化为「真正可用的 Agent 应用」的分水岭。
【免费下载链接】goosean open source, extensible AI agent that goes beyond code suggestions - install, execute, edit, and test with any LLM项目地址: https://gitcode.com/GitHub_Trending/goose3/goose
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考