032、自定义 MCP 插件从开发到发布的全流程上周五凌晨两点我盯着终端里那行血红色的报错发呆Error: MCP tool fetch_github_issue returned non-serializable resultClaude Code 调用我写的 MCP 插件时返回了一个包含datetime对象的字典——JSON 序列化直接炸了。这个坑让我意识到写一个能用的 MCP 插件和写一个能上生产环境的 MCP 插件中间隔着一条河。为什么需要自定义 MCP 插件Claude Code 内置的工具集已经很强了但总有边界。比如我需要它直接操作公司内部的 Jira 工单系统、查询自建的 CI/CD 流水线状态、或者调用某个内部 API 做数据脱敏——这些场景下自定义 MCP 插件是唯一解。MCPModel Context Protocol本质上是一个轻量级的 RPC 协议Claude Code 通过它来发现和调用外部工具。每个插件暴露一组工具tools每个工具有自己的输入参数和输出格式。Claude 会像人类阅读 API 文档一样根据你的 prompt 自动选择合适的工具来调用。脚手架搭建别从零开始我见过太多人从mkdir my-mcp-plugin开始然后手写整个项目结构。别这样写直接用官方脚手架npx anthropic/create-mcp-server my-plugincdmy-pluginnpminstall这个脚手架会生成一个 TypeScript 项目包含完整的类型定义和开发服务器。你只需要关注业务逻辑。项目结构长这样my-plugin/ ├── src/ │ ├── index.ts # 入口注册工具 │ ├── tools/ # 每个工具一个文件 │ │ ├── hello.ts │ │ └── fetch_data.ts │ └── utils/ # 工具函数 │ └── api_client.ts ├── package.json └── tsconfig.json写第一个工具从踩坑开始假设我们要写一个查询 GitHub Issue 的工具。先定义工具 schema// src/tools/fetch_issue.tsimport{z}fromzod// 这里踩过坑参数名一定要用下划线命名法Claude 对驼峰的支持不稳定exportconstFetchIssueSchemaz.object({owner:z.string().describe(仓库所有者比如 anthropics),repo:z.string().describe(仓库名比如 claude-code),issue_number:z.number().int().positive().describe(Issue 编号),})exporttypeFetchIssueParamsz.infertypeofFetchIssueSchemaexportasyncfunctionfetchIssue(params:FetchIssueParams){const{owner,repo,issue_number}paramsconsturlhttps://api.github.com/repos/${owner}/${repo}/issues/${issue_number}constresponseawaitfetch(url,{headers:{Accept:application/vnd.github.v3json,// 别这样写把 token 硬编码在这里// Authorization: Bearer ghp_xxx}})if(!response.ok){thrownewError(GitHub API 返回${response.status}:${response.statusText})}constdataawaitresponse.json()// 这里踩过坑直接返回 data 会包含 Date 对象导致序列化失败// 必须手动序列化return{title:data.title,state:data.state,body:data.body?.substring(0,500),// 限制长度Claude 上下文有限labels:data.labels.map((l:any)l.name),created_at:data.created_at,// 已经是字符串安全html_url:data.html_url,}}关键点返回的数据必须是纯 JSON 可序列化的。任何Date、Map、Set或者循环引用的对象都会让 Claude Code 崩溃。我那次凌晨的报错就是因为忘了把datetime转成字符串。注册工具别漏了这一步写好了工具函数需要在入口文件注册// src/index.tsimport{Server}fromanthropic/mcp-serverimport{fetchIssue,FetchIssueSchema}from./tools/fetch_issueconstservernewServer({name:github-helper,version:1.0.0,})// 注册工具name 要简短description 要详细// Claude 会根据 description 来决定是否调用这个工具server.tool(fetch_github_issue,获取 GitHub 仓库中指定 Issue 的详细信息包括标题、状态、标签和内容摘要,FetchIssueSchema,async(params){constresultawaitfetchIssue(params)return{content:[{type:text,text:JSON.stringify(result,null,2)}]}})server.start()这里有个容易被忽略的点description 字段是 Claude 理解工具用途的唯一途径。写得太简略Claude 可能不会调用你的工具写得太啰嗦Claude 可能误解。我一般控制在 50-100 字包含工具做什么、输入是什么、输出是什么。本地调试模拟 Claude 的调用开发阶段最痛苦的是每次都要启动 Claude Code 来测试。我后来发现可以直接用 MCP 的调试工具# 启动开发服务器npmrun dev# 在另一个终端用 mcp-cli 测试npx anthropic/mcp-cli call fetch_github_issue\--params{owner: anthropics, repo: claude-code, issue_number: 42}这样能快速验证工具是否正常工作而不需要经过 Claude 的 prompt 解析层。等工具逻辑稳定了再集成到 Claude Code 里做端到端测试。配置管理环境变量的正确姿势插件里免不了要配置 API Key、数据库连接串之类的敏感信息。别写死在代码里也别用.env文件——Claude Code 的插件运行环境不一定能读到你的.env。正确做法是使用 MCP 的配置机制// 在工具函数里读取环境变量constGITHUB_TOKENprocess.env.GITHUB_TOKENif(!GITHUB_TOKEN){thrownewError(请设置 GITHUB_TOKEN 环境变量)}然后在 Claude Code 的配置文件~/.claude/settings.json里注入{mcpServers:{github-helper:{command:node,args:[path/to/your/plugin/dist/index.js],env:{GITHUB_TOKEN:ghp_your_token_here}}}}这样配置的好处是token 只存在于 Claude Code 的配置中不会泄露到代码仓库里。错误处理让 Claude 知道发生了什么工具调用失败时返回的错误信息要足够清晰因为 Claude 会根据错误信息决定下一步操作。别返回Error: something went wrong这种废话。try{constresultawaitfetchIssue(params)return{content:[{type:text,text:JSON.stringify(result)}]}}catch(error){// 这里踩过坑直接返回 error.message 可能不够// Claude 需要知道为什么失败用户能做什么if(errorinstanceofFetchError){return{isError:true,content:[{type:text,text:GitHub API 请求失败:${error.message}。请检查 owner 和 repo 名称是否正确或者 Issue 是否存在。}]}}// 兜底错误return{isError:true,content:[{type:text,text:未知错误:${error}}]}}注意isError: true这个字段——告诉 Claude 这是一个错误响应而不是正常结果。Claude 会据此调整后续行为比如向用户解释错误原因或者尝试其他参数。发布到 npm版本号要谨慎插件开发完成后发布到 npm 让团队其他人使用# 先构建npmrun build# 更新版本号遵循 semver# 别这样写npm version patch 直接推# 先确认 changelog 和 README 都更新了npmversion patchnpmpublish发布前检查package.json里的files字段确保只包含构建产物{files:[dist/**/*,README.md],main:dist/index.js,types:dist/index.d.ts}别把src/目录和node_modules/也发布上去浪费空间不说还可能暴露源码逻辑。版本兼容性一个容易被忽视的坑MCP 协议本身在快速迭代中。我遇到过最坑的情况是插件在本地调试正常部署到 CI 环境后 Claude Code 报Tool not found。排查了半天发现是 CI 环境里的anthropic/mcp-server版本太旧不支持我用的某个 API。解决方案在package.json里锁定anthropic/mcp-server的版本范围{peerDependencies:{anthropic/mcp-server:0.3.0 0.5.0}}同时在 README 里明确标注兼容的 Claude Code 版本。个人经验三个让插件更好用的技巧工具粒度要适中。别把整个业务逻辑塞进一个工具里也别拆得太碎。一个工具对应一个原子操作比如“查询 Issue”、“创建 Issue”、“关闭 Issue”各一个工具。Claude 会组合调用多个工具来完成复杂任务。给工具加缓存。如果工具查询的是不常变化的数据比如项目配置、用户信息在工具内部加一个简单的内存缓存TTL 设 30 秒。Claude 有时会在同一个对话里多次调用同一个工具缓存能显著提升响应速度。日志是救命稻草。在工具的关键路径上加console.error日志别用console.log会污染 Claude 的响应解析。当 Claude 调用工具失败时这些日志会出现在 Claude Code 的调试输出里帮你快速定位问题。console.error([github-helper] 开始查询 Issue #${issue_number})// ... 业务逻辑console.error([github-helper] 查询完成耗时${Date.now()-start}ms)最后说一句MCP 插件开发的门槛不高但要做好需要理解 Claude 的思维方式——它不是一个普通的 API 调用者而是一个会“思考”的代理。你的工具设计得越符合直觉Claude 用起来就越顺手。