给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑

给 Claude Code 造个趁手的 MCP Tool Server,聊聊我踩的那些坑

搞前端工具链搞了好几年,组件库、设计稿、代码模板这些东西散落在各个系统里。每次新需求来了,得先翻组件库找有没有现成的,再去 Figma 看设计稿,最后手动糊代码。这套流程重复了几百次之后,终于忍不了了------能不能让 AI 帮我把这几步串起来?

一、Tool Schema 定义:看着简单,但你大概率会在这翻车

这块我花的时间最多,也是最想吐槽的部分。

MCP 的 Tool 定义看起来跟写个 JSON Schema 一样------给工具起个名字,声明入参类型,完事。但实际跑起来你就会发现,Schema 写得好不好,直接决定了 LLM 能不能正确调用你的工具。这不是"能用就行"的问题,是"差一点就完全不可用"的问题。

参数命名比你想的重要十倍

先说个真实场景。我做了个组件检索工具,第一版 Schema 长这样:

typescript 复制代码
// 组件检索工具 ------ 第一版,能跑但 LLM 经常调错
const tool = {
  name: "search_components",
  description: "搜索组件库中的组件",
  inputSchema: {
    type: "object",
    properties: {
      q: { type: "string", description: "搜索关键词" },
      t: { type: "string", enum: ["ui", "biz", "chart"] },
      limit: { type: "number" }
    },
    required: ["q"]
  }
}

跑了几次发现 Claude 经常不传 t 参数,或者把 q 理解错。改成下面这样之后命中率直接从六成拉到九成以上:

typescript 复制代码
const tool = {
  name: "search_ui_components",
  description: "在团队组件库中按名称或用途搜索可复用的 UI/业务组件,返回组件名、Props 定义和使用示例",
  inputSchema: {
    type: "object",
    properties: {
      keyword: {
        type: "string",
        description: "组件名称或使用场景,比如 'Table'、'用户选择器'、'数据筛选'"
      },
      category: {
        type: "string",
        enum: ["ui-base", "business", "chart"],
        description: "组件分类:ui-base=基础UI组件, business=业务组件, chart=图表组件"
      },
      max_results: {
        type: "number",
        description: "最多返回几个结果,默认5"
      }
    },
    required: ["keyword"]
  }
}

区别在哪?三个地方:工具名自带语义search_componentssearch_ui_components),参数名是人话qkeyword),description 里给了具体例子

这不是什么高深的道理,但你不踩一遍坑真的意识不到------LLM 理解你工具的唯一信息源就是 Schema 里的文本。你偷懒少写一个 description,它就得靠猜,猜错的概率远比你想的高。

嵌套参数的深度控制

还有个坑是参数结构太深。一开始我想着把过滤条件做得灵活一点:

yaml 复制代码
// 伪代码,别照抄
inputSchema: {
  filter: {
    platform: { os: string, version: string },
    style: { theme: string, size: enum },
    compatibility: { frameworks: string[], browsers: string[] }
  }
}

三层嵌套,参数十几个。结果 LLM 基本上构造不出正确的调用------它倒是能理解每个字段的意思,但组装成完整的嵌套 JSON 时总会漏字段或者层级搞错。

后来拍扁成一层:

typescript 复制代码
// 全部拍平,宁可参数多一点也不要嵌套
inputSchema: {
  type: "object",
  properties: {
    keyword: { type: "string" },
    platform: { type: "string", enum: ["web", "mobile", "desktop"] },
    theme: { type: "string", enum: ["light", "dark"] },
    framework: { type: "string", enum: ["react", "vue", "angular"] },
    max_results: { type: "number" }
  },
  required: ["keyword"]
}

经验就一句话:Schema 嵌套不超过一层,参数不超过 6-7 个。超了就拆成多个工具。你可能觉得"一个工具能干的事为什么要拆成三个",但对 LLM 来说,三个简单工具比一个复杂工具好使得多。

多工具协作时的命名空间问题

项目里最终搞了七八个工具:搜组件、查设计稿、生成代码、查 API 文档......工具一多,命名冲突和语义模糊的问题就来了。

比如 search_componentssearch_docs 都有个 keyword 参数,但前者期望的是组件名,后者期望的是 API 名。LLM 有时候会搞混到底该调哪个。

招很粗暴------给工具名加前缀:comp_searchdoc_searchfigma_parsecode_gen

这块我还没想透的是,当工具数量超过 15 个以后,LLM 的工具选择准确率是不是会断崖式下降。目前我控制在 10 个以内没出过问题,但如果以后要接更多系统进来,可能得做工具的动态加载------根据当前对话上下文只暴露相关的工具子集。先放着,等真到那一步再说。

二、Claude Code 集成:没你想的那么复杂

配置 MCP Server 接入 Claude Code 这步反而没什么好说的,比想象中顺滑。

在项目根目录的 .mcp.json 里声明一下就行:

json 复制代码
{
  "mcpServers": {
    "frontend-toolkit": {
      "command": "node",
      "args": ["./mcp-server/index.mjs"],
      "env": {
        "COMPONENT_LIB_PATH": "./src/components",
        "FIGMA_TOKEN": "${FIGMA_TOKEN}"
      }
    }
  }
}

Server 端用 @modelcontextprotocol/sdk 起一个 stdio 类型的服务就行。真正要注意的只有一件事:错误处理必须返回结构化信息,不能直接 throw

typescript 复制代码
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  try {
    const result = await handleTool(request.params)
    return { content: [{ type: "text", text: JSON.stringify(result) }] }
  } catch (e) {
    // 不要 throw,返回 isError
    // 不然 Claude Code 会直接断开连接,你连错误信息都看不到
    return {
      content: [{ type: "text", text: `工具执行失败: ${e.message}` }],
      isError: true
    }
  }
})

三、组件检索这个场景值得单独说说

组件检索看着是最简单的功能------不就是个搜索嘛。但要做到 LLM 能真正用起来,返回的数据格式得反复调。

关键发现:返回给 LLM 的组件信息,多了不行少了也不行

一开始我把组件的完整源码都返回了,结果 context 直接爆掉。后来只返回组件名和一句话描述,又太少了,LLM 没法判断这个组件到底能不能用。

最后稳定下来的格式大概是这样------组件名、Props 类型定义(只保留 public 的)、一个最简使用示例、适用场景的一句话说明。差不多 30-50 行的信息量,刚好够 LLM 判断要不要用、怎么用。

四、工作流编排:别一上来就想做大做全

最后说说怎么把组件检索、设计稿解析、代码生成串成一条工作流。

一个典型场景:产品丢过来一个 Figma 链接,说"照这个做"。理想的流程是------解析设计稿拿到结构 → 匹配已有组件 → 生成代码。听着挺丝滑,但实际编排的时候有个根本性的取舍要做。

自动编排 vs 人工确认

第一种思路是全自动:在 MCP Server 内部把三步串起来,对外只暴露一个 figma_to_code 工具,一步到位。

第二种思路是拆开:暴露 figma_parsecomp_searchcode_gen 三个独立工具,让 Claude 自己决定调用顺序,每一步人都能看到中间结果。

我最开始选了第一种,因为显然更"优雅"。跑了一周之后切回了第二种。

原因很现实------全自动流程一旦中间某步出错(比如设计稿里有个自定义图标组件库里没有),整条链路就废了,返回一个笼统的错误信息,你还得去翻日志看是哪步挂的。拆开之后,Claude 调完 figma_parse 会先把结构展示出来,你扫一眼说"这几个组件用现有的,那个图标先跳过",它再去调 comp_search,灵活得多。

这个取舍背后的道理其实很通用:

复制代码
工具编排的粒度选择:

粗粒度(一个工具做完所有事)
  优点:调用简单,LLM 决策少
  缺点:中间过程不可见,出错难排查,灵活性差

细粒度(每步一个工具)
  优点:中间结果可检查,人能介入,组合灵活
  缺点:LLM 需要自己编排调用顺序,偶尔会走弯路

实际选择:先细后粗
  先用细粒度工具跑通流程
  等流程稳定了,再把高频组合包装成粗粒度工具
  两套并存,简单场景用粗的,复杂场景用细的

返回值设计影响下一步决策

还有个容易忽略的细节------上一个工具的返回值格式,直接影响 LLM 下一步能不能做出正确决策。

figma_parse 返回的设计稿结构里,我专门加了一个 suggestedComponent 字段:

typescript 复制代码
// figma_parse 返回的结构(简化版)
{
  layers: [
    {
      name: "顶部导航",
      type: "frame",
      suggestedComponent: "NavBar",  // 这个字段是给 LLM 看的提示
      children: [...]
    },
    {
      name: "数据表格",
      type: "frame",
      suggestedComponent: "DataTable",
      props: { columns: 5, hasFilter: true }
    }
  ]
}

这个 suggestedComponent 不是 Figma 原生的,是我在解析层做的一层映射------根据图层命名和结构特征猜一个可能的组件名。猜对了 LLM 直接拿去搜,猜错了也没关系,LLM 会根据搜索结果自行调整。

但如果不加这个字段,LLM 就得自己从图层名"顶部导航"推断出应该搜"NavBar",这步推断的准确率大概七成,加了字段之后变成九成。一个小字段,效果差很多。

这让我意识到一件事:设计 MCP 工具的时候,不能只想"这个工具给人用该返回什么",得想"给 LLM 用该返回什么"。有时候需要多返回一些冗余信息,专门用来降低 LLM 的推理难度。这跟传统 API 设计的"返回最少够用的信息"正好相反。

说到底,MCP Tool Server 就是给 AI 用的 API。但"给 AI 用"和"给人用"的设计直觉差异比想象中大------Schema 要更语义化,参数要更扁平,返回值要更冗余,错误信息要更具体。把这几条刻进脑子里,剩下的都是体力活。

相关推荐
yuki_uix1 小时前
深拷贝:JavaScript 引用类型的完全复制之道
前端·javascript
默默学前端2 小时前
JavaScript 中 call、apply、bind 的区别
开发语言·前端·javascript
℘团子এ2 小时前
vue3中,el-table表格固定列后出现表格线段折断的问题
javascript·vue.js·elementui
馬致远3 小时前
Win7 配置 Vue脚手架
javascript·vue.js·ecmascript
一见3 小时前
WorkBuddy安装Skill的方法
android·java·javascript
badhope3 小时前
GitHub热门AI技能Top20实战指南
前端·javascript·人工智能·git·python·github·电脑
神秘的猪头3 小时前
🚀 深入浅出 Event Loop:带你彻底搞懂 JS 执行机制
前端·javascript·面试
爱宇阳3 小时前
Swiper 12 全屏滚动:优雅处理最后一屏高度不一致的问题
前端·javascript·vue.js