Chapter 50

plugin.json Complete Parameter Reference: Directory Structure, Versioning Strategy and userConfig Sensitive Data Collection

Chapter 50: Developing Your First Claude Plugin: Complete Workflow from Zero to Publication

50.1 What We're Building

This chapter builds a complete Claude Plugin from scratch — the weather-plugin. Despite its modest functionality, it covers every core aspect of Plugin development:

The weather query example is deliberate: it involves an external API call (the Open-Meteo free API, no key required), parameter validation (city name, date range), and error handling — enough to cover common real-world Plugin development challenges, without complex business logic as a distraction.

50.2 Environment Setup

Required Tools

# Node.js 18+ (LTS)
node --version  # v18.x or higher

# Claude Code CLI
claude --version  # 1.0.0 or higher

# Plugin development toolkit
npm install -g @claude/plugin-cli

# Verify installation
claude-plugin --version

Creating the Project

mkdir weather-plugin
cd weather-plugin
claude-plugin init

The claude-plugin init command launches an interactive wizard:

? 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, package.json, .gitignore

50.3 Project Structure

weather-plugin/
├── plugin.json          ← Plugin manifest
├── package.json         ← npm dependencies
├── tsconfig.json        ← TypeScript config
├── mcp/
│   ├── server.ts        ← MCP Server entry point
│   └── tools/
│       ├── current-weather.ts
│       └── forecast.ts
├── hooks/
│   └── pre-tool.ts      ← Pre-call validation Hook
├── monitor/
│   └── collector.ts     ← Telemetry collector
└── skills/
    └── weather-query.md ← Use case description

50.4 Writing 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"],
  
  "engines": { "claude-code": ">=1.0.0" },
  
  "config": {
    "schema": {
      "defaultCity": {
        "type": "string",
        "description": "Default city when none is specified",
        "default": "Beijing"
      },
      "temperatureUnit": {
        "type": "string",
        "description": "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"]
}

The permissions field declares network:outbound because our MCP Server needs to reach the Open-Meteo API. Claude Code presents this permission to the user during installation and enforces it at runtime.

50.5 Implementing the MCP Server

Dependencies

npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node

Current Weather Tool

// 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 local language)"),
    country: z.string().optional().describe("ISO country code, e.g. CN, US"),
  },
  handler: async (input: { city: string; country?: string }) => {
    // Step 1: geocode city name to coordinates
    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());
    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];
    
    // Step 2: fetch current weather
    const weatherUrl = new URL("https://api.open-meteo.com/v1/forecast");
    weatherUrl.searchParams.set("latitude", String(latitude));
    weatherUrl.searchParams.set("longitude", String(longitude));
    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;
    
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          location: `${name}, ${country}`,
          current: {
            temperature: `${current.temperature_2m}°C`,
            humidity: `${current.relative_humidity_2m}%`,
            windSpeed: `${current.wind_speed_10m} km/h`,
            condition: describeWeatherCode(current.weather_code),
            time: current.time,
          },
        }, null, 2),
      }],
    };
  },
};

function describeWeatherCode(code: number): string {
  const map: Record<number, string> = {
    0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
    45: "Foggy", 51: "Light drizzle", 61: "Slight rain", 63: "Moderate rain",
    65: "Heavy rain", 71: "Slight snow", 80: "Slight showers", 95: "Thunderstorm",
  };
  return map[code] ?? `Unknown (code ${code})`;
}

Forecast Tool

// 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("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 url = new URL("https://api.open-meteo.com/v1/forecast");
    url.searchParams.set("latitude", String(latitude));
    url.searchParams.set("longitude", String(longitude));
    url.searchParams.set(
      "daily",
      "temperature_2m_max,temperature_2m_min,precipitation_sum,weather_code"
    );
    url.searchParams.set("forecast_days", String(input.days));
    url.searchParams.set("timezone", "auto");
    
    const res = await fetch(url.toString());
    const data = await res.json();
    const daily = data.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: describeWeatherCode(daily.weather_code[i]),
    }));
    
    return {
      content: [{ type: "text", text: JSON.stringify({ location: `${name}, ${country}`, forecast }, null, 2) }],
    };
  },
};

MCP Server Entry Point

// 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);
}

const transport = new StdioServerTransport();
await server.connect(transport);

50.6 Implementing the Hook

// 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?.trim()) {
    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" };
  }
  
  // Normalize whitespace
  return {
    action: "modify",
    modifiedInput: { ...toolInput, city: city.trim() },
  };
};

50.7 Writing the Skill File

---
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 city to query weather for
    required: true
  - name: days
    type: number
    description: Forecast days (1-7), for forecast queries only
    required: false
    default: 3
tags: [weather, forecast, travel]
---

## Usage Scenarios

Use this Skill when the user asks about weather conditions for any city.

### Trigger Conditions

- User asks "What's the weather in Beijing today?"
- User asks "Will it rain in Shanghai this week?"
- User is planning travel and needs destination weather
- User needs to pick a date for an outdoor activity

### How to Use

1. For current weather only: call `get_current_weather`
2. For multi-day forecast: call `get_weather_forecast` with appropriate `days`
3. If no city is specified, use the configured `defaultCity`
4. Restate JSON data in natural language — do not output raw JSON to the user

### Response Format

Include: city name, current time, temperature with unit, weather condition.
For forecasts, list each day's high/low temperature and conditions.

### Notes

- Always display Celsius unless the user explicitly requests Fahrenheit
- For unrecognized city names, politely ask the user to check the spelling
- If the API returns an error, inform the user the service is temporarily unavailable

50.8 Monitoring Module

// 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,
    };
    fs.appendFileSync(LOG_FILE, JSON.stringify(entry) + "\n", "utf8");
  },
};

50.9 Build Configuration

// tsconfig.json
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "outDir": "./dist",
    "rootDir": "./",
    "strict": true,
    "esModuleInterop": true
  },
  "include": ["mcp/**/*", "hooks/**/*", "monitor/**/*"]
}
npm run build     # compile TypeScript to dist/
npm run dev       # watch mode for development

50.10 Local Testing

Install Locally in Claude Code

# From the Plugin directory
claude plugin install .

# Verify
claude plugin list
# [email protected] (local) ✓ active

Validate the Manifest

claude-plugin validate
# ✓ plugin.json is valid
# ✓ All declared files exist
# ✓ MCP server binary found
# ✓ Hook files found
# ✓ Skills are valid Markdown
# ✓ No undeclared permissions detected

Testing in Claude Code

Open Claude Code and verify:

User: What's the weather like in Tokyo right now?
Claude: [calls get_current_weather with city="Tokyo"]
        Tokyo is currently partly cloudy with a temperature of 24°C,
        humidity at 68%, and winds at 15 km/h from the northeast.

Debugging Tips

# View Plugin logs
claude plugin logs weather-plugin

# Enable verbose MCP communication tracing
CLAUDE_PLUGIN_DEBUG=1 claude

# Test MCP server in isolation (without Claude Code)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | \
  node dist/mcp/server.js

50.11 Packaging and Publishing

Package the Plugin

# Dry run to preview package contents
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)

# Create the package
claude-plugin pack
# Output: weather-plugin-1.0.0.clpkg

The .clpkg format is a signed ZIP file containing all compiled files and the manifest.

Publish to clawhub.ai

# Login (first time only)
claude-plugin login

# Publish
claude-plugin publish weather-plugin-1.0.0.clpkg
# ✓ Validating package...
# ✓ Uploading to clawhub.ai...
# ✓ Triggering automated review...
# 
# Package submitted successfully!
# Review: https://clawhub.ai/plugins/weather-plugin/review/1234
# Expected review time: 24-48 hours

The detailed review and version management process is covered in Chapter 52.

50.12 Common Troubleshooting

MCP Server fails to start: Verify the binary path in plugin.json points to the compiled .js file in dist/, not the .ts source. Run node dist/mcp/server.js directly to see any startup errors.

Hook not firing: Check that the hook path in plugin.json points to ./dist/hooks/pre-tool.js, not the TypeScript source. Hooks run from the compiled output.

Network access denied: Confirm "network:outbound" is declared in the permissions array. Undeclared permissions are blocked at the sandbox layer regardless of what the code attempts.

Tool not appearing in Claude: Run claude plugin logs weather-plugin to check if the MCP Server started successfully. A crashed MCP process means no tools are registered.

Summary

This chapter demonstrated the complete lifecycle of a Claude Plugin: initializing with claude-plugin init, writing an MCP Server with two weather tools, implementing input validation via Hooks, describing use cases in a Skill file, adding a monitoring collector, and publishing with claude-plugin pack and claude-plugin publish. The weather-plugin is simple enough to be approachable but realistic enough to cover the patterns you'll encounter in production Plugins. The next chapter examines the Skill file format in depth.

Rate this chapter
4.6  / 5  (3 ratings)

💬 Comments