MCP Server 开发踩坑:我用 3 行代码解决了 TypeScript 工具函数无法被 LLM 识别的问题
MCP Server 开发踩坑:我用 3 行代码解决了 TypeScript 工具函数无法被 LLM 识别的问题
搞了一下午,终于搞明白为什么我的 MCP Server 工具函数死活不被 Claude 识别。
坦白讲,MCP 协议本身的文档写得挺清楚,但 TypeScript SDK 在工具注册这个环节,藏了一个不大不小的坑。记录一下,免得后面的人继续踩。
问题:工具注册了,但 LLM 说"没有可用工具"
上周接到一个需求,要把内部的一些运维脚本封装成 MCP Server,让同事们通过 Claude 或 Cursor 直接调用。
按照官方文档,定义了工具、写好了 handler、跑起了 stdio 传输层。本地测试一切正常——mcp-server 启动没问题,日志也显示工具注册成功。
但一连接到 Claude Desktop,问题来了:
Claude 明确说"没有可用的工具"。
我反复检查:
server.setRequestHandler(ListToolsRequestSchema, ...)写了server.setRequestHandler(CallToolRequestSchema, ...)写了- 工具列表里明明有我的函数
就是识别不了。
排查:问题出在 zod schema 的 describe 上
一开始我怀疑是传输层的问题,换了 stdio 和 sse 两种模式,结果一样。
然后怀疑是 Claude Desktop 的缓存,重启了三次,还是一样。
直到我打开 MCP Inspector 仔细对比官方示例和我的代码,终于发现了差别。
问题出在 z.object() 的字段描述上。
我的代码是这样的(简化后):
const ExecCommandSchema = z.object({
command: z.string(), // 没有 describe
timeout: z.number(), // 没有 describe
});
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: "exec_command",
description: "执行 shell 命令",
inputSchema: zodToJsonSchema(ExecCommandSchema),
},
],
};
});
看起来没问题?但 MCP 协议要求,inputSchema 必须能被 LLM 理解每个字段的用途和约束。
如果字段没有 describe,Claude 拿到的是一个裸的 JSON Schema,它不知道 command 该填什么、timeout 的单位是什么。LLM 对模糊的工具描述会直接拒绝调用,而不是瞎猜。
解决:3 行代码搞定
修复方案简单到离谱——给每个字段加上 describe:
const ExecCommandSchema = z.object({
command: z.string().describe("要执行的 shell 命令,例如 'ls -la'"),
timeout: z.number().describe("命令超时时间,单位秒,默认 30"),
});
就加了这两行 .describe(),Claude 立刻就能识别并调用工具了。
说到底,MCP 不是给你人看的,是给 LLM 看的。LLM 依赖字段描述来理解工具的语义,没有描述就等于白注册。
进阶:让工具描述更"LLM 友好"
解决了基础问题后,我又测试了几轮,总结出让 LLM 更好识别的几个技巧:
1. 描述里带示例
不要写"目标路径",要写"目标路径,例如 /var/log/nginx":
path: z.string().describe("目标文件路径,例如 /var/log/nginx/access.log"),
LLM 看到示例就知道该填什么格式,大幅降低幻觉概率。
2. 枚举值用 z.enum() 而不是裸字符串
// 不好
level: z.string().describe("日志级别,可选 debug/info/warn/error"),
// 更好
level: z.enum(["debug", "info", "warn", "error"]).describe("日志级别"),
z.enum() 会自动生成 JSON Schema 的 enum 字段,LLM 看到这个会严格从列表里选,而不是自由发挥。
3. 可选字段明确标记
retry: z.number().optional().describe("失败重试次数,默认不重试"),
optional() 会让 schema 生成 "required": [] 的语义,LLM 知道这不是必填项,不会硬编一个值进去。
踩坑记录
坑 1:以为 zod 的 description 和 describe 是一回事
zod 本身有 z.string({ description: "..." }) 的写法,但这个是给 zod 内部用的,不会体现在生成的 JSON Schema 里。必须用 .describe() 才能正确输出到 inputSchema。
坑 2:Claude 的缓存比想象中持久
调试的时候改了代码,Claude Desktop 还是报"没有工具"。后来发现在 Claude 设置里点"刷新 MCP 工具"才生效,单纯重启客户端不够。
坑 3:工具名不要用驼峰,用下划线
Claude 对 execCommand 和 exec_command 的理解没有本质区别,但下划线更符合工具命名的惯例。而且 MCP Inspector 里显示也更整齐。
坑 4:description 字段不要空
工具级别的 description 如果留空,Claude 会直接忽略这个工具。哪怕你觉得工具名已经很清楚了,也要写一句描述,比如"执行远程服务器命令并返回输出"。
写在最后
MCP 协议的初衷很好——让 LLM 能安全、结构化地调用外部工具。但协议规范和实际 SDK 实现之间,总有一些"约定俗成"的细节文档不会明说。
这次踩坑给我的经验是:给 LLM 用的接口,描述比实现更重要。你写的代码能跑通只是第一步,让 LLM 能正确理解并调用,才是 MCP Server 真正"可用"的标准。
如果你也在开发 MCP Server,建议直接用 MCP Inspector 做一轮完整测试,看看 LLM 拿到的 schema 到底长什么样。很多时候问题不在代码逻辑,而在 LLM 看不到你想让它看到的东西。
有问题欢迎评论区交流。
更多推荐

所有评论(0)