032、自定义 MCP 插件:从开发到发布的全流程
上周五凌晨两点,我盯着终端里那行血红色的报错发呆:
Error: MCP tool 'fetch_github_issue' returned non-serializable result
Claude Code 调用我写的 MCP 插件时,返回了一个包含 datetime 对象的字典——JSON 序列化直接炸了。这个坑让我意识到,写一个能用的 MCP 插件和写一个能上生产环境的 MCP 插件,中间隔着一条河。
为什么需要自定义 MCP 插件
Claude Code 内置的工具集已经很强了,但总有边界。比如我需要它直接操作公司内部的 Jira 工单系统、查询自建的 CI/CD 流水线状态、或者调用某个内部 API 做数据脱敏——这些场景下,自定义 MCP 插件是唯一解。
MCP(Model Context Protocol)本质上是一个轻量级的 RPC 协议,Claude Code 通过它来发现和调用外部工具。每个插件暴露一组工具(tools),每个工具有自己的输入参数和输出格式。Claude 会像人类阅读 API 文档一样,根据你的 prompt 自动选择合适的工具来调用。
脚手架搭建:别从零开始
我见过太多人从 mkdir my-mcp-plugin 开始,然后手写整个项目结构。别这样写,直接用官方脚手架:
npx @anthropic/create-mcp-server my-plugin
cd my-plugin
npm install
这个脚手架会生成一个 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.ts
import { z } from 'zod'
// 这里踩过坑:参数名一定要用下划线命名法,Claude 对驼峰的支持不稳定
export const FetchIssueSchema = z.object({
owner: z.string().describe('仓库所有者,比如 "anthropics"'),
repo: z.string().describe('仓库名,比如 "claude-code"'),
issue_number: z.number().int().positive().describe('Issue 编号'),
})
export type FetchIssueParams = z.infer<typeof FetchIssueSchema>
export async function fetchIssue(params: FetchIssueParams) {
const { owner, repo, issue_number } = params
const url = `https://api.github.com/repos/${owner}/${repo}/issues/${issue_number}`
const response = await fetch(url, {
headers: {
'Accept': 'application/vnd.github.v3+json',
// 别这样写:把 token 硬编码在这里
// 'Authorization': 'Bearer ghp_xxx'
}
})
if (!response.ok) {
throw new Error(`GitHub API 返回 ${response.status}: ${response.statusText}`)
}
const data = await response.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.ts
import { Server } from '@anthropic/mcp-server'
import { fetchIssue, FetchIssueSchema } from './tools/fetch_issue'
const server = new Server({
name: 'github-helper',
version: '1.0.0',
})
// 注册工具:name 要简短,description 要详细
// Claude 会根据 description 来决定是否调用这个工具
server.tool(
'fetch_github_issue',
'获取 GitHub 仓库中指定 Issue 的详细信息,包括标题、状态、标签和内容摘要',
FetchIssueSchema,
async (params) => {
const result = await fetchIssue(params)
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
}
}
)
server.start()
这里有个容易被忽略的点:description 字段是 Claude 理解工具用途的唯一途径。写得太简略,Claude 可能不会调用你的工具;写得太啰嗦,Claude 可能误解。我一般控制在 50-100 字,包含:工具做什么、输入是什么、输出是什么。
本地调试:模拟 Claude 的调用
开发阶段最痛苦的是每次都要启动 Claude Code 来测试。我后来发现可以直接用 MCP 的调试工具:
# 启动开发服务器
npm run 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 的配置机制:
// 在工具函数里读取环境变量
const GITHUB_TOKEN = process.env.GITHUB_TOKEN
if (!GITHUB_TOKEN) {
throw new Error('请设置 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 {
const result = await fetchIssue(params)
return { content: [{ type: 'text', text: JSON.stringify(result) }] }
} catch (error) {
// 这里踩过坑:直接返回 error.message 可能不够
// Claude 需要知道:为什么失败?用户能做什么?
if (error instanceof FetchError) {
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 让团队其他人使用:
# 先构建
npm run build
# 更新版本号,遵循 semver
# 别这样写:npm version patch 直接推
# 先确认 changelog 和 README 都更新了
npm version patch
npm publish
发布前检查 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 用起来就越顺手。





