第 50 章
plugin.json 全参数解析:目录结构 / 版本策略 / userConfig 敏感信息收集
第五十章:开发第一个 Claude Plugin:从零到发布的完整流程
50.1 我们要构建什么
本章将从零开始构建一个完整的 Claude Plugin——天气查询 Plugin(weather-plugin)。这个 Plugin 虽然功能简单,但涵盖了 Plugin 开发的所有核心环节:
- 初始化项目结构
- 编写 MCP Server 提供工具调用
- 实现 Hooks 进行输入验证
- 编写 Skill 文件描述使用场景
- 本地测试与调试
- 打包发布到 clawhub.ai
选择天气查询作为示例有意为之:它涉及外部 API 调用(Open-Meteo 免费 API,无需 API Key)、参数验证(城市名、日期范围)、错误处理,足以覆盖真实 Plugin 开发中的常见问题,同时避免了复杂的业务逻辑分散注意力。
50.2 环境准备
所需工具
# Node.js 18+ (LTS)
node --version # v18.x 或更高
# Claude Code CLI
claude --version # 1.0.0 或更高
# Plugin 开发工具包
npm install -g @claude/plugin-cli
# 验证安装
claude-plugin --version
创建项目目录
mkdir weather-plugin
cd weather-plugin
claude-plugin init
claude-plugin init 会启动交互式初始化向导:
? Plugin name: weather-plugin
? Version: 1.0.0
? Description: Fetch current weather and forecasts for any city
? Author: Your Name <[email protected]>
? License: MIT
? Include MCP server? Yes
? Include hooks? Yes
? Include LSP? No
? Include monitoring? Yes
? Include example skill? Yes
✓ Created plugin.json
✓ Created mcp/server.ts
✓ Created hooks/pre-tool.ts
✓ Created monitor/collector.ts
✓ Created skills/weather-query.md
✓ Created tsconfig.json
✓ Created package.json
✓ Created .gitignore
50.3 项目结构详解
初始化完成后,项目结构如下:
weather-plugin/
├── plugin.json ← Plugin 清单
├── package.json ← npm 依赖
├── tsconfig.json ← TypeScript 配置
├── .gitignore
├── mcp/
│ ├── server.ts ← MCP Server 主文件
│ └── tools/
│ ├── current-weather.ts
│ └── forecast.ts
├── hooks/
│ └── pre-tool.ts ← 前置检查 Hook
├── monitor/
│ └── collector.ts ← 监控数据收集
├── skills/
│ └── weather-query.md ← 使用场景描述
└── dist/ ← 编译输出(.gitignore 中)
50.4 编写 plugin.json
{
"name": "weather-plugin",
"version": "1.0.0",
"description": "Fetch current weather and forecasts using the Open-Meteo API",
"author": "Your Name <[email protected]>",
"license": "MIT",
"keywords": ["weather", "forecast", "climate"],
"homepage": "https://github.com/yourname/weather-plugin",
"engines": {
"claude-code": ">=1.0.0"
},
"config": {
"schema": {
"defaultCity": {
"type": "string",
"description": "Default city when none is specified",
"default": "Beijing"
},
"temperatureUnit": {
"type": "string",
"description": "Temperature unit: celsius or fahrenheit",
"default": "celsius",
"enum": ["celsius", "fahrenheit"]
}
}
},
"mcp": {
"servers": [
{
"id": "weather",
"transport": "stdio",
"command": "node",
"args": ["./dist/mcp/server.js"]
}
]
},
"hooks": {
"preToolCall": "./dist/hooks/pre-tool.js"
},
"monitor": {
"collector": "./dist/monitor/collector.js",
"sampling": 0.5
},
"skills": [
"./skills/weather-query.md"
],
"permissions": [
"network:outbound"
]
}
注意 permissions 字段声明了 network:outbound,因为我们的 MCP Server 需要访问 Open-Meteo API。
50.5 实现 MCP Server
安装依赖
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node ts-node
工具定义:当前天气
// mcp/tools/current-weather.ts
import { z } from "zod";
export const currentWeatherTool = {
name: "get_current_weather",
description: "Get the current weather conditions for a specified city",
inputSchema: {
city: z.string().describe("City name (English or Chinese)"),
country: z.string().optional().describe("ISO country code (e.g., CN, US)"),
},
handler: async (input: { city: string; country?: string }) => {
// Step 1: 将城市名转换为经纬度(使用 Open-Meteo Geocoding API)
const geoUrl = new URL("https://geocoding-api.open-meteo.com/v1/search");
geoUrl.searchParams.set("name", input.city);
geoUrl.searchParams.set("count", "1");
geoUrl.searchParams.set("language", "en");
const geoRes = await fetch(geoUrl.toString());
if (!geoRes.ok) {
throw new Error(`Geocoding failed: ${geoRes.statusText}`);
}
const geoData = await geoRes.json();
if (!geoData.results || geoData.results.length === 0) {
throw new Error(`City not found: ${input.city}`);
}
const { latitude, longitude, name, country } = geoData.results[0];
// Step 2: 获取当前天气
const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast");
weatherUrl.searchParams.set("latitude", latitude.toString());
weatherUrl.searchParams.set("longitude", longitude.toString());
weatherUrl.searchParams.set(
"current",
"temperature_2m,relative_humidity_2m,wind_speed_10m,weather_code"
);
weatherUrl.searchParams.set("timezone", "auto");
const weatherRes = await fetch(weatherUrl.toString());
const weatherData = await weatherRes.json();
const current = weatherData.current;
const weatherDesc = getWeatherDescription(current.weather_code);
return {
content: [
{
type: "text",
text: JSON.stringify({
location: `${name}, ${country}`,
coordinates: { latitude, longitude },
current: {
temperature: `${current.temperature_2m}°C`,
humidity: `${current.relative_humidity_2m}%`,
windSpeed: `${current.wind_speed_10m} km/h`,
condition: weatherDesc,
time: current.time,
},
}, null, 2),
},
],
};
},
};
function getWeatherDescription(code: number): string {
const codes: Record<number, string> = {
0: "Clear sky",
1: "Mainly clear",
2: "Partly cloudy",
3: "Overcast",
45: "Foggy",
48: "Depositing rime fog",
51: "Light drizzle",
61: "Slight rain",
63: "Moderate rain",
65: "Heavy rain",
71: "Slight snow",
73: "Moderate snow",
75: "Heavy snow",
80: "Slight showers",
95: "Thunderstorm",
};
return codes[code] ?? `Unknown (code: ${code})`;
}
工具定义:天气预报
// mcp/tools/forecast.ts
import { z } from "zod";
export const forecastTool = {
name: "get_weather_forecast",
description: "Get a multi-day weather forecast for a specified city",
inputSchema: {
city: z.string().describe("City name"),
days: z.number().min(1).max(7).default(3).describe("Number of forecast days (1-7)"),
},
handler: async (input: { city: string; days: number }) => {
// 地理编码(复用逻辑,实际可抽取为共享函数)
const geoRes = await fetch(
`https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(input.city)}&count=1`
);
const geoData = await geoRes.json();
if (!geoData.results?.length) {
throw new Error(`City not found: ${input.city}`);
}
const { latitude, longitude, name, country } = geoData.results[0];
const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast");
weatherUrl.searchParams.set("latitude", latitude.toString());
weatherUrl.searchParams.set("longitude", longitude.toString());
weatherUrl.searchParams.set(
"daily",
"temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code"
);
weatherUrl.searchParams.set("forecast_days", input.days.toString());
weatherUrl.searchParams.set("timezone", "auto");
const weatherRes = await fetch(weatherUrl.toString());
const weatherData = await weatherRes.json();
const daily = weatherData.daily;
const forecast = daily.time.map((date: string, i: number) => ({
date,
maxTemp: `${daily.temperature_2m_max[i]}°C`,
minTemp: `${daily.temperature_2m_min[i]}°C`,
precipitation: `${daily.precipitation_sum[i]}mm`,
condition: getWeatherDescription(daily.weather_code[i]),
}));
return {
content: [
{
type: "text",
text: JSON.stringify({
location: `${name}, ${country}`,
forecast,
}, null, 2),
},
],
};
},
};
MCP Server 主文件
// mcp/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { currentWeatherTool } from "./tools/current-weather.js";
import { forecastTool } from "./tools/forecast.js";
const server = new McpServer({
name: "weather-plugin",
version: "1.0.0",
});
// 注册工具
for (const tool of [currentWeatherTool, forecastTool]) {
server.tool(
tool.name,
tool.description,
tool.inputSchema,
tool.handler
);
}
// 注册资源:暴露支持的城市列表
server.resource(
"supported-cities",
"weather://supported-cities",
async () => ({
contents: [
{
uri: "weather://supported-cities",
mimeType: "text/plain",
text: "This plugin supports any city worldwide via the Open-Meteo Geocoding API.",
},
],
})
);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Weather MCP server started");
50.6 实现 Hooks
// hooks/pre-tool.ts
import type { PreToolCallHook } from "@claude/plugin-sdk";
export const preToolCall: PreToolCallHook = async (toolName, toolInput) => {
// 只处理我们自己的天气工具
if (!["get_current_weather", "get_weather_forecast"].includes(toolName)) {
return { action: "allow" };
}
const { city } = toolInput as { city?: string };
// 验证城市名不为空
if (!city || city.trim().length === 0) {
return {
action: "block",
reason: "City name cannot be empty",
};
}
// 防止明显的注入攻击
if (city.includes(";") || city.includes("--") || city.length > 100) {
return {
action: "block",
reason: "Invalid city name format",
};
}
// 自动清理城市名(去除多余空格)
return {
action: "modify",
modifiedInput: {
...toolInput,
city: city.trim(),
},
};
};
50.7 编写 Skill 文件
---
name: weather-query
description: Query current weather and multi-day forecasts for any city worldwide
version: 1.0.0
parameters:
- name: city
type: string
description: The name of the city to query
required: true
- name: days
type: number
description: Number of forecast days (1-7), used only for forecast queries
required: false
default: 3
tags:
- weather
- forecast
- travel
---
## 使用场景
当用户询问某个城市的天气状况时,使用此 Skill 提供实时天气数据和预报。
### 触发时机
- 用户询问"北京今天天气怎么样"
- 用户询问"上海这周会下雨吗"
- 用户在制定旅行计划时需要了解目的地天气
- 用户需要为某项户外活动选择合适的日期
### 使用方式
1. 如果用户只问当前天气,调用 `get_current_weather` 工具
2. 如果用户问未来几天的天气,调用 `get_weather_forecast` 工具,days 参数根据用户需求设置
3. 如果用户没有指定城市,使用配置中的 `defaultCity`
4. 将工具返回的 JSON 数据用自然语言重新表述,不要直接输出 JSON
### 回复格式
回复应包含:
- 城市名和当前时间
- 温度(数值 + 单位)
- 天气状况(晴、多云、雨等)
- 如果是预报,按日期列出每天的高低温和天气
### 注意事项
- 温度始终显示摄氏度,除非用户明确要求华氏度
- 对于无法识别的城市名,友好地提示用户检查拼写
- 如果 API 返回错误,告知用户服务暂时不可用,不要猜测天气
50.8 监控模块实现
// monitor/collector.ts
import type { MonitorCollector } from "@claude/plugin-sdk";
import * as fs from "fs";
import * as path from "path";
const LOG_FILE = path.join(process.env.HOME || ".", ".weather-plugin", "usage.jsonl");
// 确保日志目录存在
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
export const collector: MonitorCollector = {
onToolCall(event) {
const entry = {
timestamp: new Date().toISOString(),
tool: event.toolName,
success: event.success,
latencyMs: event.latencyMs,
sessionId: event.sessionId,
// 记录查询的城市(用于使用统计)
city: (event.toolInput as Record<string, unknown>)?.city ?? null,
};
// 追加写入 JSONL 日志文件
fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n", "utf8");
},
};
50.9 TypeScript 编译配置
// tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["mcp/**/*", "hooks/**/*", "monitor/**/*"],
"exclude": ["node_modules", "dist"]
}
// package.json(关键字段)
{
"name": "weather-plugin",
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"test": "claude-plugin test",
"lint": "claude-plugin validate"
}
}
50.10 本地测试
编译项目
npm run build
验证清单
claude-plugin validate
✓ plugin.json is valid
✓ All declared files exist
✓ MCP server binary found: ./dist/mcp/server.js
✓ Hook files found
✓ Skills are valid Markdown with correct frontmatter
✓ No undeclared permissions detected
在 Claude Code 中本地安装
# 在 Plugin 目录中运行
claude plugin install .
# 验证安装
claude plugin list
# 输出:
# [email protected] (local) ✓ active
功能测试
打开 Claude Code,测试以下对话:
用户:北京今天天气怎么样?
Claude:[调用 get_current_weather,返回实时数据]
北京当前天气:晴天,气温 28°C,相对湿度 45%,
东风风速 12 km/h。
用户:这周上海会下雨吗?
Claude:[调用 get_weather_forecast,days=7]
上海未来 7 天天气预报:
- 今天(周一):多云,最高 30°C,最低 24°C,无降水
- 明天(周二):阵雨,最高 26°C,最低 21°C,降水量 8mm
...
调试技巧
# 查看 MCP Server 日志
claude plugin logs weather-plugin
# 以详细模式启动(显示所有 MCP 通信)
CLAUDE_PLUGIN_DEBUG=1 claude
# 单独测试 MCP Server(不依赖 Claude Code)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | \
node dist/mcp/server.js
50.11 打包与发布准备
清理和最终验证
# 运行完整测试套件
npm test
# 检查包大小
claude-plugin pack --dry-run
# 输出:
# Package contents:
# plugin.json (2.1 KB)
# dist/ (45.3 KB)
# skills/ (1.8 KB)
# Total: 49.2 KB (compressed: 18.7 KB)
生成发布包
claude-plugin pack
# 输出:weather-plugin-1.0.0.clpkg
.clpkg 是 Claude Plugin Package 格式,本质上是一个经过签名的 ZIP 文件,包含所有已编译的文件和清单。
发布到 clawhub.ai
# 登录(首次需要)
claude-plugin login
# 发布
claude-plugin publish weather-plugin-1.0.0.clpkg
# 输出:
# ✓ Validating package...
# ✓ Uploading to clawhub.ai...
# ✓ Triggering automated review...
#
# Package submitted successfully!
# Review status: https://clawhub.ai/plugins/weather-plugin/review/1234
# Expected review time: 24-48 hours
发布后的详细审核流程将在第五十二章详细介绍。
50.12 常见问题排查
MCP Server 启动失败
# 检查进程是否正常退出
node dist/mcp/server.js
# 如果立即退出,检查是否有语法错误或缺少依赖
# 检查依赖是否完整安装
npm install
Hook 没有触发
检查 plugin.json 中 hooks 路径是否指向编译后的 .js 文件(不是 .ts):
"hooks": {
"preToolCall": "./dist/hooks/pre-tool.js" ← 正确
"preToolCall": "./hooks/pre-tool.ts" ← 错误
}
API 调用被权限拒绝
确认 plugin.json 中声明了必要的权限:
"permissions": ["network:outbound"]
小结
本章完整演示了一个 Claude Plugin 从初始化到发布的全流程:使用 claude-plugin init 创建项目结构,编写 MCP Server 暴露天气查询工具,实现 Hooks 进行参数验证,编写 Skill 文件描述使用场景,添加监控模块记录使用数据,最后通过 claude-plugin pack 和 claude-plugin publish 完成发布。下一章将深入探讨 Skill 文件的格式规范。