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

一开始我怀疑是传输层的问题,换了 stdiosse 两种模式,结果一样。

然后怀疑是 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 的 descriptiondescribe 是一回事

zod 本身有 z.string({ description: "..." }) 的写法,但这个是给 zod 内部用的,不会体现在生成的 JSON Schema 里。必须用 .describe() 才能正确输出到 inputSchema

坑 2:Claude 的缓存比想象中持久

调试的时候改了代码,Claude Desktop 还是报"没有工具"。后来发现在 Claude 设置里点"刷新 MCP 工具"才生效,单纯重启客户端不够。

坑 3:工具名不要用驼峰,用下划线

Claude 对 execCommandexec_command 的理解没有本质区别,但下划线更符合工具命名的惯例。而且 MCP Inspector 里显示也更整齐。

坑 4:description 字段不要空

工具级别的 description 如果留空,Claude 会直接忽略这个工具。哪怕你觉得工具名已经很清楚了,也要写一句描述,比如"执行远程服务器命令并返回输出"。


写在最后

MCP 协议的初衷很好——让 LLM 能安全、结构化地调用外部工具。但协议规范和实际 SDK 实现之间,总有一些"约定俗成"的细节文档不会明说。

这次踩坑给我的经验是:给 LLM 用的接口,描述比实现更重要。你写的代码能跑通只是第一步,让 LLM 能正确理解并调用,才是 MCP Server 真正"可用"的标准。

如果你也在开发 MCP Server,建议直接用 MCP Inspector 做一轮完整测试,看看 LLM 拿到的 schema 到底长什么样。很多时候问题不在代码逻辑,而在 LLM 看不到你想让它看到的东西。

有问题欢迎评论区交流。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐