/**
* Plugins Tools Registration
*
* Handles: Plugin management (install, activate, deactivate, update, delete)
*
* @package WP_Navigator_Pro
* @since 1.3.0
*/
import { toolRegistry, ToolCategory } from '../../tool-registry/index.js';
import { validateRequired, buildFieldsParam } from '../../tool-registry/utils.js';
/**
* Normalize plugin identifier for WordPress REST API.
*
* Claude Code's MCP client may pre-encode the `/` character to `%2F` in parameters.
* We first decode any URL-encoded characters, then re-encode individual path segments
* while preserving the slash separator.
*
* The MCP server uses `/wp-json/` path format (not `?rest_route=` query format).
* With path format, the slash in plugin identifiers (e.g., "wordfence/wordfence")
* must NOT be encoded, as web servers decode %2F differently in paths vs query strings.
*
* @example normalizePluginPath('hello') => 'hello'
* @example normalizePluginPath('wordfence/wordfence') => 'wordfence/wordfence'
* @example normalizePluginPath('wordfence%2Fwordfence') => 'wordfence/wordfence'
* @example normalizePluginPath('my plugin/my-file') => 'my%20plugin/my-file'
*/
function normalizePluginPath(plugin: string): string {
// First decode any URL-encoded characters (handles pre-encoded input from Claude Code)
const decoded = decodeURIComponent(plugin);
// Then encode individual parts but preserve the slash for /wp-json/ path format
const result = decoded
.split('/')
.map((part) => encodeURIComponent(part))
.join('/');
return result;
}
/**
* Register plugin management tools
*/
export function registerPluginTools() {
// ============================================================================
// LIST PLUGINS
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_list_plugins',
description:
'List all installed WordPress plugins. Returns plugin identifier in "plugin" field (use this exact value for activate/deactivate/delete operations), name, version, and status (active/inactive). Format varies: single-file plugins use just the name (e.g., "hello"), directory plugins use "directory/file" without .php extension (e.g., "wordfence/wordfence", "wp-navigator-pro/wp-navigator-pro").',
inputSchema: {
type: 'object',
properties: {
status: {
type: 'string',
description:
'Optional filter by status (e.g., "active" or "inactive"). If omitted or set to "all", returns all plugins.',
},
fields: {
type: 'array',
items: { type: 'string' },
description:
'Fields to return (e.g., ["plugin", "name", "status"]). Reduces response size.',
},
},
required: [],
},
},
handler: async (args, context) => {
const params = new URLSearchParams();
if (args.status && args.status !== 'all') {
params.append('status', args.status);
}
const fieldsParam = buildFieldsParam(args.fields);
if (fieldsParam) {
params.append('_fields', fieldsParam);
}
const qs = params.toString();
const endpoint = qs ? `/wp/v2/plugins?${qs}` : '/wp/v2/plugins';
const plugins = await context.wpRequest(endpoint);
return {
content: [{ type: 'text', text: context.clampText(JSON.stringify(plugins, null, 2)) }],
};
},
category: ToolCategory.PLUGINS,
});
// ============================================================================
// GET PLUGIN
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_get_plugin',
description:
'Get details about a specific plugin by slug. Returns full metadata including description, author, and version.',
inputSchema: {
type: 'object',
properties: {
plugin: {
type: 'string',
description:
'Plugin identifier from wpnav_list_plugins "plugin" field (e.g., "wordfence/wordfence", "hello"). Do NOT include .php extension.',
},
fields: {
type: 'array',
items: { type: 'string' },
description:
'Fields to return (e.g., ["plugin", "name", "version"]). Reduces response size.',
},
},
required: ['plugin'],
},
},
handler: async (args, context) => {
validateRequired(args, ['plugin']);
const fieldsParam = buildFieldsParam(args.fields);
const basePath = `/wp/v2/plugins/${normalizePluginPath(args.plugin)}`;
const endpoint = fieldsParam ? `${basePath}?_fields=${fieldsParam}` : basePath;
const plugin = await context.wpRequest(endpoint);
return {
content: [{ type: 'text', text: context.clampText(JSON.stringify(plugin, null, 2)) }],
};
},
category: ToolCategory.PLUGINS,
});
// ============================================================================
// INSTALL PLUGIN
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_install_plugin',
description:
'Install a WordPress plugin from WordPress.org by slug. Changes are logged in audit trail.',
inputSchema: {
type: 'object',
properties: {
slug: { type: 'string', description: 'Plugin slug from WordPress.org (e.g., "akismet")' },
activate: {
type: 'boolean',
description: 'Activate plugin after installation (default: false)',
default: false,
},
},
required: ['slug'],
},
},
handler: async (args, context) => {
try {
validateRequired(args, ['slug']);
const installData: any = { slug: args.slug };
if (args.activate) {
installData.status = 'active';
}
const result = await context.wpRequest('/wp/v2/plugins', {
method: 'POST',
body: JSON.stringify(installData),
});
return {
content: [
{
type: 'text',
text: context.clampText(
JSON.stringify(
{
plugin: result.plugin,
name: result.name,
version: result.version,
status: result.status,
message: 'Plugin installed successfully',
},
null,
2
)
),
},
],
};
} catch (error: any) {
const errorMessage = error.message || 'Unknown error';
const isWritesDisabled = errorMessage.includes('WRITES_DISABLED');
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
error: isWritesDisabled ? 'writes_disabled' : 'operation_failed',
code: isWritesDisabled ? 'WRITES_DISABLED' : 'INSTALL_FAILED',
message: errorMessage,
context: {
resource_type: 'plugin',
slug: args.slug,
suggestion: isWritesDisabled
? 'Set WPNAV_ENABLE_WRITES=1 in MCP server config (.mcp.json env section)'
: 'Check plugin slug exists on WordPress.org',
},
},
null,
2
),
},
],
isError: true,
};
}
},
category: ToolCategory.PLUGINS,
});
// ============================================================================
// ACTIVATE PLUGIN
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_activate_plugin',
description: 'Activate a WordPress plugin by slug. Changes are logged in audit trail.',
inputSchema: {
type: 'object',
properties: {
plugin: {
type: 'string',
description:
'Plugin identifier from wpnav_list_plugins "plugin" field (e.g., "wordfence/wordfence", "hello"). Do NOT include .php extension.',
},
},
required: ['plugin'],
},
},
handler: async (args, context) => {
try {
validateRequired(args, ['plugin']);
const result = await context.wpRequest(
`/wp/v2/plugins/${normalizePluginPath(args.plugin)}`,
{
method: 'POST',
body: JSON.stringify({ status: 'active' }),
}
);
return {
content: [
{
type: 'text',
text: context.clampText(
JSON.stringify(
{
plugin: result.plugin,
name: result.name,
status: result.status,
message: 'Plugin activated successfully',
},
null,
2
)
),
},
],
};
} catch (error: any) {
const errorMessage = error.message || 'Unknown error';
const isWritesDisabled = errorMessage.includes('WRITES_DISABLED');
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
error: isWritesDisabled ? 'writes_disabled' : 'operation_failed',
code: isWritesDisabled ? 'WRITES_DISABLED' : 'ACTIVATE_FAILED',
message: errorMessage,
context: {
resource_type: 'plugin',
plugin: args.plugin,
suggestion: isWritesDisabled
? 'Set WPNAV_ENABLE_WRITES=1 in MCP server config (.mcp.json env section)'
: 'Check plugin is installed with wpnav_list_plugins',
},
},
null,
2
),
},
],
isError: true,
};
}
},
category: ToolCategory.PLUGINS,
});
// ============================================================================
// DEACTIVATE PLUGIN
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_deactivate_plugin',
description: 'Deactivate a WordPress plugin by slug. Changes are logged in audit trail.',
inputSchema: {
type: 'object',
properties: {
plugin: {
type: 'string',
description:
'Plugin identifier from wpnav_list_plugins "plugin" field (e.g., "wordfence/wordfence", "hello"). Do NOT include .php extension.',
},
},
required: ['plugin'],
},
},
handler: async (args, context) => {
try {
validateRequired(args, ['plugin']);
const result = await context.wpRequest(
`/wp/v2/plugins/${normalizePluginPath(args.plugin)}`,
{
method: 'POST',
body: JSON.stringify({ status: 'inactive' }),
}
);
return {
content: [
{
type: 'text',
text: context.clampText(
JSON.stringify(
{
plugin: result.plugin,
name: result.name,
status: result.status,
message: 'Plugin deactivated successfully',
},
null,
2
)
),
},
],
};
} catch (error: any) {
const errorMessage = error.message || 'Unknown error';
const isWritesDisabled = errorMessage.includes('WRITES_DISABLED');
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
error: isWritesDisabled ? 'writes_disabled' : 'operation_failed',
code: isWritesDisabled ? 'WRITES_DISABLED' : 'DEACTIVATE_FAILED',
message: errorMessage,
context: {
resource_type: 'plugin',
plugin: args.plugin,
suggestion: isWritesDisabled
? 'Set WPNAV_ENABLE_WRITES=1 in MCP server config (.mcp.json env section)'
: 'Check plugin exists with wpnav_list_plugins',
},
},
null,
2
),
},
],
isError: true,
};
}
},
category: ToolCategory.PLUGINS,
});
// ============================================================================
// UPDATE PLUGIN
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_update_plugin',
description:
'Update a WordPress plugin to the latest version. Changes are logged in audit trail.',
inputSchema: {
type: 'object',
properties: {
plugin: {
type: 'string',
description:
'Plugin identifier from wpnav_list_plugins "plugin" field (e.g., "wordfence/wordfence", "hello"). Do NOT include .php extension.',
},
},
required: ['plugin'],
},
},
handler: async (args, context) => {
try {
validateRequired(args, ['plugin']);
const result = await context.wpRequest(
`/wp/v2/plugins/${normalizePluginPath(args.plugin)}`,
{
method: 'POST',
body: JSON.stringify({ status: 'active' }), // Update endpoint uses POST with current status
}
);
return {
content: [
{
type: 'text',
text: context.clampText(
JSON.stringify(
{
plugin: result.plugin,
name: result.name,
version: result.version,
message: 'Plugin updated successfully',
},
null,
2
)
),
},
],
};
} catch (error: any) {
const errorMessage = error.message || 'Unknown error';
const isWritesDisabled = errorMessage.includes('WRITES_DISABLED');
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
error: isWritesDisabled ? 'writes_disabled' : 'operation_failed',
code: isWritesDisabled ? 'WRITES_DISABLED' : 'UPDATE_FAILED',
message: errorMessage,
context: {
resource_type: 'plugin',
plugin: args.plugin,
suggestion: isWritesDisabled
? 'Set WPNAV_ENABLE_WRITES=1 in MCP server config (.mcp.json env section)'
: 'Check plugin is installed with wpnav_list_plugins',
},
},
null,
2
),
},
],
isError: true,
};
}
},
category: ToolCategory.PLUGINS,
});
// ============================================================================
// DELETE PLUGIN
// ============================================================================
toolRegistry.register({
definition: {
name: 'wpnav_delete_plugin',
description:
'Delete a WordPress plugin by slug. Plugin must be deactivated first. WARNING: This permanently deletes the plugin files.',
inputSchema: {
type: 'object',
properties: {
plugin: {
type: 'string',
description:
'Plugin identifier from wpnav_list_plugins "plugin" field (e.g., "wordfence/wordfence", "hello"). Do NOT include .php extension.',
},
},
required: ['plugin'],
},
},
handler: async (args, context) => {
try {
validateRequired(args, ['plugin']);
const result = await context.wpRequest(
`/wp/v2/plugins/${normalizePluginPath(args.plugin)}`,
{
method: 'DELETE',
}
);
return {
content: [
{
type: 'text',
text: context.clampText(
JSON.stringify(
{
plugin: result.plugin,
message: 'Plugin deleted successfully',
},
null,
2
)
),
},
],
};
} catch (error: any) {
const errorMessage = error.message || 'Unknown error';
const isWritesDisabled = errorMessage.includes('WRITES_DISABLED');
return {
content: [
{
type: 'text',
text: JSON.stringify(
{
error: isWritesDisabled ? 'writes_disabled' : 'operation_failed',
code: isWritesDisabled ? 'WRITES_DISABLED' : 'DELETE_FAILED',
message: errorMessage,
context: {
resource_type: 'plugin',
plugin: args.plugin,
suggestion: isWritesDisabled
? 'Set WPNAV_ENABLE_WRITES=1 in MCP server config (.mcp.json env section)'
: 'Check plugin is deactivated first with wpnav_deactivate_plugin',
},
},
null,
2
),
},
],
isError: true,
};
}
},
category: ToolCategory.PLUGINS,
});
}