@adsim/wordpress-mcp-server 4.5.0 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -11
- package/dxt/manifest.json +17 -6
- package/index.js +327 -164
- package/package.json +1 -1
- package/src/plugins/IPluginAdapter.js +95 -0
- package/src/plugins/adapters/acf/acfAdapter.js +181 -0
- package/src/plugins/adapters/elementor/elementorAdapter.js +176 -0
- package/src/plugins/contextGuard.js +57 -0
- package/src/plugins/registry.js +94 -0
- package/tests/unit/pluginLayer.test.js +151 -0
- package/tests/unit/plugins/acf/acfAdapter.test.js +205 -0
- package/tests/unit/plugins/acf/acfAdapter.write.test.js +157 -0
- package/tests/unit/plugins/contextGuard.test.js +51 -0
- package/tests/unit/plugins/elementor/elementorAdapter.test.js +206 -0
- package/tests/unit/plugins/iPluginAdapter.test.js +34 -0
- package/tests/unit/plugins/registry.test.js +84 -0
- package/tests/unit/tools/dynamicFiltering.test.js +136 -0
- package/tests/unit/tools/outputCompression.test.js +342 -0
- package/tests/unit/tools/site.test.js +3 -1
- package/tests/unit/tools/siteOptions.test.js +101 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adsim/wordpress-mcp-server",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.6.0",
|
|
4
4
|
"description": "A Model Context Protocol (MCP) server for WordPress REST API integration. Manage posts, search content, and interact with your WordPress site through any MCP-compatible client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Adapter Interface — contract every adapter must implement.
|
|
3
|
+
*
|
|
4
|
+
* @typedef {Object} IPluginAdapter
|
|
5
|
+
* @property {string} id
|
|
6
|
+
* Unique identifier for the plugin (e.g. "acf", "elementor", "astra").
|
|
7
|
+
*
|
|
8
|
+
* @property {string} namespace
|
|
9
|
+
* REST API namespace used for detection (e.g. "acf/v3", "elementor/v1").
|
|
10
|
+
*
|
|
11
|
+
* @property {"low"|"medium"|"high"|"critical"} riskLevel
|
|
12
|
+
* Global risk level for audit logging.
|
|
13
|
+
* - low: read-only operations, zero side-effects
|
|
14
|
+
* - medium: modifies a single targeted resource
|
|
15
|
+
* - high: cascading modifications across multiple resources
|
|
16
|
+
* - critical: site-wide visual / structural impact
|
|
17
|
+
*
|
|
18
|
+
* @property {Object} contextConfig
|
|
19
|
+
* Context-size guardrails for this adapter.
|
|
20
|
+
* @property {number} contextConfig.maxChars
|
|
21
|
+
* Maximum serialised response size in characters (default 30 000).
|
|
22
|
+
* @property {string} contextConfig.defaultMode
|
|
23
|
+
* Default response mode if the caller omits it (typically "compact").
|
|
24
|
+
* @property {string[]} contextConfig.supportedModes
|
|
25
|
+
* Modes the adapter's tools accept (e.g. ["raw", "compact", "summary", "ids_only"]).
|
|
26
|
+
*
|
|
27
|
+
* @property {Function} getTools
|
|
28
|
+
* Returns an array of MCP tool definition objects
|
|
29
|
+
* ({ name, description, inputSchema, handler }).
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const VALID_RISK_LEVELS = ['low', 'medium', 'high', 'critical'];
|
|
33
|
+
|
|
34
|
+
const REQUIRED_FIELDS = [
|
|
35
|
+
{ key: 'id', type: 'string' },
|
|
36
|
+
{ key: 'namespace', type: 'string' },
|
|
37
|
+
{ key: 'riskLevel', type: 'string', enum: VALID_RISK_LEVELS },
|
|
38
|
+
{ key: 'contextConfig', type: 'object' },
|
|
39
|
+
{ key: 'getTools', type: 'function' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate that an adapter object satisfies the IPluginAdapter contract.
|
|
44
|
+
*
|
|
45
|
+
* @param {object} adapter Adapter object to validate
|
|
46
|
+
* @returns {{ valid: boolean, errors: string[] }}
|
|
47
|
+
*/
|
|
48
|
+
export function validateAdapter(adapter) {
|
|
49
|
+
const errors = [];
|
|
50
|
+
|
|
51
|
+
if (!adapter || typeof adapter !== 'object') {
|
|
52
|
+
return { valid: false, errors: ['adapter must be a non-null object'] };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
for (const field of REQUIRED_FIELDS) {
|
|
56
|
+
const value = adapter[field.key];
|
|
57
|
+
|
|
58
|
+
if (value === undefined || value === null) {
|
|
59
|
+
errors.push(`missing required field: "${field.key}"`);
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (field.type === 'function') {
|
|
64
|
+
if (typeof value !== 'function') {
|
|
65
|
+
errors.push(`"${field.key}" must be a function, got ${typeof value}`);
|
|
66
|
+
}
|
|
67
|
+
} else if (field.type === 'object') {
|
|
68
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
69
|
+
errors.push(`"${field.key}" must be a plain object`);
|
|
70
|
+
}
|
|
71
|
+
} else if (typeof value !== field.type) {
|
|
72
|
+
errors.push(`"${field.key}" must be a ${field.type}, got ${typeof value}`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (field.enum && !field.enum.includes(value)) {
|
|
76
|
+
errors.push(`"${field.key}" must be one of: ${field.enum.join(', ')}; got "${value}"`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// contextConfig sub-fields
|
|
81
|
+
if (adapter.contextConfig && typeof adapter.contextConfig === 'object') {
|
|
82
|
+
const cc = adapter.contextConfig;
|
|
83
|
+
if (cc.maxChars !== undefined && typeof cc.maxChars !== 'number') {
|
|
84
|
+
errors.push('"contextConfig.maxChars" must be a number');
|
|
85
|
+
}
|
|
86
|
+
if (cc.defaultMode !== undefined && typeof cc.defaultMode !== 'string') {
|
|
87
|
+
errors.push('"contextConfig.defaultMode" must be a string');
|
|
88
|
+
}
|
|
89
|
+
if (cc.supportedModes !== undefined && !Array.isArray(cc.supportedModes)) {
|
|
90
|
+
errors.push('"contextConfig.supportedModes" must be an array');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { valid: errors.length === 0, errors };
|
|
95
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACF (Advanced Custom Fields) Adapter — read + write.
|
|
3
|
+
*
|
|
4
|
+
* Provides 4 tools for ACF data via the ACF REST API (acf/v3):
|
|
5
|
+
* - acf_get_fields Read custom fields for a post or page
|
|
6
|
+
* - acf_list_field_groups List registered field groups
|
|
7
|
+
* - acf_get_field_group Get full details of a field group by ID
|
|
8
|
+
* - acf_update_fields Update custom fields (write — blocked by WP_READ_ONLY)
|
|
9
|
+
*
|
|
10
|
+
* Read tools are riskLevel "low". The write tool checks WP_READ_ONLY before
|
|
11
|
+
* making any API call. All responses pass through contextGuard.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { applyContextGuard } from '../../contextGuard.js';
|
|
15
|
+
|
|
16
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function json(data) {
|
|
19
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function auditLog(entry) {
|
|
23
|
+
console.error(`[AUDIT] ${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ── Tool handlers ───────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} args
|
|
30
|
+
* @param {Function} apiRequest wpApiCall-compatible: (endpoint, options?) => JSON
|
|
31
|
+
*/
|
|
32
|
+
async function handleGetFields(args, apiRequest) {
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
const { id, post_type = 'posts', fields, mode = 'compact' } = args;
|
|
35
|
+
|
|
36
|
+
const data = await apiRequest(`/${post_type}/${id}`, { basePath: '/wp-json/acf/v3' });
|
|
37
|
+
|
|
38
|
+
let acfData = data?.acf ?? data ?? {};
|
|
39
|
+
|
|
40
|
+
// Filter to requested keys
|
|
41
|
+
if (fields && fields.length > 0) {
|
|
42
|
+
const filtered = {};
|
|
43
|
+
for (const k of fields) {
|
|
44
|
+
if (k in acfData) filtered[k] = acfData[k];
|
|
45
|
+
}
|
|
46
|
+
acfData = filtered;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const guarded = applyContextGuard(
|
|
50
|
+
{ id, post_type, fields_count: Object.keys(acfData).length, acf: acfData },
|
|
51
|
+
{ toolName: 'acf_get_fields', mode, maxChars: 30000 }
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
auditLog({ tool: 'acf_get_fields', action: 'read_acf_fields', target: id, status: 'success', latency_ms: Date.now() - t0 });
|
|
55
|
+
return json(guarded);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function handleListFieldGroups(args, apiRequest) {
|
|
59
|
+
const t0 = Date.now();
|
|
60
|
+
|
|
61
|
+
const groups = await apiRequest('/field-groups', { basePath: '/wp-json/acf/v3' });
|
|
62
|
+
const list = Array.isArray(groups) ? groups : [];
|
|
63
|
+
|
|
64
|
+
const summary = list.map(g => ({
|
|
65
|
+
id: g.id,
|
|
66
|
+
title: g.title?.rendered ?? g.title ?? '',
|
|
67
|
+
fields: (g.fields ?? []).map(f => f.name ?? f.key ?? f.label ?? ''),
|
|
68
|
+
location: g.location ?? [],
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
auditLog({ tool: 'acf_list_field_groups', action: 'list_field_groups', status: 'success', latency_ms: Date.now() - t0, params: { count: summary.length } });
|
|
72
|
+
return json({ total: summary.length, field_groups: summary });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function handleGetFieldGroup(args, apiRequest) {
|
|
76
|
+
const t0 = Date.now();
|
|
77
|
+
const { id } = args;
|
|
78
|
+
|
|
79
|
+
const group = await apiRequest(`/field-groups/${id}`, { basePath: '/wp-json/acf/v3' });
|
|
80
|
+
|
|
81
|
+
auditLog({ tool: 'acf_get_field_group', action: 'read_field_group', target: id, status: 'success', latency_ms: Date.now() - t0 });
|
|
82
|
+
return json(group);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function handleUpdateFields(args, apiRequest) {
|
|
86
|
+
const t0 = Date.now();
|
|
87
|
+
const { id, post_type = 'posts', fields } = args;
|
|
88
|
+
|
|
89
|
+
// Validate id is a positive integer
|
|
90
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
|
|
91
|
+
throw new Error('Validation error: "id" must be a positive integer');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Validate fields is a non-empty object
|
|
95
|
+
if (!fields || typeof fields !== 'object' || Array.isArray(fields) || Object.keys(fields).length === 0) {
|
|
96
|
+
throw new Error('Validation error: "fields" must be a non-empty object of key/value pairs');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Governance: WP_READ_ONLY check
|
|
100
|
+
if (process.env.WP_READ_ONLY === 'true') {
|
|
101
|
+
auditLog({ tool: 'acf_update_fields', action: 'update_acf_fields', target: id, status: 'blocked', latency_ms: Date.now() - t0 });
|
|
102
|
+
return json({ error: 'Blocked: READ-ONLY mode' });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const data = await apiRequest(`/${post_type}/${id}`, {
|
|
106
|
+
basePath: '/wp-json/acf/v3',
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: { fields },
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const updatedFields = data?.acf ?? data ?? {};
|
|
112
|
+
|
|
113
|
+
auditLog({ tool: 'acf_update_fields', action: 'update_acf_fields', target: id, status: 'success', latency_ms: Date.now() - t0 });
|
|
114
|
+
return json({ id, post_type, updated_fields: updatedFields });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Tool definitions (MCP format) ───────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
const TOOLS = [
|
|
120
|
+
{
|
|
121
|
+
name: 'acf_get_fields',
|
|
122
|
+
description: 'Use to read ACF custom fields for a post or page. Read-only.',
|
|
123
|
+
inputSchema: {
|
|
124
|
+
type: 'object',
|
|
125
|
+
properties: {
|
|
126
|
+
id: { type: 'number' },
|
|
127
|
+
post_type: { type: 'string', enum: ['posts', 'pages'], default: 'posts' },
|
|
128
|
+
fields: { type: 'array', items: { type: 'string' }, description: 'Return only these field keys (returns all if omitted)' },
|
|
129
|
+
mode: { type: 'string', enum: ['raw', 'compact', 'summary'], default: 'compact' },
|
|
130
|
+
},
|
|
131
|
+
required: ['id'],
|
|
132
|
+
},
|
|
133
|
+
handler: handleGetFields,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'acf_list_field_groups',
|
|
137
|
+
description: 'Use to list ACF field groups registered on the site. Read-only.',
|
|
138
|
+
inputSchema: { type: 'object', properties: {} },
|
|
139
|
+
handler: handleListFieldGroups,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: 'acf_get_field_group',
|
|
143
|
+
description: 'Use to get full details of an ACF field group by ID. Read-only.',
|
|
144
|
+
inputSchema: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
id: { type: 'number' },
|
|
148
|
+
},
|
|
149
|
+
required: ['id'],
|
|
150
|
+
},
|
|
151
|
+
handler: handleGetFieldGroup,
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'acf_update_fields',
|
|
155
|
+
description: 'Use to update ACF custom fields for a post or page. Write — blocked by WP_READ_ONLY.',
|
|
156
|
+
inputSchema: {
|
|
157
|
+
type: 'object',
|
|
158
|
+
properties: {
|
|
159
|
+
id: { type: 'number' },
|
|
160
|
+
post_type: { type: 'string', enum: ['posts', 'pages'], default: 'posts' },
|
|
161
|
+
fields: { type: 'object', additionalProperties: true, description: 'Key/value pairs of ACF fields to update' },
|
|
162
|
+
},
|
|
163
|
+
required: ['id', 'fields'],
|
|
164
|
+
},
|
|
165
|
+
handler: handleUpdateFields,
|
|
166
|
+
},
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
// ── Adapter export ──────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
export const acfAdapter = {
|
|
172
|
+
id: 'acf',
|
|
173
|
+
namespace: 'acf/v3',
|
|
174
|
+
riskLevel: 'medium',
|
|
175
|
+
contextConfig: {
|
|
176
|
+
maxChars: 30000,
|
|
177
|
+
defaultMode: 'compact',
|
|
178
|
+
supportedModes: ['raw', 'compact', 'summary', 'ids_only'],
|
|
179
|
+
},
|
|
180
|
+
getTools: () => TOOLS,
|
|
181
|
+
};
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Elementor Adapter — read-only.
|
|
3
|
+
*
|
|
4
|
+
* Provides 3 tools for reading Elementor data via the REST API (elementor/v1):
|
|
5
|
+
* - elementor_list_templates List templates (page, section, block, popup)
|
|
6
|
+
* - elementor_get_template Get full template content and elements
|
|
7
|
+
* - elementor_get_page_data Get Elementor editor data for a post/page
|
|
8
|
+
*
|
|
9
|
+
* All tools are read-only (riskLevel: "low") and pass responses through
|
|
10
|
+
* contextGuard to prevent oversized payloads from consuming LLM context.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { applyContextGuard } from '../../contextGuard.js';
|
|
14
|
+
|
|
15
|
+
// ── Helpers ─────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function json(data) {
|
|
18
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function auditLog(entry) {
|
|
22
|
+
console.error(`[AUDIT] ${JSON.stringify({ timestamp: new Date().toISOString(), ...entry })}`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── Tool handlers ───────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
async function handleListTemplates(args, apiRequest) {
|
|
28
|
+
const t0 = Date.now();
|
|
29
|
+
const { limit = 20, type } = args;
|
|
30
|
+
|
|
31
|
+
const data = await apiRequest('/templates', { basePath: '/wp-json/elementor/v1' });
|
|
32
|
+
let list = Array.isArray(data) ? data : [];
|
|
33
|
+
|
|
34
|
+
// Filter by type if requested
|
|
35
|
+
if (type) {
|
|
36
|
+
list = list.filter(t => t.type === type);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Apply limit
|
|
40
|
+
list = list.slice(0, limit);
|
|
41
|
+
|
|
42
|
+
const templates = list.map(t => ({
|
|
43
|
+
id: t.id,
|
|
44
|
+
title: t.title?.rendered ?? t.title ?? '',
|
|
45
|
+
type: t.type ?? 'unknown',
|
|
46
|
+
author: t.author ?? null,
|
|
47
|
+
date: t.date ?? null,
|
|
48
|
+
source: t.source ?? 'local',
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
auditLog({ tool: 'elementor_list_templates', action: 'list_templates', status: 'success', latency_ms: Date.now() - t0, params: { count: templates.length, type: type || 'all' } });
|
|
52
|
+
return json({ total: templates.length, templates });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function handleGetTemplate(args, apiRequest) {
|
|
56
|
+
const t0 = Date.now();
|
|
57
|
+
const { id } = args;
|
|
58
|
+
|
|
59
|
+
if (typeof id !== 'number' || !Number.isInteger(id) || id <= 0) {
|
|
60
|
+
throw new Error('Validation error: "id" must be a positive integer');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const data = await apiRequest(`/templates/${id}`, { basePath: '/wp-json/elementor/v1' });
|
|
64
|
+
|
|
65
|
+
const result = {
|
|
66
|
+
id: data.id ?? id,
|
|
67
|
+
title: data.title?.rendered ?? data.title ?? '',
|
|
68
|
+
type: data.type ?? 'unknown',
|
|
69
|
+
content: data.content ?? data.elements ?? data,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const guarded = applyContextGuard(result, { toolName: 'elementor_get_template', mode: 'compact', maxChars: 50000 });
|
|
73
|
+
|
|
74
|
+
auditLog({ tool: 'elementor_get_template', action: 'read_template', target: id, status: 'success', latency_ms: Date.now() - t0 });
|
|
75
|
+
return json(guarded);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function handleGetPageData(args, apiRequest) {
|
|
79
|
+
const t0 = Date.now();
|
|
80
|
+
const { post_id } = args;
|
|
81
|
+
|
|
82
|
+
if (typeof post_id !== 'number' || !Number.isInteger(post_id) || post_id <= 0) {
|
|
83
|
+
throw new Error('Validation error: "post_id" must be a positive integer');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let data;
|
|
87
|
+
try {
|
|
88
|
+
data = await apiRequest(`/document/${post_id}`, { basePath: '/wp-json/elementor/v1' });
|
|
89
|
+
} catch {
|
|
90
|
+
// Fallback: read post meta _elementor_data via WP REST API
|
|
91
|
+
const post = await apiRequest(`/posts/${post_id}`, { basePath: '/wp-json/wp/v2' });
|
|
92
|
+
data = post?.meta?._elementor_data ?? null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Parse elements to extract widget info
|
|
96
|
+
const elements = Array.isArray(data?.elements) ? data.elements : (Array.isArray(data) ? data : []);
|
|
97
|
+
const widgetsUsed = new Set();
|
|
98
|
+
let elementsCount = 0;
|
|
99
|
+
|
|
100
|
+
function walkElements(els) {
|
|
101
|
+
for (const el of els) {
|
|
102
|
+
elementsCount++;
|
|
103
|
+
if (el.widgetType) widgetsUsed.add(el.widgetType);
|
|
104
|
+
if (el.elType === 'widget' && el.widgetType) widgetsUsed.add(el.widgetType);
|
|
105
|
+
if (Array.isArray(el.elements)) walkElements(el.elements);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
walkElements(elements);
|
|
109
|
+
|
|
110
|
+
const result = {
|
|
111
|
+
post_id,
|
|
112
|
+
elementor_status: elements.length > 0 ? 'active' : 'inactive',
|
|
113
|
+
widgets_used: [...widgetsUsed],
|
|
114
|
+
elements_count: elementsCount,
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const guarded = applyContextGuard(result, { toolName: 'elementor_get_page_data', mode: 'compact', maxChars: 50000 });
|
|
118
|
+
|
|
119
|
+
auditLog({ tool: 'elementor_get_page_data', action: 'read_page_data', target: post_id, status: 'success', latency_ms: Date.now() - t0 });
|
|
120
|
+
return json(guarded);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ── Tool definitions (MCP format) ───────────────────────────────────────
|
|
124
|
+
|
|
125
|
+
const TOOLS = [
|
|
126
|
+
{
|
|
127
|
+
name: 'elementor_list_templates',
|
|
128
|
+
description: 'Use to list Elementor templates (page, section, block, popup). Read-only.',
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: 'object',
|
|
131
|
+
properties: {
|
|
132
|
+
limit: { type: 'number', default: 20, description: 'Max templates to return (default 20)' },
|
|
133
|
+
type: { type: 'string', enum: ['page', 'section', 'block', 'popup'], description: 'Filter by template type (optional)' },
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
handler: handleListTemplates,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
name: 'elementor_get_template',
|
|
140
|
+
description: 'Use to get full Elementor template content and elements. Read-only.',
|
|
141
|
+
inputSchema: {
|
|
142
|
+
type: 'object',
|
|
143
|
+
properties: {
|
|
144
|
+
id: { type: 'number', description: 'Template ID' },
|
|
145
|
+
},
|
|
146
|
+
required: ['id'],
|
|
147
|
+
},
|
|
148
|
+
handler: handleGetTemplate,
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: 'elementor_get_page_data',
|
|
152
|
+
description: 'Use to get Elementor editor data for a post or page. Read-only.',
|
|
153
|
+
inputSchema: {
|
|
154
|
+
type: 'object',
|
|
155
|
+
properties: {
|
|
156
|
+
post_id: { type: 'number', description: 'Post or page ID' },
|
|
157
|
+
},
|
|
158
|
+
required: ['post_id'],
|
|
159
|
+
},
|
|
160
|
+
handler: handleGetPageData,
|
|
161
|
+
},
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
// ── Adapter export ──────────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export const elementorAdapter = {
|
|
167
|
+
id: 'elementor',
|
|
168
|
+
namespace: 'elementor/v1',
|
|
169
|
+
riskLevel: 'low',
|
|
170
|
+
contextConfig: {
|
|
171
|
+
maxChars: 50000,
|
|
172
|
+
defaultMode: 'compact',
|
|
173
|
+
supportedModes: ['raw', 'compact', 'summary', 'ids_only'],
|
|
174
|
+
},
|
|
175
|
+
getTools: () => TOOLS,
|
|
176
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Guard — prevents oversized responses from consuming LLM context.
|
|
3
|
+
*
|
|
4
|
+
* Every plugin-layer tool handler must pass its response through
|
|
5
|
+
* applyContextGuard() before returning. If the serialised JSON exceeds
|
|
6
|
+
* maxChars and the caller did not request mode='raw', the response is
|
|
7
|
+
* truncated and a warning is attached.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {*} data Raw response data (object, array, string…)
|
|
12
|
+
* @param {object} options
|
|
13
|
+
* @param {string} options.toolName Tool name for logging
|
|
14
|
+
* @param {string} [options.mode] Caller-requested mode (raw = no truncation)
|
|
15
|
+
* @param {number} [options.maxChars] Max serialised chars (default 50 000)
|
|
16
|
+
* @returns {*} Original data or truncated wrapper
|
|
17
|
+
*/
|
|
18
|
+
export function applyContextGuard(data, options = {}) {
|
|
19
|
+
const { toolName = 'unknown', mode, maxChars = 50000 } = options;
|
|
20
|
+
const serialised = JSON.stringify(data);
|
|
21
|
+
const size = serialised.length;
|
|
22
|
+
|
|
23
|
+
if (size > maxChars && mode !== 'raw') {
|
|
24
|
+
console.error(JSON.stringify({
|
|
25
|
+
tool: toolName,
|
|
26
|
+
event: 'context_overflow',
|
|
27
|
+
size_chars: size,
|
|
28
|
+
max_chars: maxChars,
|
|
29
|
+
truncated: true,
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const truncated = serialised.substring(0, maxChars);
|
|
33
|
+
|
|
34
|
+
// Try to parse the truncated JSON back into an object; fall back to string
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
// Find last complete JSON boundary to avoid broken UTF-8 / structure
|
|
38
|
+
const lastBrace = Math.max(truncated.lastIndexOf('}'), truncated.lastIndexOf(']'));
|
|
39
|
+
if (lastBrace > 0) {
|
|
40
|
+
parsed = JSON.parse(truncated.substring(0, lastBrace + 1));
|
|
41
|
+
} else {
|
|
42
|
+
parsed = truncated;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
parsed = truncated;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
_truncated: true,
|
|
50
|
+
_original_size: size,
|
|
51
|
+
_warning: "Response truncated. Use mode='raw' to get full data.",
|
|
52
|
+
data: parsed,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return data;
|
|
57
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin Layer Registry — runtime detection and tool aggregation.
|
|
3
|
+
*
|
|
4
|
+
* Detects active WordPress plugins (ACF, Elementor, Astra) by inspecting
|
|
5
|
+
* the REST API namespaces at /wp-json/. Aggregates tools from active adapters
|
|
6
|
+
* and respects the WP_DISABLE_PLUGIN_LAYERS governance flag.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const KNOWN_PLUGINS = [
|
|
10
|
+
{ id: 'acf', namespace: 'acf/v3' },
|
|
11
|
+
{ id: 'elementor', namespace: 'elementor/v1' },
|
|
12
|
+
{ id: 'astra', namespace: 'astra/v1' },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
export class PluginRegistry {
|
|
16
|
+
constructor() {
|
|
17
|
+
/** @type {Map<string, { id: string, namespace: string, version: string|null, tools: object[] }>} */
|
|
18
|
+
this.active = new Map();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Detect active plugins by calling GET /wp-json/ and inspecting namespaces.
|
|
23
|
+
*
|
|
24
|
+
* @param {Function} apiRequest A function that takes (endpoint, options) and
|
|
25
|
+
* returns parsed JSON. Must support basePath override to hit /wp-json/.
|
|
26
|
+
* @returns {Promise<{ active: string[], disabled_by_env: boolean }>}
|
|
27
|
+
*/
|
|
28
|
+
async initialize(apiRequest) {
|
|
29
|
+
const discovery = await apiRequest('/', { basePath: '/wp-json' });
|
|
30
|
+
const namespaces = discovery?.namespaces || [];
|
|
31
|
+
|
|
32
|
+
for (const known of KNOWN_PLUGINS) {
|
|
33
|
+
if (namespaces.includes(known.namespace)) {
|
|
34
|
+
this.active.set(known.id, {
|
|
35
|
+
id: known.id,
|
|
36
|
+
namespace: known.namespace,
|
|
37
|
+
version: null,
|
|
38
|
+
tools: [],
|
|
39
|
+
});
|
|
40
|
+
console.error(JSON.stringify({
|
|
41
|
+
event: 'plugin_layer_detected',
|
|
42
|
+
plugin: known.id,
|
|
43
|
+
namespaces_found: namespaces.filter(ns => ns.startsWith(known.id)),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return this.getSummary();
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Return all tools from active adapters.
|
|
53
|
+
* Returns [] if WP_DISABLE_PLUGIN_LAYERS is true.
|
|
54
|
+
*
|
|
55
|
+
* @returns {object[]}
|
|
56
|
+
*/
|
|
57
|
+
getAvailableTools() {
|
|
58
|
+
if (process.env.WP_DISABLE_PLUGIN_LAYERS === 'true') return [];
|
|
59
|
+
return [...this.active.values()].flatMap(a => a.tools ?? []);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check whether a plugin is detected as active.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} pluginId e.g. "acf", "elementor", "astra"
|
|
66
|
+
* @returns {boolean}
|
|
67
|
+
*/
|
|
68
|
+
isActive(pluginId) {
|
|
69
|
+
return this.active.has(pluginId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Return a summary of the registry state.
|
|
74
|
+
*
|
|
75
|
+
* @returns {{ active: string[], disabled_by_env: boolean }}
|
|
76
|
+
*/
|
|
77
|
+
getSummary() {
|
|
78
|
+
return {
|
|
79
|
+
active: [...this.active.keys()],
|
|
80
|
+
disabled_by_env: process.env.WP_DISABLE_PLUGIN_LAYERS === 'true',
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Register an adapter's tools for a detected plugin.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} pluginId
|
|
88
|
+
* @param {object[]} tools
|
|
89
|
+
*/
|
|
90
|
+
registerTools(pluginId, tools) {
|
|
91
|
+
const entry = this.active.get(pluginId);
|
|
92
|
+
if (entry) entry.tools = tools;
|
|
93
|
+
}
|
|
94
|
+
}
|