使用 mcp-use 构建 MCP Tools:从 Zod Schema 定义到 Widget 与安全加固实战
2026/9/10 23:07:30 网站建设 项目流程

使用 mcp-use 构建 MCP Tools:从 Zod Schema 定义到 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

导读

在 mcp-use 框架中,Tools(工具)是 AI 可以直接调用的后端动作:它们接收结构化输入、执行业务逻辑并返回格式化输出,是 MCP Server 中最核心的能力载体。本文以 CopilotKit 仓库open-mcp-client示例中内置的mcp-apps-builder技能文档为主体,结合仓库内mcp-use-server的真实源码(入口文件、工具文件、构建脚本),系统讲解工具的定义规范、Zod 输入校验、注解(Annotations)、上下文能力、错误处理、Widget 视觉返回、输出 Schema 校验、环境变量、性能优化与安全检查清单。读完本文,你将能够用 mcp-use 写出一个生产级、可被 AI 正确理解和调用的 MCP 工具集。


一、工具的本质:AI 的“后端动作”

在 MCP(Model Context Protocol)的四大原语中,工具承担的是有输入、有输出的后端操作

  • Tools:动作、操作、API 调用、数据变更、数据获取(本文主题);
  • Resources:客户端可获取的只读数据;
  • Prompts:可复用的消息模板;
  • Widgets:可视化交互界面。

mcp-apps-builder技能文档(SKILL.md)的核心原则可以提炼出分工定位:“Tools for actions”——凡是需要执行动作的场景都应建模为工具。一个工具由三部分组成:配置(name / description / schema)、异步 handler 函数、以及返回的响应助手(response helper)。

在仓库的 mcp-use-server/index.ts 中可以看到一个最小服务器的完整骨架:通过new MCPServer({...})创建实例,随后逐个注册工具,最后server.listen(port)启动服务。open-mcp-client示例里的mcp-use-server就是按“每个工具一个tools/<tool-name>.ts文件、导出register(server)函数”的约定组织的,新增工具只需三步:在resources/<widget-name>/widget.tsx写 UI、在tools/<tool-name>.ts里调用server.tool()、再在入口文件两处标记位置完成 import 与 register。

二、第一个工具:完整的最小示例

import { MCPServer, text } from "mcp-use/server"; import { z } from "zod"; const server = new MCPServer({ name: "my-server", version: "1.0.0", baseUrl: process.env.MCP_URL || "http://localhost:3000", }); server.tool( { name: "send-email", description: "Send an email to a user", schema: z.object({ to: z.string().email().describe("Recipient email address"), subject: z.string().describe("Email subject line"), body: z.string().describe("Email body content"), priority: z .enum(["low", "normal", "high"]) .optional() .describe("Email priority"), }), }, async ({ to, subject, body, priority = "normal" }) => { // Your logic here await sendEmail(to, subject, body, priority); return text(`Email sent to ${to}`); }, );

关键点:

  • 第一个参数:工具配置对象,包含namedescriptionschema
  • 第二个参数:异步 handler 函数,接收的入参已经被 Zod 校验过,类型与 schema 完全匹配;
  • 返回值:必须使用响应助手(text()object()widget()等),不能直接返回裸对象。响应助手负责设置正确的 MIME 类型、保证序列化正确、支持客户端渲染与多内容响应(详见 response-helpers.md)。

在仓库的真实实现 tools/product-search.ts 中,search-tools工具就是完全遵循这一形态:name+description+schemaquery字段为可选字符串并带.describe()),handler 内先按关键字过滤水果目录、用setTimeout模拟 2 秒网络延迟以演示 Widget 的加载态,最后返回widget({ props, output })

三、工具定义规范:让 AI 正确理解你的工具

3.1 命名(Name)

  • 使用kebab-casesend-emailfetch-usercreate-todo
  • 一个工具只做一件事:❌manage-users→ ✅create-userdelete-userlist-users
  • 名称要具体,避免模糊的“总括式”命名。

3.2 描述(Description)

写清楚、可执行的描述,说明工具做什么,AI 依赖这段描述决定“何时调用你的工具”:

✅ "Send an email to a user with subject and body" ❌ "Email tool"

3.3 Schema(Zod)

每个字段都必须使用.describe(),这直接决定 AI 能否正确生成入参:

// ✅ Good z.object({ city: z.string().describe("City name (e.g., 'New York', 'Tokyo')"), units: z .enum(["celsius", "fahrenheit"]) .optional() .describe("Temperature units"), limit: z.number().min(1).max(50).optional().describe("Max results to return"), }); // ❌ Bad - no descriptions z.object({ city: z.string(), units: z.string(), limit: z.number(), });

Schema 最佳实践:

  • 非必填字段用.optional()
  • 加上校验规则:.min().max().email().url()
  • 固定取值集合用z.enum(),不要用z.string()
  • 列表用z.array()
  • 键值映射用z.record()

四、工具注解(Tool Annotations):声明工具性质以保护用户

通过annotations声明工具的行为性质,客户端可以据此向用户发出提示(例如危险操作前二次确认):

server.tool( { name: "delete-user", description: "Permanently delete a user account", schema: z.object({ userId: z.string().describe("User ID") }), annotations: { destructiveHint: true, // Deletes or overwrites data readOnlyHint: false, // Has side effects openWorldHint: false, // Stays within user's account (not external APIs) }, }, async ({ userId }) => { await deleteUser(userId); return text(`User ${userId} deleted`); }, );

三个注解的语义:

  • destructiveHint: true—— 会删除/覆盖数据,客户端可能要求用户确认;
  • readOnlyHint: true—— 无副作用,可安全重复调用;
  • openWorldHint: true—— 会调用用户控制范围之外的外部 API 或服务。

五、工具上下文(Tool Context):进度、日志与 LLM 协作

handler 的第二个参数ctx提供高级能力,包括进度上报、结构化日志、请求 LLM 辅助以及能力探测:

server.tool( { name: "process-large-file", schema: z.object({ fileUrl: z.string().describe("URL to file") }), }, async ({ fileUrl }, ctx) => { // Progress reporting await ctx.reportProgress?.(0, 100, "Starting download..."); const file = await downloadFile(fileUrl); await ctx.reportProgress?.(50, 100, "Processing..."); const result = await processFile(file); // Structured logging await ctx.log("info", `Processed ${file.size} bytes`); // Structured logging with additional context (optional third parameter) await ctx.log( "info", "Processing complete", `fileSize: ${file.size} bytes, duration: 2.5s`, ); // Check client capabilities if (ctx.client.can("sampling")) { // Ask the LLM to help analyze results const summary = await ctx.sample(`Summarize this data: ${result}`); return text(summary); } await ctx.reportProgress?.(100, 100, "Complete"); return object(result); }, );

上下文方法一览:

  • ctx.reportProgress(current: number, total: number, message: string)—— 向用户展示进度;
  • ctx.log(level: "debug" | "info" | "warn" | "error", message: string, data?: string)—— 结构化日志,第三参数为可选的附加上下文字符串;
  • ctx.sample(prompt: string)—— 请求 LLM 协助(需要客户端支持);
  • ctx.client.can(capability: string)—— 探测客户端是否支持某项能力。

六、错误处理:返回error()而不是抛出异常

永远用error()助手优雅地返回失败,不要 throw——抛出的异常会让客户端看到原始错误:

import { text, error } from "mcp-use/server"; server.tool( { name: "fetch-user", schema: z.object({ id: z.string() }) }, async ({ id }) => { try { const user = await fetchUser(id); if (!user) { return error(`User not found: ${id}`); } return object(user); } catch (err) { // Log for debugging console.error("Failed to fetch user:", err); // Return error to client return error( `Failed to fetch user: ${err instanceof Error ? err.message : "Unknown error"}`, ); } }, );

错误处理规则:

  • ✅ 用error()返回优雅失败;
  • ❌ 不要抛出异常(客户端看到的是原始错误);
  • ✅ 错误消息中带上有助于排查的上下文;
  • ✅ 在服务端记录日志便于调试。

七、工具 + Widget:让工具返回可视化 UI

当工具需要返回视觉界面时(浏览列表、对比条目、交互式选择),使用widget

import { widget, text } from "mcp-use/server"; server.tool( { name: "search-products", description: "Search products by keyword", schema: z.object({ query: z.string().describe("Search query"), }), widget: { name: "product-list", // Must match resources/product-list.tsx invoking: "Searching products...", invoked: "Products loaded", }, }, async ({ query }) => { const products = await searchProducts(query); return widget({ props: { products, query, totalCount: products.length, }, output: text(`Found ${products.length} products matching "${query}"`), }); }, );

Widget 工具的要求:

  • 在工具配置中增加widget: { name }
  • handler 返回widget({ props, output })
  • 创建同名 Widget 文件:resources/{name}.tsx
  • exposeAsTool默认为false,此种模式下省略该字段是正确写法。

关于widget()响应的结构:props是发给 Widget 组件的可视化数据,outputAI 模型真正看到的文本/对象摘要。完整的响应助手清单与 Widget 实现可参考 response-helpers.md 与 widgets/basics.md。

仓库 tools/product-search.ts 就是这一模式的完整落地:search-tools返回widget({ props: { query, results }, output: text(...) })渲染水果搜索结果轮播界面;而配套的get-fruit-details是一个纯数据工具,专门供 Widget 内部通过useCallTool("get-fruit-details")调用,二者组成“视觉入口 + 数据后盾”的组合。

八、结构化输出 Schema:运行时校验工具输出

在工具配置中加入outputSchema,可在运行时验证返回值形状:

server.tool( { name: "calculate-stats", schema: z.object({ data: z.array(z.number()).describe("Array of numbers"), }), outputSchema: z.object({ mean: z.number(), median: z.number(), stdDev: z.number(), count: z.number(), }), }, async ({ data }) => { const stats = calculateStats(data); // Output is validated against outputSchema return object({ mean: stats.mean, median: stats.median, stdDev: stats.stdDev, count: data.length, }); }, );

何时使用outputSchema

  • 需要对工具输出做运行时校验;
  • 多条代码路径可能返回不同的形状;
  • 排查输出结构不一致的问题。

仓库中的get-fruit-details工具即为范例:outputSchema声明{ fruit, color, facts }的结构(其中factsz.array(z.string())),handler 始终按此形状返回object({...})

九、环境变量:安全处理密钥与配置

绝不能把密钥硬编码进代码。API 密钥一律通过process.env读取,并在缺失时给出明确的错误提示:

// index.ts const WEATHER_API_KEY = process.env.WEATHER_API_KEY; server.tool( { name: "get-weather", schema: z.object({ city: z.string() }), }, async ({ city }) => { if (!WEATHER_API_KEY) { return error( "WEATHER_API_KEY not configured. Please set it in environment variables.", ); } const data = await fetch( `https://api.weather.com/v1?key=${WEATHER_API_KEY}&city=${city}`, ); // ... rest of logic }, );

最佳实践:

  • ❌ 永远不要硬编码密钥;
  • ✅ 使用process.env.VAR_NAME
  • ✅ 检查必需变量是否已设置;
  • ✅ 在.env.example中记录所有必需变量。

.env.example示例:

# Weather API key (get from weatherapi.com) WEATHER_API_KEY= # Database connection string DATABASE_URL=

在仓库open-mcp-client示例根目录同样提供有 .env.example 文件,用于记录整个多应用编排所需的全部环境变量,可作为团队协作时的模板参考。

十、性能模式:缓存与限流

10.1 缓存(Caching)

为昂贵的操作加缓存,例如按城市缓存 5 分钟的天气数据:

const cache = new Map<string, { data: any; expires: number }>(); server.tool( { name: "fetch-weather", schema: z.object({ city: z.string() }) }, async ({ city }) => { const cacheKey = `weather:${city}`; const cached = cache.get(cacheKey); // Return cached data if not expired if (cached && cached.expires > Date.now()) { return object(cached.data); } // Fetch fresh data const data = await fetchWeather(city); // Cache for 5 minutes cache.set(cacheKey, { data, expires: Date.now() + 5 * 60 * 1000, }); return object(data); }, );

10.2 限流(Rate Limiting)

使用hono-rate-limiter防止滥用。注意 key 生成需适配托管环境(反向代理 IP 头、CDN 头等):

import { rateLimiter } from "hono-rate-limiter"; server.use( rateLimiter({ windowMs: 15 * 60 * 1000, // 15 minutes limit: 100, // 100 requests per window per key keyGenerator: (c) => c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("cf-connecting-ip") ?? c.req.header("x-real-ip") ?? "unknown", }), );

请根据你的托管环境调整 key generator。

重要兼容性说明:mcp-use 构建在 Hono 之上,因此请使用 Hono 兼容的中间件;Express 中间件(例如express-rate-limit不兼容。自定义中间件与高级路由可参考 foundations/architecture.md。

十一、部署前安全检查清单

在部署工具之前逐项核对:

  • 所有 schema 字段都有.describe()
  • 使用 Zod 做输入校验
  • 用户输入已消毒(无 SQL 注入、XSS)
  • API 密钥存放在环境变量中
  • 错误通过error()助手返回(而非 throw)
  • 异步操作包裹 try/catch
  • 昂贵操作用限流保护
  • 破坏性操作标记destructiveHint: true

mcp-apps-builder技能文档(SKILL.md)还总结了四条“黄金法则”,与本清单互补:一个工具一个能力一次性返回完整数据(工具调用昂贵,避免list-products+get-product-details的两次往返,应让list-products直接返回含详情的数据);Widget 自己管理 UI 状态(不要在服务端造select-itemset-filter这类工具);只在边界做校验(信任内部代码与框架保证,校验用户输入与外部 API 响应)。

十二、仓库实战:在 open-mcp-client 中按此规范组织工具

上述规范并非纸上谈兵——open-mcp-client示例中的mcp-use-server应用正是按这套约定落地的,可作为“可运行的教科书”对照学习:

  1. 入口文件apps/mcp-use-server/index.ts 定义了服务器元信息(nameversionbaseUrlfaviconicons等),通过register(server)聚合各工具文件,并以server.listen(parseInt(process.env.PORT ?? "3109", 10))启动;
  2. 工具文件apps/mcp-use-server/tools/product-search.ts 遵循“一文件一工具集”,同时演示了带_meta["ui/previewData"]预览数据的 Widget 工具(供 MCP UI Studio 在未发起真实调用时预览界面)与带outputSchema的数据工具;
  3. 构建与运行脚本见 apps/mcp-use-server/package.json:npm run dev会先执行mcp-use build --inline(内联编译 Widget),再以tsx启动开发服务并支持热重载;mcp-use deploy可直接部署到托管云;postinstall中的mcp-use generate-types会自动生成类型定义。

启动开发服务器的方式:

cd examples/showcases/open-mcp-client/apps/mcp-use-server npm install npm run dev

随后可在浏览器打开http://localhost:3109/inspector使用内置 Inspector 测试工具调用。

十三、下一步

  • 格式化响应→ response-helpers.md
  • 添加可视化 UI→ widgets/basics.md
  • 查看更多端到端示例→ patterns/common-patterns.md
  • 系统了解 MCP 原语定位→ foundations/concepts.md

【免费下载链接】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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询