CopilotKit open-mcp-client:mcp-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 State(Widget State):由 Widget 用
useState或setState管理,包括当前选中项、激活 Tab、筛选设置、排序方式、分页页码、展开/折叠状态、提交前的表单输入值; - Server State(Tool State):由服务器管理并在 Tool 响应中返回,包括条目列表、用户数据、API 结果、计算结果、数据库查询。
这一原则并非孤立存在,而是 mcp-use 技能体系"Golden Rules"的一部分。SKILL.md 中列出的第 3 条黄金法则正是:
3. Widgets Own Their State
UI state lives in the widget, not in separate tools:
- ❌
select-itemtool,set-filtertool- ✅ Widget manages with
useStateorsetState
同时它还解释了为什么这样设计——Tool 调用是昂贵的("Tool calls are expensive"),每一次状态变更若都走一次 Tool 往返,都会引入网络延迟和 Token 成本;而 UI 状态变化本应在客户端毫秒级完成。仓库中的真实服务器 tools/product-search.ts 印证了这种分层:search-tools工具负责触发 Widget UI 并返回全量results(服务端状态一次给足),Widget 拿到数据后自行完成展示与交互。
用 React useState 管理 UI 状态
标准 React 状态管理在 Widget 中完全可用。文档给出的完整示例是一个"带筛选和排序的商品列表":工具提供数据(products),Widget 管理 UI 状态(selectedCategory、sortBy),Widget 渲染筛选/排序后的视图,无需任何额外的 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] = useState<string>("all"); const [sortBy, setSortBy] = useState<"name" | "price">("name"); if (isPending) { return ( <McpUseProvider autoSize> <div>Loading...</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 value="name">Name</option> <option value="price">Price</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、sortBy); - Widget 渲染筛选/排序后的视图(注意
[...filtered].sort()先拷贝再排序,避免修改props数据); - 分类选项直接从 props 动态推导(
new Set(props.products.map((p) => p.category))),无需服务端额外提供; - 全程零额外 Tool 调用。
一个值得注意的细节:exposeAsTool: false是该 Widget 的正确配置。basics.md 明确说明该字段默认就是false,Widget 只作为 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是"需要持久化"时的升级选项。
选择状态:单选与多选
单选
跟踪哪个条目被选中,用useState<string | null>(null)存selectedId,点击时高亮:
import { useState } from "react"; export default function ItemSelector() { const { props, isPending } = useWidget(); const [selectedId, setSelectedId] = useState<string | null>(null); if (isPending) return ( <McpUseProvider autoSize> <div>Loading...</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> ); }多选
用Set<string>存选中集合,切换时先拷贝再修改(React 状态不可变更新的标准做法):
const [selectedIds, setSelectedIds] = useState<Set<string>>(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 type="checkbox" checked={selectedIds.has(item.id)} readOnly /> {item.name} </div> ))} </div> </McpUseProvider> );这个"拷贝-修改-替换"的 Set 更新模式在后面的展开/折叠示例中还会复用,是多选与多展开场景的通用写法。
Tab 状态:切换视图不触发 Tool 调用
多个视图(如概览/详情/历史)用本地字符串联合类型管理,切换 Tab 时只重渲染客户端,不产生任何 Tool 调用:
const [activeTab, setActiveTab] = useState<"overview" | "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"黄金法则的体现),分页就退化为纯粹的客户端slice:
const [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] = useState<Filters>({ 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 type="text" placeholder="Search..." 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 value="all">All Categories</option> {/* ... category options */} </select> <input type="number" value={filters.priceMin} onChange={(e) => setFilters({ ...filters, priceMin: Number(e.target.value) }) } placeholder="Min price" style={{ width: 80, padding: 8, marginRight: 8 }} /> <input type="number" value={filters.priceMax} onChange={(e) => setFilters({ ...filters, priceMax: Number(e.target.value) }) } placeholder="Max 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)的显式转换——type="number"的 input 返回的是字符串,直接混入数值区间比较会出 bug。
展开/折叠状态:手风琴模式
与多选相同的Set<string>模式,追踪哪些条目处于展开态:
const [expandedIds, setExpandedIds] = useState<Set<string>>(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 type="text" value={formData.name} onChange={(e) => handleChange("name", e.target.value)} placeholder="Name" /> <input type="email" value={formData.email} onChange={(e) => handleChange("email", e.target.value)} placeholder="Email" /> <textarea value={formData.message} onChange={(e) => handleChange("message", e.target.value)} placeholder="Message" /> <button type="submit">Send</button> </form> </McpUseProvider> );这里体现了一条清晰的状态边界:输入阶段是 Widget 状态,提交动作才是 Tool 调用。真正提交表单时应通过useCallTool()发起 Tool 调用——完整的表单提交、乐观更新等交互模式见 interactivity.md。仓库真实代码 product-search.ts 中也注册了配套的数据工具get-fruit-details("Companion data tool — called from within the widget via useCallTool"),展示的就是这种"Widget 内部按需调用数据工具"的合法用法——注意它请求的是数据,不是 UI 状态。
状态初始化:isPending 下的正确姿势(易错点)
这是全文档最有含金量的一个陷阱。当需要"根据异步到达的 props 初始化状态"时,懒初始化不可行:
const [selectedCategory, setSelectedCategory] = useState<string>(""); // Initialize when props load useEffect(() => { if (props.categories && props.categories.length > 0 && !selectedCategory) { setSelectedCategory(props.categories[0]); } }, [props.categories, selectedCategory]);文档明确指出原因:
Note:Lazy initialization like
useState(() => props.categories?.[0] || "all")won't work here — on the first renderisPendingistrueandpropsis{}, so the initializer always resolves to"all". 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] = useState<string | 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> ) : ( <p>Select an item to view details</p> )} </div> </div> );详情面板不发起第二次 Tool 请求(selectedItem直接从props.items中find),这要求 Tool 返回的数据"自带详情"——与 SKILL.md 黄金法则 2"Return 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 </button>useCallTool本身是合法工具(见 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] = useState<string | null>(null);props 是 Tool 响应的只读快照,任何"回写"既无意义也不被允许。
最佳实践清单
原文档总结的五条实践:
- 状态保持局部——非必要不上提(lift)状态;
- 从 props 初始化——props 作为初始数据来源,UI 交互用 state;
- 命名要描述性——
selectedCategory而不是filter; - 适时重置——props 变化时同步更新依赖状态;
- 避免多余重渲染——昂贵计算用
useMemo缓存(如上面的搜索+筛选+排序链)。
延伸阅读与仓库代码索引
围绕 Widget 状态,mcp-apps-builder 技能文档的完整脉络是:
- basics.md:Widget 结构、
useWidget()Hook 完整 API、isPending生命周期、McpUseProvider用法; - interactivity.md:
useCallTool()交互、表单提交、动作按钮、乐观更新; - ui-guidelines.md:主题样式(
useWidgetTheme())、明暗模式、autoSize布局; - advanced.md:异步数据、错误边界、memoization、代码分割等高级模式。
仓库内可直接运行的参照实现:
- index.ts:MCP 服务器入口,文件头注释完整说明了"新建一个 Widget 应用"的三步流程(创建
resources/<widget-name>/widget.tsx、创建tools/<tool-name>.ts、在入口注册); - tools/product-search.ts:Widget 工具(
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 4),Widget 在 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),仅供参考