news 2026/9/8 16:09:32

用 goose 构建真正可用的 MCP Apps:渲染机制与五个实战技巧

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用 goose 构建真正可用的 MCP Apps:渲染机制与五个实战技巧

用 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 渲染一份鸡尾酒配方为例,完整流程为:

  1. 你向 LLM 提问「给我一份玛格丽特配方」(Show me a margarita recipe);
  2. LLM 以正确参数调用get-cocktail工具——该工具通过_meta.ui.resourceUri声明指向 HTML 资源的 UI 链接;
  3. 客户端用该 URI 获取 MCP resource,其中包含该视图(View)的 HTML 内容;
  4. 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_uriresource_resultGooseMcpAppToolAttachment,交给 UI 层注入 iFrame 完成渲染——若资源读取失败,错误也会被记录进read_error,供客户端展示异常态。

视角之外还有不少幕后工作:视图水合(hydration)、能力协商(capability negotiation)、CSP 策略等。goose 在 goose_apps/resource.rs 中就为 App 资源维护了CspMetadataPermissionsMetadataUiMetadata等元数据结构,并在 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 提供两个方法让模型与用户的旅程保持同步:sendMessageupdateModelContext

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_modelis_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),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/8 16:09:26

芯片工程师的中年清醒:用SoC设计思维重构职业与家庭

芯片工程师这几个字放在招聘软件上&#xff0c;从来都是硬通货。但我见过太多同行&#xff0c;包括我自己&#xff0c;在一个说不清哪一天的节点&#xff0c;突然感觉自己像一颗跑了十年的PLL&#xff1a;输出频率还在&#xff0c;相位却开始抖。那种抖动不来自某一行代码、某一…

作者头像 李华
网站建设 2026/9/8 16:09:18

WandEnhancer 使用教程:3步本地解锁 WeMod Pro 时间限制

WandEnhancer 使用教程&#xff1a;3步本地解锁 WeMod Pro 时间限制 【免费下载链接】Wand-Enhancer Advanced UX and interoperability extension for Wand (WeMod) app 项目地址: https://gitcode.com/GitHub_Trending/we/Wand-Enhancer 每次打开 Wand&#xff08;WeM…

作者头像 李华
网站建设 2026/9/8 16:09:06

老牌免费窗口管理工具,Alt键拖拽窗口

软件介绍 咱们今天要聊的这款工具&#xff0c;名字叫 AltDrag。说到它&#xff0c;就不得不提上一期咱们聊过的 AltSnap&#xff0c;其实 AltSnap 就是基于 AltDrag 开发出来的。这款 AltDrag 最后的版本停留在了 2015 年&#xff0c;虽然不再更新了&#xff0c;但我亲测发现它…

作者头像 李华
网站建设 2026/9/8 16:08:52

综合测评|OKBIYE 全模块梳理:一套工具走完本科毕设全流程

写本科毕业论文&#xff0c;完整链路包含选题开题、文献研读、正文撰写、问卷实证、外文翻译、图表绘制、降重检测、格式排版、答辩准备&#xff0c;环节繁多。很多同学手上要同时切换五六个网站软件&#xff0c;文件来回导出导入&#xff0c;不仅效率低下&#xff0c;还存在文…

作者头像 李华
网站建设 2026/9/8 16:08:20

Go网络编程与中间件开发:微服务稳定性的核心技艺

如果你已经在用 Go 写微服务&#xff0c;估计你会有同感&#xff1a;业务接口的 CRUD 大多不难&#xff0c;真正让人头疼的往往在另一个地方——连接怎么断的、超时怎么控制、一个请求中间想插入日志和鉴权应该放在哪、线上突然 panic 会不会拖垮整个进程。Go 网络编程和中间件…

作者头像 李华
网站建设 2026/9/8 16:03:21

双线性变换公式推导到C语言实现:数字滤波器设计全解析

网上搜“双线性变换”&#xff0c;十篇有八篇上来就甩给你一个替换式&#xff1a;s (2/T)(1 - z⁻)/(1 z⁻)。然后就是“代入即可、整理可得、最后得到”三连。公式谁都会抄&#xff0c;问题是这个式子到底怎么来的&#xff1f;为什么偏偏是它&#xff0c;而不是 s (z-1)/T …

作者头像 李华