ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

CopilotKit open-mcp-client:mcp-use Widget 状态管理实战(Widget State vs Tool State)

CopilotKit open-mcp-client:mcp-use Widget 状态管理实战(Widget State vs Tool State) CopilotKit open-mcp-clientmcp-use Widget 状态管理实战Widget State vs Tool State【免费下载链接】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本文基于 CopilotKit 仓库examples/showcases/open-mcp-client示例中的 mcp-use 技能参考文档《Widget State》系统讲解 MCP App Widget 的 UI 状态管理原则UI 状态选中项、Tab、筛选、分页、展开折叠、表单输入应由 Widget 自身用 ReactuseState或useWidget的setState管理而服务端状态数据列表、API 结果、计算结果才放在 Tool 中返回。读完本文你将掌握Widget 拥有自己的状态这一核心架构原则的全部落地模式、初始化陷阱isPending下的懒初始化失效、常见反模式与最佳实践并能直接对照仓库内的真实 MCP 服务器代码进行验证。核心原则UI State in Widget, Server State in Tools原参考文档state.md开宗明义给出了一条不可违背的规则Widgets manage their own UI state (selections, filters, tabs, pagination).Never create tools to manage widget state.即Widget 管理自己的 UI 状态选中项、筛选器、Tab、分页永远不要为管理 Widget 状态去创建 Tool。其背后的关键原则是UI StateWidget State由 Widget 用useState或setState管理包括当前选中项、激活 Tab、筛选设置、排序方式、分页页码、展开/折叠状态、提交前的表单输入值Server StateTool State由服务器管理并在 Tool 响应中返回包括条目列表、用户数据、API 结果、计算结果、数据库查询。这一原则并非孤立存在而是 mcp-use 技能体系Golden Rules的一部分。SKILL.md 中列出的第 3 条黄金法则正是3. Widgets Own Their StateUI state lives in the widget, not in separate tools:❌select-itemtool,set-filtertool✅ Widget manages withuseStateorsetState同时它还解释了为什么这样设计——Tool 调用是昂贵的Tool calls are expensive每一次状态变更若都走一次 Tool 往返都会引入网络延迟和 Token 成本而 UI 状态变化本应在客户端毫秒级完成。仓库中的真实服务器 tools/product-search.ts 印证了这种分层search-tools工具负责触发 Widget UI 并返回全量results服务端状态一次给足Widget 拿到数据后自行完成展示与交互。用 React useState 管理 UI 状态标准 React 状态管理在 Widget 中完全可用。文档给出的完整示例是一个带筛选和排序的商品列表工具提供数据productsWidget 管理 UI 状态selectedCategory、sortByWidget 渲染筛选/排序后的视图无需任何额外的 Tool 调用。import { useState } from react; import { McpUseProvider, useWidget, type WidgetMetadata } from mcp-use/react; import { z } from zod; export const widgetMetadata: WidgetMetadata { description: Product list with filtering, props: z.object({ products: z.array( z.object({ id: z.string(), name: z.string(), category: z.string(), price: z.number(), }), ), }), exposeAsTool: false, }; export default function ProductList() { const { props, isPending } useWidget(); const [selectedCategory, setSelectedCategory] useStatestring(all); const [sortBy, setSortBy] useStatename | price(name); if (isPending) { return ( McpUseProvider autoSize divLoading.../div /McpUseProvider ); } // Filter and sort based on state const filtered selectedCategory all ? props.products : props.products.filter((p) p.category selectedCategory); const sorted [...filtered].sort((a, b) { if (sortBy name) return a.name.localeCompare(b.name); return a.price - b.price; }); const categories [all, ...new Set(props.products.map((p) p.category))]; return ( McpUseProvider autoSize div style{{ padding: 20 }} {/* Category filter */} div style{{ marginBottom: 16 }} {categories.map((cat) ( button key{cat} onClick{() setSelectedCategory(cat)} style{{ padding: 8px 16px, margin: 0 4px, backgroundColor: selectedCategory cat ? #007bff : #f0f0f0, color: selectedCategory cat ? white : black, border: none, borderRadius: 4, cursor: pointer, }} {cat} /button ))} /div {/* Sort controls */} div style{{ marginBottom: 16 }} label Sort by: select value{sortBy} onChange{(e) setSortBy(e.target.value as any)} style{{ marginLeft: 8 }} option valuenameName/option option valuepricePrice/option /select /label /div {/* Product list */} div {sorted.map((product) ( div key{product.id} style{{ padding: 12, border: 1px solid #ddd, marginBottom: 8 }} h3{product.name}/h3 p Category: {product.category} | ${product.price} /p /div ))} /div /div /McpUseProvider ); }模式拆解Tool 提供数据products一次给全Widget 管理 UI 状态selectedCategory、sortByWidget 渲染筛选/排序后的视图注意[...filtered].sort()先拷贝再排序避免修改props数据分类选项直接从 props 动态推导new Set(props.products.map((p) p.category))无需服务端额外提供全程零额外 Tool 调用。一个值得注意的细节exposeAsTool: false是该 Widget 的正确配置。basics.md 明确说明该字段默认就是falseWidget 只作为 resource 注册、通过自定义 Tool 暴露给模型这样能避免重复注册 Tool仓库中 product-search.ts 正是通过widget: { name: product-search-result }把 Widget 与 Tool 绑定的。用 useWidget 的 setState 实现跨交互持久化useWidget()除了props和isPending还提供state和setState是 ReactuseState之外的另一种状态手段区别在于它带有跨 Widget 交互的自动状态持久化能力。完整的useWidget()API 参考见 basics.md 的 useWidget Hook 一节。选择useState还是setState简单、短暂的 UI 状态Widget 卸载即重置→ 用useState需要在多次交互之间保持的状态如用户在上一轮对话中的选择→ 用useWidget的setState。文档对两者的定位非常克制绝大多数筛选、Tab、分页场景用useState就足够setState是需要持久化时的升级选项。选择状态单选与多选单选跟踪哪个条目被选中用useStatestring | null(null)存selectedId点击时高亮import { useState } from react; export default function ItemSelector() { const { props, isPending } useWidget(); const [selectedId, setSelectedId] useStatestring | null(null); if (isPending) return ( McpUseProvider autoSize divLoading.../div /McpUseProvider ); return ( McpUseProvider autoSize div {props.items.map((item) ( div key{item.id} onClick{() setSelectedId(item.id)} style{{ padding: 12, border: 2px solid ${selectedId item.id ? #007bff : #ddd}, marginBottom: 8, cursor: pointer, }} {item.name} /div ))} /div /McpUseProvider ); }多选用Setstring存选中集合切换时先拷贝再修改React 状态不可变更新的标准做法const [selectedIds, setSelectedIds] useStateSetstring(new Set()); const toggleSelection (id: string) { const newSelection new Set(selectedIds); if (newSelection.has(id)) { newSelection.delete(id); } else { newSelection.add(id); } setSelectedIds(newSelection); }; return ( McpUseProvider autoSize div {props.items.map((item) ( div key{item.id} onClick{() toggleSelection(item.id)} style{{ padding: 12, backgroundColor: selectedIds.has(item.id) ? #e3f2fd : white, border: 1px solid #ddd, }} input typecheckbox checked{selectedIds.has(item.id)} readOnly / {item.name} /div ))} /div /McpUseProvider );这个拷贝-修改-替换的 Set 更新模式在后面的展开/折叠示例中还会复用是多选与多展开场景的通用写法。Tab 状态切换视图不触发 Tool 调用多个视图如概览/详情/历史用本地字符串联合类型管理切换 Tab 时只重渲染客户端不产生任何 Tool 调用const [activeTab, setActiveTab] useStateoverview | details | history( overview, ); return ( McpUseProvider autoSize div {/* Tab buttons */} div style{{ borderBottom: 1px solid #ddd, marginBottom: 16 }} {[overview, details, history].map((tab) ( button key{tab} onClick{() setActiveTab(tab as any)} style{{ padding: 8px 16px, border: none, borderBottom: activeTab tab ? 2px solid #007bff : none, background: none, cursor: pointer, }} {tab.charAt(0).toUpperCase() tab.slice(1)} /button ))} /div {/* Tab content */} {activeTab overview div{/* Overview content */}/div} {activeTab details div{/* Details content */}/div} {activeTab history div{/* History content */}/div} /div /McpUseProvider );条件渲染activeTab x ...意味着非激活 Tab 的内容不挂载切换成本极低。分页状态客户端分页大列表数据已经全量到达 Widget这正是Return Complete Data Upfront黄金法则的体现分页就退化为纯粹的客户端sliceconst [currentPage, setCurrentPage] useState(1); const itemsPerPage 10; const totalPages Math.ceil(props.items.length / itemsPerPage); const startIndex (currentPage - 1) * itemsPerPage; const currentItems props.items.slice(startIndex, startIndex itemsPerPage); return ( McpUseProvider autoSize div {/* Items */} div {currentItems.map((item) ( div key{item.id}{item.name}/div ))} /div {/* Pagination controls */} div style{{ marginTop: 16, display: flex, gap: 8 }} button onClick{() setCurrentPage((p) Math.max(1, p - 1))} disabled{currentPage 1} Previous /button span Page {currentPage} of {totalPages} /span button onClick{() setCurrentPage((p) Math.min(totalPages, p 1))} disabled{currentPage totalPages} Next /button /div /div /McpUseProvider );两个实现要点Math.max(1, p - 1)/Math.min(totalPages, p 1)防止页码越界首尾页分别disabled对应按钮。筛选状态组合式复杂筛选当筛选条件超过两个时把多个字段收进一个Filters对象用展开运算符做不可变更新interface Filters { search: string; category: string; priceMin: number; priceMax: number; } const [filters, setFilters] useStateFilters({ search: , category: all, priceMin: 0, priceMax: 1000, }); const filteredItems props.items.filter((item) { if ( filters.search !item.name.toLowerCase().includes(filters.search.toLowerCase()) ) { return false; } if (filters.category ! all item.category ! filters.category) { return false; } if (item.price filters.priceMin || item.price filters.priceMax) { return false; } return true; }); return ( McpUseProvider autoSize div {/* Filter controls */} div style{{ marginBottom: 16 }} input typetext placeholderSearch... value{filters.search} onChange{(e) setFilters({ ...filters, search: e.target.value })} style{{ padding: 8, marginRight: 8 }} / select value{filters.category} onChange{(e) setFilters({ ...filters, category: e.target.value })} style{{ padding: 8, marginRight: 8 }} option valueallAll Categories/option {/* ... category options */} /select input typenumber value{filters.priceMin} onChange{(e) setFilters({ ...filters, priceMin: Number(e.target.value) }) } placeholderMin price style{{ width: 80, padding: 8, marginRight: 8 }} / input typenumber value{filters.priceMax} onChange{(e) setFilters({ ...filters, priceMax: Number(e.target.value) }) } placeholderMax price style{{ width: 80, padding: 8 }} / /div {/* Filtered items */} div {filteredItems.map((item) ( div key{item.id} {item.name} - ${item.price} /div ))} /div /div /McpUseProvider );注意Number(e.target.value)的显式转换——typenumber的 input 返回的是字符串直接混入数值区间比较会出 bug。展开/折叠状态手风琴模式与多选相同的Setstring模式追踪哪些条目处于展开态const [expandedIds, setExpandedIds] useStateSetstring(new Set()); const toggleExpand (id: string) { const newExpanded new Set(expandedIds); if (newExpanded.has(id)) { newExpanded.delete(id); } else { newExpanded.add(id); } setExpandedIds(newExpanded); }; return ( McpUseProvider autoSize div {props.items.map((item) ( div key{item.id} style{{ marginBottom: 8 }} div onClick{() toggleExpand(item.id)} style{{ padding: 12, backgroundColor: #f5f5f5, cursor: pointer, display: flex, justifyContent: space-between, }} span{item.title}/span span{expandedIds.has(item.id) ? ▼ : ▶}/span /div {expandedIds.has(item.id) ( div style{{ padding: 12, border: 1px solid #ddd }} {item.details} /div )} /div ))} /div /McpUseProvider );表单状态提交前的输入跟踪表单字段在提交前只存在于 Widget 内用单一formData对象 通用handleChange处理多字段const [formData, setFormData] useState({ name: , email: , message: , }); const handleChange (field: string, value: string) { setFormData((prev) ({ ...prev, [field]: value })); }; return ( McpUseProvider autoSize form onSubmit{(e) { e.preventDefault(); // Handle submission (see interactivity.md) }} input typetext value{formData.name} onChange{(e) handleChange(name, e.target.value)} placeholderName / input typeemail value{formData.email} onChange{(e) handleChange(email, e.target.value)} placeholderEmail / textarea value{formData.message} onChange{(e) handleChange(message, e.target.value)} placeholderMessage / button typesubmitSend/button /form /McpUseProvider );这里体现了一条清晰的状态边界输入阶段是 Widget 状态提交动作才是 Tool 调用。真正提交表单时应通过useCallTool()发起 Tool 调用——完整的表单提交、乐观更新等交互模式见 interactivity.md。仓库真实代码 product-search.ts 中也注册了配套的数据工具get-fruit-detailsCompanion data tool — called from within the widget via useCallTool展示的就是这种Widget 内部按需调用数据工具的合法用法——注意它请求的是数据不是 UI 状态。状态初始化isPending 下的正确姿势易错点这是全文档最有含金量的一个陷阱。当需要根据异步到达的 props 初始化状态时懒初始化不可行const [selectedCategory, setSelectedCategory] useStatestring(); // Initialize when props load useEffect(() { if (props.categories props.categories.length 0 !selectedCategory) { setSelectedCategory(props.categories[0]); } }, [props.categories, selectedCategory]);文档明确指出原因Note:Lazy initialization likeuseState(() props.categories?.[0] || all)wont work here — on the first renderisPendingistrueandpropsis{}, so the initializer always resolves toall. TheuseEffectpattern above is the correct approach for props that arrive asynchronously.为什么懒初始化必然失败basics.md 给出了 Widget 生命周期Widget 在 Tool 执行完成前就已挂载首帧isPending true且props是空对象{}此时useState的初始化器只跑这一次拿到的永远是{}里的值等 Tool 返回、props 就绪时再重渲染初始化器早已失效。所以依赖异步 props 的默认值必须放在useEffect里等 props 到达后再设置。!selectedCategory的守卫条件则避免覆盖用户已手动选择的值。常见组合模式搜索 筛选 排序流水线多个派生状态时按搜索 → 分类筛选 → 排序的顺序链式应用const [search, setSearch] useState(); const [category, setCategory] useState(all); const [sortBy, setSortBy] useState(name); let filtered props.items; // Apply search if (search) { filtered filtered.filter((item) item.name.toLowerCase().includes(search.toLowerCase()), ); } // Apply category filter if (category ! all) { filtered filtered.filter((item) item.category category); } // Apply sort filtered.sort((a, b) { if (sortBy name) return a.name.localeCompare(b.name); if (sortBy price) return a.price - b.price; return 0; });Master-Detail 视图左侧主列表 右侧详情面板selectedId驱动两侧联动const [selectedId, setSelectedId] useStatestring | null(null); const selectedItem selectedId ? props.items.find((item) item.id selectedId) : null; return ( div style{{ display: flex, gap: 16 }} {/* Master list */} div style{{ flex: 1 }} {props.items.map((item) ( div key{item.id} onClick{() setSelectedId(item.id)} style{{ padding: 12, backgroundColor: selectedId item.id ? #e3f2fd : white, }} {item.name} /div ))} /div {/* Detail panel */} div style{{ flex: 2 }} {selectedItem ? ( div h2{selectedItem.name}/h2 p{selectedItem.description}/p /div ) : ( pSelect an item to view details/p )} /div /div );详情面板不发起第二次 Tool 请求selectedItem直接从props.items中find这要求 Tool 返回的数据自带详情——与 SKILL.md 黄金法则 2Return Complete Data Upfront一脉相承。反模式三条红线文档明确列出三类必须避免的错误均对应状态边界被破坏1. 不要为 UI 状态创建 Tool// ❌ Bad - Tool for UI state server.tool( { name: set-filter, schema: z.object({ category: z.string() }) }, async ({ category }) { // This is wrong! Filters should be widget state }, ); // ✅ Good - Widget manages its own filters const [filter, setFilter] useState(all);2. 不要用 Tool 调用做客户端筛选/排序// ❌ Bad - Using a tool call for client-side filtering const { callTool: filterItems } useCallTool(filter-items); button onClick{() filterItems({ category: electronics })} Filter /button // ✅ Good - Filter in widget button onClick{() setCategory(electronics)} Filter /buttonuseCallTool本身是合法工具见 interactivity.md红线在于调用目的请求数据/执行动作可以搬运 UI 状态不行。3. 不要把 UI 状态写进 props// ❌ Bad - Trying to mutate props props.selectedId 123; // Error! Props are read-only // ✅ Good - Use state const [selectedId, setSelectedId] useStatestring | null(null);props 是 Tool 响应的只读快照任何回写既无意义也不被允许。最佳实践清单原文档总结的五条实践状态保持局部——非必要不上提lift状态从 props 初始化——props 作为初始数据来源UI 交互用 state命名要描述性——selectedCategory而不是filter适时重置——props 变化时同步更新依赖状态避免多余重渲染——昂贵计算用useMemo缓存如上面的搜索筛选排序链。延伸阅读与仓库代码索引围绕 Widget 状态mcp-apps-builder 技能文档的完整脉络是basics.mdWidget 结构、useWidget()Hook 完整 API、isPending生命周期、McpUseProvider用法interactivity.mduseCallTool()交互、表单提交、动作按钮、乐观更新ui-guidelines.md主题样式useWidgetTheme()、明暗模式、autoSize布局advanced.md异步数据、错误边界、memoization、代码分割等高级模式。仓库内可直接运行的参照实现index.tsMCP 服务器入口文件头注释完整说明了新建一个 Widget 应用的三步流程创建resources/widget-name/widget.tsx、创建tools/tool-name.ts、在入口注册tools/product-search.tsWidget 工具search-tools 配套数据工具get-fruit-details的成对注册示例展示了widget({ props, output: text(...) })返回形态与invoking/invoked加载文案SKILL.md技能总纲与决策树其中Common Mistakes部分列出的❌ Widget handles server state (filters, selections)与本文主题互为印证。适用前提以上模式基于 mcp-use 框架仓库mcp-use-server使用mcp-use^1.22.3 React 19 Zod 4Widget 在 iframe 中渲染、通过McpUseProvider autoSize自适应尺寸useCallTool的类型自动推导依赖mcp-use dev/mcp-use build生成的.mcp-use/tool-registry.d.ts。在其他 MCP 客户端如 CopilotKit 的 MCP Apps 渲染链路见 open-mcp-client 的 web 应用中消费 Widget 时同样的状态留在 Widget 内原则依然适用因为状态管理发生在 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),仅供参考
返回列表