拓冰建站拓冰建站
首页 / 资讯中心 / 正文

mcp-use Widgets 开发实战:用 React 组件为 MCP 工具打造交互式可视化 UI

mcp-use Widgets 开发实战用 React 组件为 MCP 工具打造交互式可视化 UI【免费下载链接】CopilotKitThe Frontend Stack for Agents Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKitWidgets 是 mcp-use 框架为 MCPModel Context Protocol工具提供的一套可视化方案开发者只需在resources/目录编写一个 React 组件并声明widgetMetadata描述 Zod props 模式mcp-use 就会自动将其同时注册为 MCP 工具与资源工具被调用时组件随即渲染出交互式 UI。本文以本仓库 open-mcp-client 展示项目中的 mcp-use-server 为实例完整讲解 Widget 的目录约定、创建流程、元数据字段、useWidget钩子体系与端到端实现让读者可以直接复刻出可运行的 Widget 服务。Widget 的核心机制一次编写双端注册Widget 的工作方式可以用四步概括这也是理解后续所有配置的思维模型在resources/文件夹下创建一个 React 组件组件导出widgetMetadata包含描述与 props 的 Zod schema以及一个默认 React 组件mcp-use 自动将其注册为一个MCP 工具与一个MCP 资源当工具被调用时Widget 以工具的输出数据作为 props 进行渲染。由此带来两个关键设计数据模型可见的输出与 UI用户可见的渲染天然分离——通过widget()返回的props只流向 Widget 界面而output才是模型能看到的内容同时 Widget 与普通 MCP 工具共用同一套注册与调用链路不需要额外搭一套接口。目录约定文件名即 Widget 名Widget 文件放在服务根目录的resources/文件夹中支持两种组织方式单文件模式适合简单 Widgetresources/weather-display.tsx → widget name: weather-display resources/recipe-card.tsx → widget name: recipe-card文件夹模式适合复杂 Widgetresources/product-search/ widget.tsx → entry point (required name) components/ProductCard.tsx hooks/useFilter.ts types.ts命名规范文件或文件夹名即 Widget 名称统一使用 kebab-case。文件夹模式下入口文件必须命名为widget.tsx。仓库中的实际示例 resources/product-search-result/ 正是文件夹模式的代表widget.tsx为入口types.ts集中定义 Zod propSchema 与类型components/下拆分 Carousel、CarouselSkeleton、Accordion 等子组件。创建第一个 Widget两步实战Step 1创建 Widget 文件在resources/weather-display.tsx中编写组件同时导出widgetMetadata与默认组件// resources/weather-display.tsx import { McpUseProvider, useWidget, type WidgetMetadata } from mcp-use/react; import { z } from zod; export const widgetMetadata: WidgetMetadata { description: Display current weather conditions for a city, props: z.object({ city: z.string().describe(City name), temp: z.number().describe(Temperature in Celsius), conditions: z.string().describe(Weather conditions), humidity: z.number().describe(Humidity percentage), }), }; export default function WeatherDisplay() { const { props, isPending } useWidget(); if (isPending) { return ( McpUseProvider autoSize div style{{ padding: 16, textAlign: center }} Loading weather... /div /McpUseProvider ); } return ( McpUseProvider autoSize div style{{ padding: 20, borderRadius: 12, background: #f0f9ff }} h2 style{{ margin: 0, fontSize: 24 }}{props.city}/h2 div style{{ fontSize: 48, fontWeight: bold }}{props.temp}°C/div p style{{ color: #666 }}{props.conditions}/p p style{{ color: #999, fontSize: 14 }} Humidity: {props.humidity}% /p /div /McpUseProvider ); }注意两点一是props通过 Zod schema 声明既作为类型约束又作为运行时校验调用端传入非法数据会被拦截二是组件渲染时工具可能仍在执行因此必须先处理isPending详见下文加载态一节。Step 2注册工具并关联 Widget在服务端入口如index.ts通过server.tool()注册工具并在widget字段中声明要渲染的 Widget// index.ts import { MCPServer, widget, text } from mcp-use/server; import { z } from zod; const server new MCPServer({ name: weather-server, version: 1.0.0, baseUrl: process.env.MCP_URL || http://localhost:3000, }); server.tool( { name: get-weather, description: Get current weather for a city, schema: z.object({ city: z.string().describe(City name), }), widget: { name: weather-display, // Must match resources/weather-display.tsx invoking: Fetching weather..., invoked: Weather loaded, }, }, async ({ city }) { const data getWeather(city); return widget({ props: { city, temp: data.temp, conditions: data.conditions, humidity: data.humidity, }, output: text(Weather in ${city}: ${data.temp}°C, ${data.conditions}), }); }, ); server.listen();widget.name必须与resources/下的文件名/文件夹名完全一致这是 UI 与工具绑定的唯一约定。invoking/invoked则用于指定工具运行期间与完成后的状态文案在 inspector 中以 shimmer 形式呈现。必需的导出与 WidgetMetadata 字段详解每个 Widget 文件必须导出两个成员1.widgetMetadata—— 描述 Widget 及声明输入数据的 Zod schemaexport const widgetMetadata: WidgetMetadata { description: Human-readable description of what this widget shows, props: z.object({ /* Zod schema for widget input */ }), };2. 默认 React 组件—— Widget 的 UI 本体export default function MyWidget() { ... }WidgetMetadata 字段表字段类型必填说明descriptionstring是描述 Widget 展示的内容propsz.ZodObject是定义 Widget 输入数据的 Zod schemaexposeAsToolboolean否是否自动注册为工具默认falsetoolOutputCallToolResult \| (params CallToolResult)否自动注册工具被调用时模型可以看到的内容titlestring否展示标题annotationsobject否readOnlyHint、destructiveHint等 MCP 工具标注metadataobject否CSP、边框、resize 配置、调用状态文案等metadata.invokingstring否工具运行期间的文案在 inspector 中显示为 shimmer自动默认Loading {name}...metadata.invokedstring否工具完成后的文案显示在 inspector 中自动默认{name} ready调用状态文案的优先级metadata中的invoking/invoked是协议无关的对mcpApps与appsSdk两类 Widget 均生效但如果工具配置里使用了widget: { name, invoking, invoked }则以widget:中的值为准。exposeAsTool默认是false默认情况下 Widget 仅注册为 MCP 资源。当你用自定义工具widget: { name: my-widget }关联 Widget 时省略exposeAsTool是正确的——因为此时调用入口由自定义工具负责export const widgetMetadata: WidgetMetadata { description: Weather display, props: z.object({ city: z.string(), temp: z.number() }), // exposeAsTool defaults to false — custom tool handles registration };只有当你希望省去自定义工具定义、让 Widget 直接被模型作为工具调用时才设置exposeAsTool: true。仓库示例 widget.tsx 中显式写了exposeAsTool: false因为它的调用入口是自定义的search-tools工具。toolOutput控制模型看到的输出当 Widget 通过exposeAsTool: true自动注册为工具时用toolOutput定制模型侧的返回值export const widgetMetadata: WidgetMetadata { description: Recipe card, props: z.object({ name: z.string(), ingredients: z.array(z.string()) }), toolOutput: (params) text( Showing recipe: ${params.name} (${params.ingredients.length} ingredients), ), };useWidgetWidget 的万能钩子useWidget是 Widget 内部访问数据与宿主能力的核心钩子返回值可分成五组const { // Core data props, // Widget input data (from tools widget() call or auto-registered tool) isPending, // true while tool is still executing (props may be partial) toolInput, // Original tool input arguments output, // Additional tool output data metadata, // Response metadata // Persistent state state, // Persisted widget state (survives re-renders) setState, // Update persistent state: setState(newState) or setState(prev newState) // Host environment theme, // light | dark displayMode, // inline | pip | fullscreen safeArea, // { insets: { top, bottom, left, right } } maxHeight, // Max available height in pixels userAgent, // { device: { type }, capabilities: { hover, touch } } locale, // User locale (e.g., en-US) timeZone, // IANA timezone // Actions callTool, // Call another MCP tool: callTool(tool-name, { args }) sendFollowUpMessage, // Trigger LLM response: sendFollowUpMessage(analyze this) openExternal, // Open external URL: openExternal(https://example.com) requestDisplayMode, // Request mode change: requestDisplayMode(fullscreen) mcp_url, // MCP server base URL for custom API requests } useWidget();加载态关键Widget 在工具执行完成之前就会渲染因此必须处理isPending否则props可能是不完整数据const { props, isPending } useWidget(); if (isPending) { return ( McpUseProvider autoSize divLoading.../div /McpUseProvider ); } // Now props are safe to use return ( McpUseProvider autoSize div {props.city}: {props.temp}°C /div /McpUseProvider );仓库示例在加载态中渲染了CarouselSkeleton骨架屏见 widget.tsx 的isPending分支这正是加载态的最佳实践——用骨架屏避免布局抖动同时模拟 2 秒网络延迟的setTimeout见 product-search.ts可以让加载效果被真实观察到。调用其他工具Widget 可以通过callTool调用 MCP 服务器上的任意工具实现UI 驱动数据const { callTool } useWidget(); const handleRefresh async () { try { const result await callTool(get-weather, { city: Tokyo }); console.log(result.content); } catch (err) { console.error(Tool call failed:, err); } };仓库中 widget.tsx 用useCallTool(get-fruit-details)订阅工具结果——点击水果卡片时触发getFruitDetails({ fruit })返回的结构化数据被渲染为详情面板这是工具回调 订阅的组合用法。触发 LLM 响应点击 Widget 内按钮即可向 LLM 发送追问让 AI 继续参与const { sendFollowUpMessage } useWidget(); button onClick{() sendFollowUpMessage(Compare the weather in these cities)} Ask AI to Compare /button;仓库示例在水果详情面板中放置了 Ask the AI for more about {fruit} 按钮点击后通过sendFollowUpMessage把了解更多水果趣闻的请求发回给 LLM形成用户 ↔ Widget ↔ LLM的闭环。持久状态state/setState让 Widget 的状态在重新渲染与多次调用间保持const { state, setState } useWidget(); // Set state await setState({ favorites: [...(state?.favorites || []), city] }); // Update with function await setState((prev) ({ ...prev, count: (prev?.count || 0) 1 }));仓库中的水果商店 Widget 就用它实现收藏夹FavoritesState { favorites: string[] }作为useWidget的第二泛型参数toggleFavorite回调通过setState({ favorites: next })持久化收藏列表并在工具栏展示收藏数量徽标。便捷 Hooks简单场景可跳过useWidget按需取用import { useWidgetProps, useWidgetTheme, useWidgetState } from mcp-use/react; // Just props const props useWidgetPropsMyProps(); // Just theme const theme useWidgetTheme(); // light | dark // Just state (like useState) const [state, setState] useWidgetStateMyState({ count: 0 });useWidgetState的签名与 ReactuseState保持一致便于迁移现有组件。McpUseProviderWidget 的渲染外壳Widget 内容必须包裹在McpUseProvider中它是 Widget 与宿主 iframe 通信的桥梁import { McpUseProvider } from mcp-use/react; export default function MyWidget() { return ( McpUseProvider autoSize divWidget content/div /McpUseProvider ); }Prop类型默认值说明autoSizebooleanfalse根据内容自动调整 Widget 高度viewControlsboolean \| pip \| fullscreenfalse显示显示模式画中画/全屏切换按钮debuggerbooleanfalse显示调试检查器覆盖层仓库示例没有启用autoSize因为内容中有定位工具栏但它的 Accordion 说明文字特别强调autosize featurewidget 将自动调整大小以适配内容正如 mcp-apps 规范所支持——对于内容长度动态变化的 WidgetautoSize可以避免滚动条与裁切。样式Inline 与 Tailwind 双路可用Widget 在 iframe 中独立渲染样式不会被宿主页面干扰因此两种方式都可以放心使用// Inline styles div style{{ padding: 20, borderRadius: 12, background: #f0f9ff }} // Tailwind div classNamep-5 rounded-xl bg-blue-50仓库示例大量使用 Tailwind 工具类如rounded-3xl、bg-surface-elevated、text-secondary并混用mcp-use/react的Image组件与openai/apps-sdk-ui的Button、Icon组件说明 Widget 可以自由引入第三方 UI 库来提升视觉质量。widget()响应辅助函数服务端工具回调中使用widget()把数据发送给 Widgetimport { widget, text } from mcp-use/server; return widget({ props: { city: Tokyo, temp: 25 }, // Sent to widget via useWidget().props output: text(Weather in Tokyo: 25°C), // What the AI model sees message: Current weather for Tokyo, // Optional text override });字段类型说明propsRecordstring, any提供给 Widget UI 的数据对模型隐藏outputCallToolResult模型可见的响应辅助函数结果text()、object()等messagestring可选覆盖默认文本消息工具侧widget配置关联 Widget 的工具配置支持以下字段server.tool({ name: tool-name, schema: z.object({ ... }), widget: { name: widget-name, // Must match resources/ file/folder name invoking: Loading..., // Text shown while tool runs invoked: Ready, // Text shown when complete widgetAccessible: true, // Widget can call other tools (default: true) }, }, async (input) { ... });widgetAccessible控制该 Widget 是否允许调用服务器上的其他工具默认开启若你的 Widget 没有跨工具调用需求可以显式设为false收紧权限。端到端示例Recipe Finder把以上知识点串起来一个完整的搜索 卡片列表Widget 只需两个文件。index.ts服务端import { MCPServer, widget, text, object } from mcp-use/server; import { z } from zod; const server new MCPServer({ name: recipe-finder, version: 1.0.0, baseUrl: process.env.MCP_URL || http://localhost:3000, }); const mockRecipes [ { id: 1, name: Pasta Carbonara, cuisine: Italian, time: 30, ingredients: [pasta, eggs, bacon, parmesan], }, { id: 2, name: Chicken Tikka, cuisine: Indian, time: 45, ingredients: [chicken, yogurt, spices, rice], }, { id: 3, name: Sushi Rolls, cuisine: Japanese, time: 60, ingredients: [rice, nori, fish, avocado], }, ]; server.tool( { name: search-recipes, description: Search for recipes by query or cuisine, schema: z.object({ query: z.string().describe(Search query (e.g., pasta, chicken)), cuisine: z.string().optional().describe(Filter by cuisine), }), widget: { name: recipe-list, invoking: Searching recipes..., invoked: Recipes found, }, }, async ({ query, cuisine }) { const results mockRecipes.filter( (r) r.name.toLowerCase().includes(query.toLowerCase()) || (cuisine r.cuisine.toLowerCase() cuisine.toLowerCase()), ); return widget({ props: { recipes: results, query }, output: text(Found ${results.length} recipes for ${query}), }); }, ); server.listen();resources/recipe-list.tsxWidget 界面import { McpUseProvider, useWidget, type WidgetMetadata } from mcp-use/react; import { z } from zod; export const widgetMetadata: WidgetMetadata { description: Display recipe search results, props: z.object({ recipes: z.array( z.object({ id: z.string(), name: z.string(), cuisine: z.string(), time: z.number(), ingredients: z.array(z.string()), }), ), query: z.string(), }), exposeAsTool: false, }; export default function RecipeList() { const { props, isPending } useWidget(); if (isPending) { return ( McpUseProvider autoSize div style{{ padding: 16 }}Searching.../div /McpUseProvider ); } return ( McpUseProvider autoSize div style{{ padding: 16 }} h2 style{{ margin: 0 0 12px }}Recipes for {props.query}/h2 {props.recipes.length 0 ? ( p style{{ color: #999 }}No recipes found./p ) : ( div style{{ display: flex, flexDirection: column, gap: 12 }} {props.recipes.map((recipe) ( div key{recipe.id} style{{ padding: 16, borderRadius: 8, border: 1px solid #e5e7eb, background: #fff, }} h3 style{{ margin: 0 0 4px }}{recipe.name}/h3 p style{{ margin: 0, color: #666, fontSize: 14 }} {recipe.cuisine} · {recipe.time} min ·{ } {recipe.ingredients.join(, )} /p /div ))} /div )} /div /McpUseProvider ); }仓库实战剖析open-mcp-client 的 mcp-use-server本仓库 examples/showcases/open-mcp-client 中带有一个完整的可运行示例服务mcp-use-server其组织方式可以作为工程化模板入口文件的增量式设计apps/mcp-use-server/index.ts 采用新增 Widget 三步走的注释规范① 在resources/widget-name/widget.tsx创建组件② 在tools/tool-name.ts中导出register(server)并调用server.tool()③ 在入口文件的Tool imports与Tool registrations两个标记区导入并调用register()。这种模式让每个工具/Widget 独立成文件通过registerProductSearch(server)这样的函数组合进服务器天然支持多 Widget 并行开发。入口还通过process.env.MCP_URL与process.env.PORT让端口、baseUrl 可配置默认http://localhost:3109。双工具协作一个给 Widget一个给 Widget 内部调用tools/product-search.ts 演示了 Widget 生态的两种工具角色search-tools主工具配置widget: { name: product-search-result, invoking: Searching..., invoked: Results loaded }回调中先用setTimeout模拟 2 秒网络延迟再返回widget({ props: { query, results }, output: text(...) })同时用_meta[ui/previewData]提供未调用前的预览数据get-fruit-details伴生数据工具outputSchema声明结构化返回供 Widget 内部通过useCallTool(get-fruit-details)调用并渲染详情。真实 Widget 的工程结构resources/product-search-result/ 展示了成熟 Widget 的拆分方式types.ts集中管理propSchemaZod并用z.infer推导 TS 类型ProductSearchResultProps保证 schema 与组件类型单源components/下拆出Carousel、CarouselSkeleton、Accordion等子组件CarouselItem.tsx 用mcp-use/react的Image加载/fruits/*.png资源widget.tsx中widgetMetadata声明了prefersBorder: false与自定义 CSPresourceDomains: [https://cdn.openai.com]、invoking/invoked文案并在组件内实际用到了displayMode、requestDisplayMode画中画/全屏切换、locale语言徽标、state/setState收藏夹与sendFollowUpMessage——几乎覆盖了文档中useWidget的每一项能力。构建与开发脚本package.json 定义了完整的工具链脚本命令作用buildmcp-use build --inline内联构建 Widget 资源devmcp-use build --inline cross-env NODE_ENVproduction npx tsx index.ts保存时自动重建 Widget 并重启服务startmcp-use start启动服务器deploymcp-use deploy部署到托管平台postinstallmcp-use generate-types安装后自动生成类型定义依赖方面以mcp-use^1.22.3为核心配合react/react-dom^19.2.4、zod4.3.5、tailwindcss^4.2.0与openai/apps-sdk-uiUI 组件库。开发模式下访问http://localhost:3109/inspector即可在浏览器中测试服务器与 WidgetREADME 中默认标注的 3000 端口可通过PORT环境变量调整。此外 build.dev.ts 展示了将服务打包为 E2B 沙箱模板、用于缩短冷启动时间的可选方案。最佳实践小结目录即命名resources/下的文件/文件夹名必须与widget: { name }严格一致统一 kebab-case加载态是硬性要求isPending分支必须在任何props解构之前处理配合骨架屏可获得更流畅的体验数据分层widget({ props, output })中props只面向 UIoutput面向模型不要让 UI 细节污染模型上下文exposeAsTool按需开启有自定义工具时保持默认false纯展示型 Widget 需要模型直接调用时再置true并用toolOutput定制模型侧输出状态与交互跨调用状态用state/setState跨工具数据用callTool/useCallTool联动 LLM 用sendFollowUpMessage工程化组织参照仓库采用tools/resources/双目录与register(server)注册函数让 Widget 数量增长时仍保持可维护性。【免费下载链接】CopilotKitThe Frontend Stack for Agents Generative UI. React, Angular, Mobile, Slack, and more. Makers of the AG-UI Protocol项目地址: https://gitcode.com/GitHub_Trending/co/CopilotKit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门