第 50 章

plugin.json 全参数解析:目录结构 / 版本策略 / userConfig 敏感信息收集

第五十章:开发第一个 Claude Plugin:从零到发布的完整流程

50.1 我们要构建什么

本章将从零开始构建一个完整的 Claude Plugin——天气查询 Plugin(weather-plugin)。这个 Plugin 虽然功能简单,但涵盖了 Plugin 开发的所有核心环节:

选择天气查询作为示例有意为之:它涉及外部 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 packclaude-plugin publish 完成发布。下一章将深入探讨 Skill 文件的格式规范。

本章评分
4.6  / 5  (3 评分)

💬 留言讨论