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:
- Initializing the project structure
- Writing an MCP Server to expose tool calls
- Implementing Hooks for input validation
- Writing a Skill file to describe use cases
- Local testing and debugging
- Packaging and publishing to clawhub.ai
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.