Plugin Authoring Guide
This guide covers how to create, test, and deploy plugins for EVOKORE-MCP. Plugins extend the server with custom tools and resources that are loaded at startup and can be hot-reloaded during a live session.
What this covers
- Plugin manifest shape and discovery rules
- Registering tools and resources
- Webhook integration from a plugin
- Hot-reload behavior at runtime
- Configuration knobs and testing approach
- Best practices and a complete worked example
Overview
Plugins are standalone JavaScript files that register tools and resources with the EVOKORE-MCP server. They are discovered from the plugins/ directory (or a custom path) and loaded automatically at server startup.
When to use plugins:
- You need custom tools that are not provided by child MCP servers or the built-in skill system.
- You want to add domain-specific resources accessible via the MCP
resources/readprotocol. - You want functionality that can be developed and deployed independently of the core server.
- You need to iterate on tool implementations without restarting the server (hot-reload).
How plugins fit into the architecture:
Plugin tools are registered alongside native tools (skills, discovery) and proxied tools (from child servers like GitHub, filesystem, Supabase). When a client calls tools/list, plugin tools appear in the full list. When a client calls a plugin tool, the PluginManager dispatches the call to the plugin's handler function. Plugin resources appear in resources/list and are read through resources/read.
Plugin Structure
A plugin is a single .js file placed in the plugins directory. It must export a manifest object with the following shape:
module.exports = {
name: 'my-plugin', // required -- unique plugin identifier
version: '1.0.0', // optional -- semver version string (defaults to "0.0.0")
register(context) { // required -- called once during plugin load
// Use context to register tools and resources
}
};
Manifest Fields
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Unique identifier for the plugin. Used in diagnostics and webhook events. |
version | string | No | Semver version string. Defaults to "0.0.0" if omitted. |
register | function | Yes | Called once with a PluginContext object. Can be async. |
File Discovery Rules
- Only files ending in
.jsare loaded. - Files starting with
.(dotfiles) are ignored. - Non-JS files (
.json,.txt,.ts, etc.) are ignored. - The
registerfunction can be synchronous or return a Promise (async). - Both
module.exports = { ... }andmodule.exports.default = { ... }export styles are supported.
Directory Layout
plugins/
.gitkeep
example-hello.js # The bundled example plugin
my-custom-plugin.js # Your plugin file
Each plugin is a single file. If your plugin needs helper modules, bundle them into the single file or use require() to load them from a known path.
Tool Definitions
Tools are registered via context.addTool() inside the register function.
addTool Signature
context.addTool(name, schema, handler)
| Parameter | Type | Description |
|---|---|---|
name | string | Tool name. Must be a non-empty string. This is the name clients use to call the tool. |
schema | object | An object with description, inputSchema, and optional annotations. |
handler | function | Async function receiving args and returning an MCP tool result. |
Schema Object
{
description: 'Human-readable description of what the tool does.',
inputSchema: {
type: 'object',
properties: {
param1: { type: 'string', description: 'First parameter' },
param2: { type: 'number', description: 'Second parameter' }
},
required: ['param1'] // optional -- list of required properties
},
annotations: { // optional -- MCP tool annotations
title: 'My Tool',
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}
The inputSchema follows JSON Schema (draft-07) conventions. If omitted, it defaults to { type: "object", properties: {} }.
Handler Function
The handler receives the parsed arguments object and must return an MCP-compatible result:
async (args) => {
const value = args?.myParam || 'default';
return {
content: [{
type: 'text',
text: `Result: ${value}`
}]
};
}
The content array can contain multiple items. Each item has a type (typically "text") and the corresponding data field (text for text content).
Handler Error Handling
If your handler throws an error, the PluginManager propagates it as an MCP error to the client. To return a user-facing error instead of throwing, set isError: true:
async (args) => {
if (!args?.required_field) {
return {
content: [{ type: 'text', text: 'Missing required_field parameter.' }],
isError: true
};
}
// ... normal processing
}
Resource Definitions
Resources are registered via context.addResource() inside the register function.
addResource Signature
context.addResource(uri, metadata, handler)
| Parameter | Type | Description |
|---|---|---|
uri | string | Resource URI. Must be a non-empty string. Clients use this to read the resource. |
metadata | object | An object with name, optional mimeType, and optional description. |
handler | function | Async function returning { text: string }. |
Resource Example
context.addResource(
'plugin://my-plugin/status',
{
name: 'My Plugin Status',
mimeType: 'application/json',
description: 'Returns the current status of my-plugin.'
},
async () => ({
text: JSON.stringify({ healthy: true, uptime: process.uptime() })
})
);
Resources appear in resources/list alongside server-level resources (evokore://server/status, etc.) and skill resources (skill:// URIs).
WebhookManager Integration
Plugins can emit webhook events through the context.emitWebhook() method. This integrates with the EVOKORE-MCP webhook event system, which dispatches HMAC-signed payloads to configured HTTP endpoints.
emitWebhook Signature
context.emitWebhook(event, data)
| Parameter | Type | Description |
|---|---|---|
event | string | The webhook event type (e.g., "tool_call", "tool_error"). |
data | object | Arbitrary data payload. The plugin name is automatically appended. |
Usage Example
register(context) {
context.addTool('process_data', {
description: 'Process incoming data and emit a webhook event.',
inputSchema: {
type: 'object',
properties: {
input: { type: 'string' }
}
}
}, async (args) => {
const result = doSomething(args.input);
// Emit a custom webhook event
context.emitWebhook('tool_call', {
action: 'data_processed',
inputLength: args.input?.length || 0
});
return {
content: [{ type: 'text', text: `Processed: ${result}` }]
};
});
}
The plugin field is automatically added to the data payload by the PluginManager, so the webhook receiver knows which plugin originated the event.
Plugin Lifecycle Events
The PluginManager itself emits webhook events for plugin lifecycle changes:
| Event | When Emitted | Data Fields |
|---|---|---|
plugin_loaded | After a plugin loads successfully | plugin, version, toolCount, resourceCount |
plugin_unloaded | Before clearing plugins during reload | plugin, version |
plugin_load_error | When a plugin fails to load | file, error |
These events are emitted automatically. You do not need to emit them from your plugin code.
When emitWebhook is a No-Op
If the server was constructed without webhooks enabled (EVOKORE_WEBHOOKS_ENABLED is not "true") or no WebhookManager was passed to the PluginManager, calls to context.emitWebhook() are silently ignored. Your plugin does not need to guard against this case.
Hot-Reload
Plugins support hot-reload through the built-in reload_plugins tool. This allows you to add, modify, or remove plugin files without restarting the EVOKORE-MCP server.
How It Works
- The client (or AI agent) calls the
reload_pluginstool (no arguments needed). - The PluginManager clears all previously loaded plugins from memory.
- It deletes the Node.js
require.cacheentry for each plugin file, ensuring the fresh file is read from disk. - It re-scans the plugins directory and loads all
.jsfiles. - The tool catalog is rebuilt and
tools/list_changedis sent to connected clients.
Reload Behavior
- Modified plugins: The updated code is loaded. New tool definitions replace old ones.
- Removed plugins: Tools and resources from deleted plugin files are no longer available.
- Added plugins: New
.jsfiles in the plugins directory are discovered and loaded. - Partial failures: If some plugins fail during reload, the successful ones are still loaded. The response lists any errors.
Reload Response Format
The reload_plugins tool returns a summary:
Plugins reloaded in 12ms: 3 loaded, 1 failed, 5 tools, 2 resources.
Errors:
- broken-plugin.js: Unexpected token (syntax error)
Loaded plugins:
- my-plugin v1.2.0 (2 tools, 1 resources)
- utility-plugin v1.0.0 (3 tools, 1 resources)
- example-hello v1.0.0 (0 tools, 0 resources)
Development Workflow
- Start the EVOKORE-MCP server normally.
- Edit or create a plugin file in the
plugins/directory. - Ask the AI agent to call
reload_plugins(or call it directly via the MCP client). - The new tools are immediately available.
Configuration
EVOKORE_PLUGINS_DIR
By default, the PluginManager loads plugins from the plugins/ directory at the repository root. Override this by setting the EVOKORE_PLUGINS_DIR environment variable:
# In .env or shell environment
EVOKORE_PLUGINS_DIR=/path/to/my/custom/plugins
The path is resolved to an absolute path via path.resolve().
Webhook Configuration
To receive plugin lifecycle events (and events emitted by plugins via context.emitWebhook), configure webhooks in mcp.config.json:
{
"webhooks": [
{
"url": "https://my-endpoint.example.com/hooks",
"events": ["plugin_loaded", "plugin_unloaded", "plugin_load_error", "tool_call"],
"secret": "my-hmac-secret"
}
]
}
And enable webhooks:
# In .env
EVOKORE_WEBHOOKS_ENABLED=true
No Config File Changes Needed for Plugins
Plugins do not require any entry in mcp.config.json. The servers block in that file is for child MCP servers (proxied via stdio or HTTP). Plugins are discovered from the filesystem and loaded by the PluginManager independently.
Testing Plugins
Unit Testing a Plugin Module
You can test a plugin in isolation by constructing a mock PluginContext:
const { describe, it, expect } = require('vitest');
describe('my-plugin', () => {
it('registers the expected tool', () => {
const plugin = require('../plugins/my-plugin.js');
const tools = [];
const mockContext = {
addTool(name, schema, handler) {
tools.push({ name, schema, handler });
},
addResource() {},
log() {},
emitWebhook() {}
};
plugin.register(mockContext);
expect(tools.length).toBe(1);
expect(tools[0].name).toBe('my_tool');
});
it('handler returns expected output', async () => {
const plugin = require('../plugins/my-plugin.js');
let capturedHandler;
const mockContext = {
addTool(name, schema, handler) {
capturedHandler = handler;
},
addResource() {},
log() {},
emitWebhook() {}
};
plugin.register(mockContext);
const result = await capturedHandler({ input: 'test' });
expect(result.content[0].text).toContain('test');
});
});
Integration Testing with PluginManager
You can test plugins through the real PluginManager by pointing it at a temporary directory:
const { describe, it, expect, beforeEach, afterEach } = require('vitest');
const fsp = require('fs/promises');
const path = require('path');
const os = require('os');
describe('my-plugin integration', () => {
let tmpDir;
let originalEnv;
beforeEach(async () => {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'plugin-test-'));
originalEnv = process.env.EVOKORE_PLUGINS_DIR;
process.env.EVOKORE_PLUGINS_DIR = tmpDir;
});
afterEach(async () => {
if (originalEnv === undefined) {
delete process.env.EVOKORE_PLUGINS_DIR;
} else {
process.env.EVOKORE_PLUGINS_DIR = originalEnv;
}
await fsp.rm(tmpDir, { recursive: true, force: true });
});
it('loads and calls the tool', async () => {
// Copy your plugin to the temp dir
await fsp.copyFile(
path.join(__dirname, '..', 'plugins', 'my-plugin.js'),
path.join(tmpDir, 'my-plugin.js')
);
const { PluginManager } = require('../dist/PluginManager.js');
const pm = new PluginManager();
const result = await pm.loadPlugins();
expect(result.loaded).toBe(1);
expect(pm.isPluginTool('my_tool')).toBe(true);
const callResult = await pm.handleToolCall('my_tool', { input: 'hello' });
expect(callResult.content[0].text).toBeDefined();
});
});
Running Tests
EVOKORE-MCP uses vitest as its test runner:
# Run all tests
npx vitest run
# Run only plugin-related tests
npx vitest run tests/integration/plugin-system.test.ts
npx vitest run tests/integration/webhook-plugin-integration.test.ts
Best Practices
Naming
- Use a descriptive, unique
namefor your plugin (e.g.,"jira-integration", not"plugin1"). - Use
snake_casefor tool names to match MCP conventions (e.g.,create_ticket, notcreateTicket). - Avoid tool names that could collide with native tools (
discover_tools,refresh_skills,reload_plugins,fetch_skill) or with tools from child servers. Prefixing tool names with your plugin name is a safe approach (e.g.,jira_create_ticket).
Error Handling
- Always validate input arguments at the start of your handler.
- Return
isError: truefor user-recoverable errors rather than throwing. - Throw errors only for unexpected failures (bugs, missing dependencies).
- Never let unhandled promise rejections escape your handler.
async (args) => {
if (!args?.url) {
return {
content: [{ type: 'text', text: 'The "url" parameter is required.' }],
isError: true
};
}
try {
const data = await fetchData(args.url);
return { content: [{ type: 'text', text: JSON.stringify(data) }] };
} catch (err) {
return {
content: [{ type: 'text', text: `Failed to fetch: ${err.message}` }],
isError: true
};
}
}
Security
- Never hardcode secrets, API keys, or credentials in plugin files. Read them from environment variables via
process.env. - Be cautious with
context.emitWebhook()-- do not include sensitive data in webhook payloads. - If your plugin executes user-provided input, sanitize it to prevent injection attacks.
- Plugin files are loaded via
require(), so they run with full Node.js privileges. Only load plugins from trusted sources.
Performance
- Keep the
registerfunction fast. Heavy initialization (network calls, large file reads) should happen lazily inside tool handlers, not during registration. - Avoid blocking the event loop in handlers. Use async I/O for file and network operations.
- If a tool handler is expensive, document it in the tool description so the AI agent can make informed decisions about when to call it.
Annotations
Use MCP tool annotations to give clients metadata about your tool's behavior:
annotations: {
title: 'Human-Friendly Tool Name',
readOnlyHint: true, // true if the tool only reads data
destructiveHint: false, // true if the tool modifies/deletes data
idempotentHint: true, // true if calling twice has same effect as once
openWorldHint: false // true if the tool accesses external systems
}
Logging
Use context.log() for diagnostic output. Messages are prefixed with the plugin name and sent to stderr:
context.log('Initializing database connection...');
// Output: [EVOKORE][plugin:my-plugin] Initializing database connection...
Do not use console.log() for logging -- it writes to stdout, which is reserved for the MCP JSON-RPC transport in stdio mode. Use console.error() or context.log() instead.
Example Plugin
Here is a complete walkthrough of the bundled example-hello plugin that ships with EVOKORE-MCP.
Source: plugins/example-hello.js
/**
* Example EVOKORE-MCP Plugin
*
* Demonstrates the plugin contract by registering a simple hello_world tool.
* Place .js files in the plugins/ directory (or the path set by EVOKORE_PLUGINS_DIR)
* and they will be loaded automatically at server startup.
*
* Reload without restarting the server by calling the reload_plugins tool.
*
* Plugin contract:
* module.exports = {
* name: string, // required - unique plugin identifier
* version: string, // optional - semver version
* register(context): void // required - called once to register tools/resources
* }
*
* context API:
* context.addTool(name, schema, handler)
* context.addResource(uri, meta, handler)
* context.log(message)
*/
module.exports = {
name: 'example-hello',
version: '1.0.0',
register(context) {
context.log('Registering hello_world tool');
context.addTool(
'hello_world',
{
description: 'A simple example tool that returns a greeting. Demonstrates the EVOKORE-MCP plugin contract.',
inputSchema: {
type: 'object',
properties: {
name: {
type: 'string',
description: 'Name to greet (default: "World")'
}
}
}
},
async (args) => {
const name = args?.name || 'World';
return {
content: [{
type: 'text',
text: `Hello, ${name}! This response comes from the example-hello plugin.`
}]
};
}
);
}
};
What Happens at Startup
- The
PluginManagerscansplugins/and findsexample-hello.js. - It
require()s the file and validates the manifest (nameandregisterare present). - It calls
register(context), which registers thehello_worldtool. - The tool catalog is rebuilt to include
hello_worldalongside native and proxied tools. - If webhooks are enabled, a
plugin_loadedevent is emitted with{ plugin: "example-hello", version: "1.0.0", toolCount: 1, resourceCount: 0 }.
Calling the Tool
Once the server is running, any MCP client can call the tool:
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "hello_world",
"arguments": { "name": "Alice" }
}
}
Response:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [{
"type": "text",
"text": "Hello, Alice! This response comes from the example-hello plugin."
}]
}
}
Building on the Example
To create your own plugin, copy example-hello.js, rename it, and modify the name, version, and register function to register your own tools and resources. Then call reload_plugins or restart the server to pick up the new file.
PluginContext API Reference
The context object passed to the register function provides these methods:
| Method | Signature | Description |
|---|---|---|
addTool | addTool(name: string, schema: { description, inputSchema, annotations? }, handler: fn) | Register a tool with the given name and handler. |
addResource | addResource(uri: string, meta: { name, mimeType?, description? }, handler: fn) | Register a resource at the given URI. |
log | log(message: string) | Log a message to stderr with plugin name prefix. |
emitWebhook | emitWebhook(event: string, data: Record<string, unknown>) | Emit a webhook event (no-op if webhooks disabled). |
See also
- Architecture - runtime modules and request routing
- Webhook Guide - event types, signing, and replay protection
- Webhook Envelope v1 - frozen payload schema
- Testing and Validation - plugin and webhook test surface
Last verified: 2026-05-20