给 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_components → search_ui_components),参数名是人话 (q → keyword),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_components 和 search_docs 都有个 keyword 参数,但前者期望的是组件名,后者期望的是 API 名。LLM 有时候会搞混到底该调哪个。
招很粗暴------给工具名加前缀:comp_search、doc_search、figma_parse、code_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_parse、comp_search、code_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 要更语义化,参数要更扁平,返回值要更冗余,错误信息要更具体。把这几条刻进脑子里,剩下的都是体力活。