Bundling Hooks, Skills, and MCP in a Single Plugin

Have you ever wished you could bundle multiple types of Claude Code extensions into one cohesive package? Maybe you want a plugin that not only responds to lifecycle events with hooks, but also registers reusable skills and manages its own MCP server to handle external integrations?
Good news: you absolutely can. And it's more powerful than you might think. When you combine these three layers effectively, you're not just building an extension—you're building a miniature operating system that has hooks (nervous system), skills (capabilities), and external integrations (sensory input/output).
This guide walks you through creating a production-ready plugin that combines hooks, skills, and an embedded MCP server—all working together as a unified system. By the end, you'll understand not just how to bundle these components, but why this architecture matters for building sophisticated Claude Code extensions that developers will actually want to use repeatedly.
Table of Contents
- The Three-Layer Plugin Architecture
- Project Structure: The Foundation
- Plugin Metadata: Declaring Your Components
- Layer 1: Hook Implementation with State Management
- Layer 2: Skill Registration and Composition
- Layer 3: Embedding an MCP Server
- Layer 3.5: The Shared State Manager: The Glue
- The Power of Composition
- Bringing It All Together: Real Usage
- Real-World Example: Building an Audit & Compliance Plugin
- Testing Your Plugin Thoroughly
- Building Observability Into Your Plugin
- Debugging Your Plugin
- The Broader Impact: Thinking Systematically
- Key Takeaways
- Advanced Pattern: Cross-Layer Communication with Pub/Sub
- Performance Optimization: Async Processing and Batching
- Error Handling Across Layers
- Testing Multi-Layer Plugins
- Deployment Strategies for Bundled Plugins
- The Economics of Bundled Plugins
- Real-World Success Patterns
- Building Organizational Systems
- Conclusion: From Components to Systems
The Three-Layer Plugin Architecture
Before we dive into code, let's establish a mental model. A fully-featured plugin has three distinct layers that are meant to work together, not in isolation. Think of it like a nervous system, a skeletal system, and a digestive system working together in a body.
-
Hooks Layer: Responds to Claude Code lifecycle events (
PreToolUse,PostToolUse,SessionStart,UserPromptSubmit, etc.). These are your early-warning system, letting you react immediately when something is about to happen or just happened. Hooks are synchronous—they block until they complete, so they're perfect for validation and cleanup. A hook might check: "Is this operation allowed?" before it happens, or "Should I record this for audit?" after it happens. -
Skills Layer: Registers reusable capabilities that Claude can invoke through slash commands or direct references. Skills are Claude's hands—the things it can actually do. Unlike hooks which watch passively, skills are proactive abilities that Claude invokes to accomplish tasks. A skill might process data, validate files, transform formats, or coordinate with other components. Skills are written to be composable—they're not monolithic "do everything" functions, but focused capabilities that can be chained together.
-
MCP Layer: Manages external services, APIs, and resources through the Model Context Protocol. This is your plugin's gateway to the world—it lets your plugin integrate with external systems, pull in data from APIs, manage resources, and provide Claude with information that lives outside your repository. Without this layer, your plugin is isolated. With it, your plugin becomes a bridge between Claude Code and your entire infrastructure.
These aren't separate systems—they're interdependent. Your hooks can trigger skill execution. Your skills can leverage the MCP server to fetch data from external systems. Your MCP server can emit events that hooks respond to. The magic happens when all three layers work in concert, with a shared state manager orchestrating communication.
The reason this architecture matters is scalability. As your plugin grows and does more, having clear separation between reactive (hooks), proactive (skills), and integrative (MCP) work prevents it from becoming monolithic. You can enhance one layer without understanding the others. New developers can join and add hooks without needing to understand MCP. You can swap implementations—start with a simple hook, later replace it with a skill for more flexibility.
Project Structure: The Foundation
Here's how to organize a multi-component plugin that's maintainable and testable. Organization matters because it's where your architectural decisions become concrete. A good structure reflects the three-layer architecture clearly. A bad structure obscures it.
The key principle: each layer should be independently understandable. Someone reading the hooks directory should understand what hooks do without reading skills code. Someone implementing a skill shouldn't need to understand MCP. Separation of concerns isn't just nice to have—it's how you keep your plugin from becoming a tangled mess as it grows.
my-integrated-plugin/
├── package.json # Root dependencies
├── plugin.yaml # Plugin metadata
├── config/
│ ├── hooks.js # Hook registration
│ ├── skills.js # Skill registration
│ └── mcp.js # MCP configuration
├── hooks/
│ ├── preToolUse-validate.js # Validate before execution
│ ├── postToolUse-cleanup.js # Cleanup after execution
│ └── userPromptSubmit-enrich.js # Enrich prompts with context
├── skills/
│ ├── core-skill.js # Primary skill implementation
│ ├── utility-skill.js # Helper utilities
│ └── index.js # Skill exports
├── mcp/
│ ├── server.js # MCP server definition
│ ├── handlers/
│ │ ├── resource-handler.js # Resource management
│ │ └── tool-handler.js # Tool definitions
│ └── utils/
│ └── config-loader.js # Load plugin config
├── lib/
│ ├── logger.js # Shared logging
│ ├── state-manager.js # Plugin state
│ └── error-handler.js # Error management
└── tests/
├── hooks.test.js
├── skills.test.js
└── mcp.test.js
This structure enforces separation of concerns while making dependencies obvious. Your hooks don't directly import skills—they ask the StateManager for skill results. Your MCP server doesn't import hooks—it calls skills through the same interface. Everything flows through the center: the StateManager.
This architectural pattern scales beautifully. As you add more hooks, skills, or MCP resources, the structure stays clean because everything has a clear place. A new developer joining the team can understand the architecture immediately from the directory structure.
The wisdom in this organization comes from hard-won experience. You'll inevitably encounter plugin complexity that makes you want to shortcut these boundaries. Maybe a hook needs direct access to MCP data. Maybe a skill needs to trigger another skill. Maybe the MCP server needs to coordinate with hooks. These desires are normal—they come from trying to solve real problems efficiently.
But here's the insight: the boundaries exist to protect you from complexity spiraling. When you violate them, you solve today's problem at the cost of tomorrow's maintainability. The StateManager pattern exists specifically to enable these interactions without direct coupling. When you use it consistently, your plugin stays understandable even as it grows.
Plugin Metadata: Declaring Your Components
Start with a declarative plugin configuration. This tells Claude Code what your plugin provides and how to wire it all together:
# plugin.yaml
name: "integrated-plugin"
version: "1.0.0"
description: "Multi-component plugin with hooks, skills, and MCP server"
hooks:
- event: "PreToolUse"
matcher: "Write|Edit"
path: "hooks/preToolUse-validate.js"
- event: "PostToolUse"
matcher: ".*"
path: "hooks/postToolUse-cleanup.js"
- event: "UserPromptSubmit"
matcher: ".*"
path: "hooks/userPromptSubmit-enrich.js"
skills:
- name: "data-processing"
description: "Process and transform data"
path: "skills/core-skill.js"
handler: "processData"
- name: "validation"
description: "Validate input and output"
path: "skills/utility-skill.js"
handler: "validate"
mcp:
enabled: true
server:
path: "mcp/server.js"
stdio: true
resources:
- uri: "plugin://config"
name: "Plugin Configuration"
- uri: "plugin://status"
name: "Plugin Status"
tools:
- name: "fetch-external-data"
description: "Fetch data from external service"This declarative approach is powerful. Claude Code can auto-wire everything without manual configuration. You're not writing plumbing code; you're just declaring what exists. The framework handles the connections.
This pattern also makes your plugin self-documenting. Someone reads the plugin.yaml and immediately understands the architecture: "Oh, when the user submits a prompt, we enrich it. When they use Write or Edit tools, we validate. We expose three skills." No need to read code to understand structure.
Layer 1: Hook Implementation with State Management
Hooks are your plugin's nervous system. They respond to Claude Code events, but they need coordination to be useful at scale. A hook that only logs is useless. A hook that validates but doesn't share context with other layers is a silo. The key is using a shared StateManager that all components can read and write to—this is how hooks, skills, and the MCP server communicate and create a coherent system.
The pattern is: hooks are fast, synchronous, and focused. They answer specific questions: "Is this allowed?" or "Should I record this?" They don't do heavy lifting. They don't call external APIs. They validate and record. The skill layer handles the heavy lifting. The MCP layer handles external integration.
Here's a practical preToolUse hook that validates operations and coordinates with your skill layer:
// hooks/preToolUse-validate.js
const fs = require("fs").promises;
const path = require("path");
const { StateManager } = require("../lib/state-manager");
const { Logger } = require("../lib/logger");
const logger = new Logger("preToolUse-validate");
const stateManager = new StateManager();
async function validateBeforeExecution(hookInput) {
const { tool_name, tool_input, session_id, cwd } = hookInput;
try {
// 1. Check plugin state - has it been initialized?
const pluginState = await stateManager.getState(session_id);
if (!pluginState.initialized) {
logger.warn(`Plugin not initialized for session ${session_id}`);
return {
continue: false,
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "ask",
permissionDecisionReason: "Plugin initialization required",
},
};
}
// 2. Validate the tool operation against plugin rules
const isValid = await validateToolOperation(
tool_name,
tool_input,
pluginState,
);
if (!isValid) {
logger.error(
`Invalid operation: ${tool_name} with ${JSON.stringify(tool_input)}`,
);
return {
continue: false,
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Operation violates plugin constraints",
},
};
}
// 3. Log the operation for audit trail
await stateManager.recordEvent(session_id, {
type: "PreToolUse",
tool: tool_name,
timestamp: new Date().toISOString(),
input: tool_input,
});
logger.info(`Validated: ${tool_name}`);
return { continue: true };
} catch (error) {
logger.error(`Hook execution failed: ${error.message}`);
// Non-blocking error - allow operation to proceed
return { continue: true };
}
}
async function validateToolOperation(toolName, toolInput, pluginState) {
// Implement your validation logic here
if (toolName === "Write" || toolName === "Edit") {
const targetPath = toolInput.file_path;
const disallowedPatterns = pluginState.config?.disallow || [];
for (const pattern of disallowedPatterns) {
if (targetPath.match(new RegExp(pattern))) {
return false;
}
}
}
return true;
}
// Hook entry point
async function main() {
const input = JSON.parse(process.argv[2] || "{}");
const result = await validateBeforeExecution(input);
console.log(JSON.stringify(result));
process.exit(result.continue ? 0 : 2);
}
if (require.main === module) {
main().catch((err) => {
console.error(
JSON.stringify({
continue: true,
error: err.message,
}),
);
process.exit(1);
});
}
module.exports = { validateBeforeExecution };The StateManager is the bridge between components. Hooks write events to it. Skills query its history. The MCP server reads its metrics. It's the single source of truth for everything that happens in your plugin session. This creates an audit trail that lets you replay what happened, debug issues, and understand system behavior.
Why is this pattern so powerful? Because it decouples components. A hook doesn't need to know about skills. It just records an event. Later, a skill queries those events. If you add a new MCP tool that needs the same event history, it uses the exact same StateManager. You're not duplicating logic or creating hidden dependencies. Everything flows through the center.
Consider a concrete example: Your plugin tracks file modifications. A PreToolUse hook records every Write/Edit. A skill called file-audit-report queries those events and generates a summary. An MCP resource endpoint called plugin://modifications exposes recent modifications as a resource. All three components read from or write to the same StateManager. If an issue occurs, you have complete visibility—you can see what the hook recorded, what the skill read, what the resource exposed. Everything is consistent because it all came from one source.
Layer 2: Skill Registration and Composition
Skills are the plugin's capability layer. They're reusable functions that can be invoked by Claude or by other components. Here's how to structure them so they compose cleanly:
// skills/index.js - Skill registry and dispatcher
const { Logger } = require("../lib/logger");
const coreSkill = require("./core-skill");
const utilitySkill = require("./utility-skill");
const logger = new Logger("skills");
const SKILL_REGISTRY = {
"data-processing": {
description: "Process and transform data",
handler: coreSkill.processData,
inputs: {
data: { type: "string", required: true },
format: { type: "string", enum: ["json", "csv", "xml"] },
},
},
validation: {
description: "Validate files and data",
handler: utilitySkill.validate,
inputs: {
filePath: { type: "string", required: true },
rules: { type: "object" },
},
},
};
/**
* Execute a registered skill
* @param {string} skillName - Name of the skill
* @param {object} inputs - Skill inputs
* @param {object} context - Execution context (session, user, etc)
* @returns {Promise<object>} Skill result
*/
async function executeSkill(skillName, inputs, context = {}) {
const skill = SKILL_REGISTRY[skillName];
if (!skill) {
const err = new Error(`Skill not found: ${skillName}`);
logger.error(err.message);
throw err;
}
try {
logger.info(`Executing skill: ${skillName}`, { inputs, context });
// Validate inputs against schema
const validated = validateInputs(inputs, skill.inputs);
if (!validated.valid) {
throw new Error(
`Input validation failed: ${validated.errors.join(", ")}`,
);
}
// Execute the skill with context
const result = await skill.handler(validated.data, context);
logger.info(`Skill completed: ${skillName}`, { result });
return result;
} catch (error) {
logger.error(`Skill execution failed: ${skillName}`, {
error: error.message,
});
throw error;
}
}
/**
* Get skill metadata for registration with Claude Code
*/
function getSkillMetadata() {
return Object.entries(SKILL_REGISTRY).map(([name, skill]) => ({
name,
description: skill.description,
inputs: skill.inputs,
tags: ["processing", "validation", "transformation"],
}));
}
function validateInputs(inputs, schema) {
const errors = [];
const validated = { ...inputs };
for (const [key, def] of Object.entries(schema || {})) {
const value = inputs[key];
if (def.required && (value === undefined || value === null)) {
errors.push(`${key} is required`);
continue;
}
if (value !== undefined && def.type && typeof value !== def.type) {
errors.push(`${key} must be ${def.type}, got ${typeof value}`);
}
if (def.enum && value && !def.enum.includes(value)) {
errors.push(`${key} must be one of ${def.enum.join(", ")}`);
}
}
return {
valid: errors.length === 0,
errors,
data: validated,
};
}
module.exports = {
executeSkill,
getSkillMetadata,
SKILL_REGISTRY,
};Notice how skills are decoupled from implementation. A skill is just an interface—a name, description, and inputs. The handler can be anything: synchronous functions, async operations, or calls to other services. This decoupling lets you swap implementations without changing how Claude uses them. You can prototype with a simple function, then replace it with an MCP call later. You can add caching, retry logic, or rate limiting to a skill without Claude knowing.
The input validation is particularly important. Every skill input is validated against its schema before the handler runs. This prevents garbage input from crashing your handler. If someone tries to invoke data-processing with invalid format, they get an error message explaining what they should have passed. This is defensive programming that scales—as you add more skills, the validation system protects all of them automatically.
Layer 3: Embedding an MCP Server
The MCP server is your plugin's gateway to external services. Here's how to embed and manage one. The MCP server lives inside your plugin process and handles resource and tool requests:
// mcp/server.js
const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
const {
StdioServerTransport,
} = require("@modelcontextprotocol/sdk/server/stdio.js");
const {
CallToolRequestSchema,
ListResourcesRequestSchema,
ReadResourceRequestSchema,
} = require("@modelcontextprotocol/sdk/types.js");
const { Logger } = require("../lib/logger");
const { StateManager } = require("../lib/state-manager");
const resourceHandler = require("./handlers/resource-handler");
const toolHandler = require("./handlers/tool-handler");
const logger = new Logger("mcp-server");
const stateManager = new StateManager();
// Initialize MCP server
const server = new Server({
name: "integrated-plugin-server",
version: "1.0.0",
});
/**
* Resource endpoints - expose plugin state and config as resources
*/
server.setRequestHandler(ListResourcesRequestSchema, async (request) => {
try {
logger.info("ListResources request");
const resources = [
{
uri: "plugin://config",
name: "Plugin Configuration",
description: "Current plugin configuration and settings",
mimeType: "application/json",
},
{
uri: "plugin://status",
name: "Plugin Status",
description: "Real-time plugin status and metrics",
mimeType: "application/json",
},
];
return { resources };
} catch (error) {
logger.error("ListResources failed", { error: error.message });
throw error;
}
});
/**
* Read resource handler
*/
server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
try {
const { uri } = request.params;
logger.info(`ReadResource: ${uri}`);
const content = await resourceHandler.readResource(uri, stateManager);
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(content, null, 2),
},
],
};
} catch (error) {
logger.error(`ReadResource failed: ${request.params.uri}`, {
error: error.message,
});
throw error;
}
});
/**
* Tool execution handler
*/
server.setRequestHandler(CallToolRequestSchema, async (request) => {
try {
const { name, arguments: args } = request.params;
logger.info(`CallTool: ${name}`, { arguments: args });
const result = await toolHandler.executeTool(name, args, stateManager);
return {
content: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
],
};
} catch (error) {
logger.error(`CallTool failed: ${request.params.name}`, {
error: error.message,
});
return {
content: [
{
type: "text",
text: JSON.stringify({ error: error.message }, null, 2),
},
],
isError: true,
};
}
});
/**
* Start MCP server with stdio transport
*/
async function startServer() {
try {
const transport = new StdioServerTransport();
await server.connect(transport);
logger.info("MCP server started on stdio transport");
} catch (error) {
logger.error("Failed to start MCP server", { error: error.message });
process.exit(1);
}
}
// Start server if run directly
if (require.main === module) {
startServer().catch((err) => {
console.error("Server startup failed:", err);
process.exit(1);
});
}
module.exports = { server, startServer };The MCP server is a bridge. It takes requests from Claude Code (via the MCP protocol) and translates them into local operations. When Claude asks for a resource, the server retrieves it. When Claude asks to call a tool, the server executes it. But unlike typical MCP servers that run as separate processes, yours runs inside your plugin, giving it direct access to the StateManager and all hooks/skills.
Layer 3.5: The Shared State Manager: The Glue
None of this works without coordination. The StateManager is the central hub where hooks, skills, and the MCP server share information. It's the single source of truth for everything that happens in your plugin:
// lib/state-manager.js
const fs = require("fs").promises;
const path = require("path");
class StateManager {
constructor(dataDir = ".plugin-state") {
this.dataDir = dataDir;
this.sessionCache = new Map();
this.ensureDataDir();
}
async ensureDataDir() {
try {
await fs.mkdir(this.dataDir, { recursive: true });
} catch (error) {
// Directory might already exist
}
}
/**
* Initialize a new session
*/
async initSession(sessionId) {
const state = {
sessionId,
initialized: true,
createdAt: new Date().toISOString(),
config: {},
events: [],
skills: {},
resources: {},
};
this.sessionCache.set(sessionId, state);
await this.persist(sessionId, state);
return state;
}
/**
* Get session state
*/
async getState(sessionId) {
if (this.sessionCache.has(sessionId)) {
return this.sessionCache.get(sessionId);
}
const statePath = path.join(this.dataDir, `${sessionId}.json`);
try {
const data = await fs.readFile(statePath, "utf8");
const state = JSON.parse(data);
this.sessionCache.set(sessionId, state);
return state;
} catch {
// Session doesn't exist, return fresh state
return await this.initSession(sessionId);
}
}
/**
* Record an event in session history
*/
async recordEvent(sessionId, event) {
const state = await this.getState(sessionId);
state.events.push({
...event,
recordedAt: new Date().toISOString(),
});
this.sessionCache.set(sessionId, state);
await this.persist(sessionId, state);
}
/**
* Get all events for a session
*/
async getEvents(sessionId, filter = {}) {
const state = await this.getState(sessionId);
if (filter.type) {
return state.events.filter((e) => e.type === filter.type);
}
return state.events;
}
/**
* Persist state to disk
*/
async persist(sessionId, state) {
const statePath = path.join(this.dataDir, `${sessionId}.json`);
await fs.writeFile(statePath, JSON.stringify(state, null, 2), "utf8");
}
}
module.exports = { StateManager };The StateManager is beautifully simple but powerful. It maintains session state, records events, persists to disk, and caches in memory for performance. Every component (hooks, skills, MCP server) reads from and writes to it. This creates a unified audit trail and lets components communicate asynchronously. A hook records an event, a skill reads it later, the MCP server queries it in response to a request.
The Power of Composition
What makes this architecture so powerful is that the three layers compose in ways you don't anticipate. Start with just hooks—you're validating operations. Later, add a skill—now developers can explicitly request validation. Later still, expose validation results through MCP—now external systems can query whether an operation is allowed. You've gone from reactive validation to proactive capability to external integration, all without rewriting anything.
This is composition. Each layer enhances the others. The same logic that prevents bad operations (hook) can suggest good operations (skill) can inform external systems (MCP). You've written the validation once and used it three ways.
Compare this to a monolithic plugin where you try to do everything in one place. You end up with conditional logic scattered everywhere. Hooks are intermingled with skills. MCP handlers call hook logic. When you need to change how validation works, you search through three different code paths and hope you didn't miss one. With proper composition, you change validation once and all three layers pick up the change.
Bringing It All Together: Real Usage
When you build this correctly, here's what happens. A developer uses Claude Code and triggers a tool. The PreToolUse hook fires, validates the operation using StateManager, records the event. Claude then asks to execute a skill. The skill registry dispatches to the handler, which might query StateManager for context. Meanwhile, the MCP server is running in the background, and Claude can ask for resources or tools from it. All three layers are working in concert, sharing state seamlessly.
External integrations can now build on top. Your monitoring system reads plugin status through the MCP resource endpoint. Your audit system queries the state directly. Your incident response system can invoke skills to remediate. The plugin becomes infrastructure that your entire organization builds on.
The beauty is that this architecture is composable and scalable. You're not building a monolith; you're building a system that grows gracefully. Add a new hook? Drop it in. New skill? Register it. New MCP resource? Define it in the handler. The architecture handles the complexity of coordination. The StateManager ensures everything stays consistent. New developers can understand the system by reading the plugin.yaml and understanding which layer does what.
Real-World Example: Building an Audit & Compliance Plugin
Let's walk through a concrete example of all three layers working together in practice. Imagine you're building a compliance plugin for a financial services organization. This plugin needs to audit every file modification, validate data sensitivity levels, and flag potential violations. It's a system that would be painful to implement any other way.
The hooks layer listens to PreToolUse events. When Claude tries to write a file, the hook intercepts it, checks whether the file is sensitive (by querying StateManager for policy metadata), and either allows or blocks it. If allowed, the hook records the event—who tried to modify what, when, and why. The hook is synchronous and fast—it adds milliseconds to the operation.
The skills layer provides reusable compliance capabilities. Developers can invoke classify-data-sensitivity to tag files as public, internal, or restricted. They can invoke audit-query to pull audit logs from StateManager. They can invoke risk-assessment to evaluate the sensitivity level of content. The skills are available through the standard Claude Code interface—developers don't need to know how hooks work internally. A skill might take longer to run than a hook, and that's fine—developers invoke it explicitly.
The MCP layer bridges to external systems. It exposes the audit log as a resource, so Claude can pull compliance data directly. It provides tools for integration with external auditing platforms like your compliance system or SIEM. When an alert needs to be triggered, the MCP layer sends it to external monitoring systems. It can even expose real-time compliance dashboards as resources.
Now picture a scenario: A developer tries to modify a financial report file. The PreToolUse hook fires, checks the file classification (restricted), and blocks the operation. It records the attempt. The developer asks Claude: "I need to update this report. Can you show me the audit trail?" Claude queries the skills layer, which pulls from StateManager, and shows the recent attempts. The developer then files a request through the MCP layer's tool, which integrates with your approval workflow system. A compliance officer gets a notification with full context.
Six hours later, the request is approved. Claude retrieves the approval record through MCP and allows the modification. The operation happens, the hook records it with the approval ID, and the audit trail is complete. Three months later, during a compliance audit, someone queries the entire history of modifications to financial reports. The audit system retrieves it from the MCP resource, and compliance team generates reports directly from your plugin.
All of this happens through a unified interface. The developer never thinks about hooks, skills, or MCP. They just work with Claude. But under the hood, three sophisticated systems are coordinating to keep the organization compliant.
This is the power of bundled plugins. You're not building three separate things. You're building one coherent system where compliance, capability, and integration are woven together. The system grows with your needs. Tomorrow you might add a new hook for code reviews. The existing skills and MCP integrations automatically work with it. Week after that, you add a new MCP integration with your legal system. All your existing hooks and skills now feed data to legal automatically.
Testing Your Plugin Thoroughly
A bundled plugin has more moving parts, so testing becomes critical. You need tests at multiple levels to ensure everything works together:
Unit tests verify individual skill handlers work correctly in isolation. Test that your validation skill actually validates correctly. Test that your data processing skill produces expected output. Test edge cases. These are straightforward—isolate the function and test it. Unit tests catch individual component bugs.
Integration tests verify that hooks, skills, and state work together seamlessly. Create a mock session, trigger a hook, verify it records events correctly, query StateManager, verify the events are actually there. Trigger a skill, verify it can access state and modify it. These tests catch coordination bugs that unit tests can't detect.
End-to-end tests run complete workflows simulating actual usage. Simulate Claude calling a Write tool, your hook intercepts it and validates, your skill executes a related operation, your MCP server responds with updated state. Test the entire flow from hook trigger to final output. These tests catch the integration issues that integration tests miss because they don't exercise everything.
Scenario tests simulate real-world usage patterns. "User is in a session, makes 10 edits, we track all of them, audit queries work correctly, MCP tool retrieves the data accurately." These tests build confidence that your plugin works in actual use cases, not just in test harness scenarios.
Start with unit tests for each component. Get those passing. Add integration tests. Finally, add scenario tests. This layered approach gives you comprehensive coverage without being overwhelming. The progression also matches how you'd develop: first make individual functions work, then make them work together, then make the system work end-to-end.
The investment in testing a multi-layer plugin pays dividends. Because the layers are decoupled, you can make changes confidently. Change a skill implementation? Unit tests verify it still works. Change how hooks and skills interact? Integration tests verify coordination still works. Add a new MCP resource? Scenario tests verify it integrates correctly. Testing becomes a safety net that enables refactoring, which is essential for long-term maintainability.
Building Observability Into Your Plugin
Before you need to debug, build observability in. This means logging not just errors, but interesting events. When a skill executes, log what inputs it received and what outputs it produced. When the hook blocks an operation, log why. When the MCP server handles a request, log what was requested and what was returned.
Use structured logging, not just text strings. Log objects that can be queried and analyzed. This might seem like overkill for a single plugin, but once you have multiple instances running, the ability to query your logs becomes invaluable. You can ask: "Show me all skill executions that took longer than 1 second in the past hour." You can't answer that question with text logs.
Build dashboards that show plugin health. How many hooks have fired? How many skills have executed? What's the error rate? Are response times degrading? These dashboards help you spot problems before your users do.
Debugging Your Plugin
When something goes wrong, debugging a bundled plugin can be tricky because the interaction between layers is complex. But it can also be surprisingly straightforward if you approach it systematically.
Check the logs at each layer. Your Logger utility should log at every stage: hook invoked, state updated, skill called, MCP request handled. If you see the logs for hook invocation but not for state update, the bug is in the StateManager. If you see state update but not skill execution, the bug is in the skill dispatch. Logs create a breadcrumb trail through your system.
Use the StateManager as a debugging tool. You can query session state directly and see exactly what's recorded. Compare that to what you expected. If there's a discrepancy, you've found your bug. You're looking at the single source of truth, which is what makes debugging systematic instead of guesswork. The StateManager is more reliable than logs because it's the actual state of the system, not a trace of what happened.
Test each layer in isolation. Can the skill work without the hook? Can the hook work without the StateManager? This isolation helps identify which component is broken. If a skill behaves differently when called directly versus through the dispatcher, the bug is in the dispatcher logic, not the skill. Isolation makes problems obvious.
Add temporary logging at boundaries between layers. When the hook calls StateManager, log what it's passing and what it gets back. When the skill reads from StateManager, log what it expects and what it finds. These boundary logs often reveal the exact problem. The bug is almost always in the communication between components, not in the components themselves. Systems break at the seams, not in the middle.
The Broader Impact: Thinking Systematically
Building a multi-layer plugin forces you to think systematically about what your code is doing. Instead of one big file with everything tangled together, you have clear separation. Hooks react to events. Skills provide capability. MCP bridges systems. Each has a clear purpose.
This systematic thinking extends beyond plugins. You start to apply the same principles to your entire codebase. You separate concerns. You make systems composable. You create clear boundaries between layers. The techniques you learn building one plugin become part of your engineering toolkit, applicable to systems much larger than a single plugin.
Key Takeaways
When you bundle these three layers properly, you get something remarkable: a plugin that doesn't just respond to events—it learns, adapts, coordinates components, and integrates with external systems, all while maintaining a single source of truth through the StateManager.
Your hooks ensure integrity and audit compliance. Your skills provide reusable capability. Your MCP server bridges the external world. Together, they form a cohesive, composable extension that Claude Code developers will want to use again and again.
The architecture scales too. Need to add a new hook? Drop it in. New skill? Register it. New MCP tool? Just add it. The plugin grows without becoming monolithic. You're not fighting complexity; you're organizing it. New developers joining the team can understand the architecture from the plugin.yaml and the directory structure. The code explains itself through organization.
Advanced Pattern: Cross-Layer Communication with Pub/Sub
As your plugin grows, hooks and skills often need to communicate in ways that aren't direct function calls. A hook observes something and needs to notify a skill to take action. A skill completes work and needs to alert the MCP server. This cross-layer communication is best handled through a publish/subscribe pattern.
The StateManager we built earlier is basically a pub/sub system. But you can formalize it with explicit events:
// lib/event-bus.ts
class EventBus {
private listeners = new Map();
subscribe(eventType, callback) {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, []);
}
this.listeners.get(eventType).push(callback);
}
publish(eventType, data) {
const callbacks = this.listeners.get(eventType) || [];
callbacks.forEach(cb => cb(data));
}
}
// Events flow like this:
// 1. Hook: PreToolUse fires -> publishes "tool-use-requested"
// 2. Skill: "validation" skill listens -> validates -> publishes "validation-complete"
// 3. MCP: "status" resource listens -> updates status
// 4. Hook: PostToolUse fires -> records the event
export const eventBus = new EventBus();This pattern decouples layers completely. Hooks don't know about skills. Skills don't know about MCP. But they all communicate through well-defined events. When you need to add a new feature that reacts to something happening, you just subscribe to the relevant event. No rewiring necessary.
Performance Optimization: Async Processing and Batching
In production, some operations in hooks and skills take time. You don't want a hook blocking for 30 seconds while it does heavy processing. The pattern is to use async processing with status tracking.
A hook can defer work:
// Hook: See file is sensitive, mark for async processing
hook: "PreToolUse",
handler: async (input) => {
const isSensitive = await checkSensitivity(input.file_path);
if (isSensitive) {
// Don't block the tool use
// Instead, queue async processing
await queueAsyncJob("classify-sensitive-content", {
file_path: input.file_path,
sessionId: input.session_id
});
return { continue: true }; // Allow tool to proceed
}
}
// Meanwhile, a skill processes the queue
async function processAsyncJobs() {
const jobs = await getQueuedJobs();
for (const job of jobs) {
if (job.type === "classify-sensitive-content") {
const classification = await classifyContent(job.data.file_path);
await stateManager.recordEvent(job.data.sessionId, {
type: "content-classified",
classification
});
}
}
}This pattern prevents hooks from becoming bottlenecks. Work happens in the background. The MCP layer or skills layer can check status asynchronously. Users never wait for heavy processing.
Error Handling Across Layers
When errors happen in one layer, they need to propagate correctly. A skill error shouldn't crash the hook. An MCP tool error shouldn't prevent the system from operating.
The pattern is explicit error handling at boundaries:
async executeSkill(skillName, inputs) {
try {
return await SKILL_REGISTRY[skillName].handler(inputs);
} catch (error) {
// Log the error
logger.error(`Skill ${skillName} failed: ${error.message}`);
// Return structured error that other layers can understand
return {
success: false,
error: error.message,
skillName,
isRetryable: this.isRetryable(error),
timestamp: new Date().toISOString()
};
}
}The key insight: don't throw errors across layer boundaries. Return structured error responses that other layers can interpret and act on. This prevents cascading failures while maintaining visibility.
Testing Multi-Layer Plugins
Testing a bundled plugin is complex because you have multiple layers. The approach is to test at different scopes:
Unit tests: Test hooks, skills, and MCP handlers in isolation. Mock out dependencies. Verify each component works.
Integration tests: Test how layers interact. Run a hook, verify it records state, trigger a skill, verify it reads state correctly.
End-to-end tests: Simulate a user interaction that touches all three layers. Verify the entire workflow works.
Stress tests: Run many concurrent operations through all layers. Verify the system degrades gracefully under load.
Each layer of testing catches different bugs. Unit tests catch logic errors. Integration tests catch coordination bugs. End-to-end tests catch workflow issues. Stress tests catch performance issues.
Deployment Strategies for Bundled Plugins
Once you've built a solid bundled plugin, how do you deploy it without breaking things?
The pattern is progressive rollout:
- Deploy to development and test all three layers thoroughly
- Deploy to staging with real-ish workloads and have your team test manually
- Deploy to production with feature flags for the hooks and skills. Keep them off initially.
- Enable hooks first at low percentage (1% of requests), monitor for errors
- Gradually increase hook percentage (5%, 25%, 50%, 100%) as you gain confidence
- Enable skills once hooks are stable and working well
- Enable MCP once everything else is solid
This progression takes time, but it's worth it. Each stage lets you catch issues at scale before they affect 100% of users.
The Economics of Bundled Plugins
Building a bundled plugin requires upfront investment. You're creating a whole system with hooks, skills, state management, and MCP integration. Is it worth it?
The ROI comes from reusability. Your first bundled plugin takes a month to build properly. Your second takes two weeks because you have patterns. Your third takes one week because you have shared libraries. By the tenth, you're essentially composing existing components.
The other ROI is reach. A bundled plugin that does one thing well can be used by dozens of teams in your organization. The development cost is amortized across many users. That's where bundled plugins become economic: they're infrastructure that many people build on.
Real-World Success Patterns
The bundled plugins that see adoption tend to share characteristics:
Single responsibility with wide applicability — The plugin does one thing, but that thing is useful to many people. A compliance plugin that audits file access. A performance monitoring plugin that helps teams optimize. A data validation plugin that prevents data quality issues.
Clear value proposition — Users understand immediately why the plugin helps them. Not "this is infrastructure we built," but "this saves you X hours per week" or "this prevents Y class of bugs."
Evolves based on feedback — The plugin isn't static. You collect feedback from users. What hooks do they want? What skills would be useful? You add capabilities based on actual usage patterns, not imagined needs.
Good documentation and examples — Developers can read how to use the plugin. There are examples showing hooks, skills, and MCP working together. When new developers join, they can understand the system from documentation.
Maintainability — The code is clean, well-organized, and understandable. The plugin doesn't become a dumping ground for random features. When new developers touch it, they can make changes without fear.
Building Organizational Systems
The deepest pattern is recognizing that bundled plugins are a way to build organizational systems. You're not just building a feature—you're building infrastructure that shapes how your organization works.
The hooks become your nervous system. The skills become your capability system. The MCP becomes your integration system. Together, they form a coherent whole that your organization can build on.
When you see Claude Code adoption increasing because developers are using bundled plugins to work better, you know you've succeeded. The plugins become part of the infrastructure, like version control or CI/CD. People don't think about using them—they just do. That's when bundled plugins have fully delivered value.
Conclusion: From Components to Systems
Building a bundled plugin that combines hooks, skills, and MCP is the difference between building components and building systems. Components are useful. Systems transform how people work.
A hook alone is interesting. A skill alone is useful. An MCP integration alone is nice. But together, they create something more powerful than the sum of parts. They create a system that can observe what's happening, take action based on that observation, and integrate with the wider world.
This is why the architecture we've described matters. It's not just about code organization—it's about creating systems that scale, evolve, and compound in value over time. Your first bundled plugin teaches you how to build these systems. Your subsequent plugins benefit from that knowledge.
The organizations that will dominate the next few years are those that master this pattern. They build bundled plugins as part of their engineering infrastructure. They treat these systems as first-class infrastructure alongside their code. They evolve them based on usage. They share them across teams.
That's the opportunity of bundled plugins. Not just extension points in Claude Code, but organizational infrastructure that scales human capability across teams.
-iNet - Modern DevTools & Engineering Practices