@adsim/wordpress-mcp-server 4.5.1 → 5.1.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/.env.example +18 -0
- package/README.md +857 -447
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +718 -90
- package/index.js +188 -4747
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- 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/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -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/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/siteOptions.test.js +101 -0
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adsim/wordpress-mcp-server",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
|
+
"description": "Enterprise WordPress MCP Server — 180 tools, modular architecture, governance, audit trail, WooCommerce, Schema.org, multilingual.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"bin": {
|
|
@@ -24,18 +24,26 @@
|
|
|
24
24
|
"claude",
|
|
25
25
|
"ai",
|
|
26
26
|
"automation",
|
|
27
|
-
"cms"
|
|
27
|
+
"cms",
|
|
28
|
+
"seo",
|
|
29
|
+
"woocommerce",
|
|
30
|
+
"schema-org",
|
|
31
|
+
"multilingual",
|
|
32
|
+
"security-audit",
|
|
33
|
+
"performance",
|
|
34
|
+
"enterprise",
|
|
35
|
+
"full-site-editing"
|
|
28
36
|
],
|
|
29
37
|
"author": "Georges Cordewiener <georges@adsim.be>",
|
|
30
38
|
"license": "MIT",
|
|
31
39
|
"repository": {
|
|
32
40
|
"type": "git",
|
|
33
|
-
"url": "git+https://github.com/
|
|
41
|
+
"url": "git+https://github.com/GeorgesAdSim/wordpress-mcp-server.git"
|
|
34
42
|
},
|
|
35
43
|
"bugs": {
|
|
36
|
-
"url": "https://github.com/
|
|
44
|
+
"url": "https://github.com/GeorgesAdSim/wordpress-mcp-server/issues"
|
|
37
45
|
},
|
|
38
|
-
"homepage": "https://github.com/
|
|
46
|
+
"homepage": "https://github.com/GeorgesAdSim/wordpress-mcp-server#readme",
|
|
39
47
|
"engines": {
|
|
40
48
|
"node": ">=18.0.0"
|
|
41
49
|
},
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_meta": {
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Estimated performance impact of common WordPress plugins. Impact scores are relative (1=minimal, 5=heavy). Data compiled from WebPageTest benchmarks, Query Monitor profiling, and community reports.",
|
|
5
|
+
"last_updated": "2025-03-01"
|
|
6
|
+
},
|
|
7
|
+
"plugins": {
|
|
8
|
+
"elementor/elementor.php": { "name": "Elementor", "impact": 4, "category": "page-builder", "notes": "Heavy DOM, inline CSS, multiple JS files. Use with landing pages only if possible." },
|
|
9
|
+
"elementor-pro/elementor-pro.php": { "name": "Elementor Pro", "impact": 5, "category": "page-builder", "notes": "Adds motion effects, popups, form widgets. Significant render cost." },
|
|
10
|
+
"js_composer/js_composer.php": { "name": "WPBakery Page Builder", "impact": 5, "category": "page-builder", "notes": "Legacy shortcode-based builder. Heavy inline styles and scripts." },
|
|
11
|
+
"beaver-builder-lite-version/fl-builder.php": { "name": "Beaver Builder", "impact": 3, "category": "page-builder", "notes": "Cleaner output than Elementor. Moderate JS footprint." },
|
|
12
|
+
"divi-builder/divi-builder.php": { "name": "Divi Builder", "impact": 5, "category": "page-builder", "notes": "Very heavy CSS and JS. One of the heaviest page builders." },
|
|
13
|
+
"wordpress-seo/wp-seo.php": { "name": "Yoast SEO", "impact": 2, "category": "seo", "notes": "Schema output and sitemap generation. Moderate admin overhead." },
|
|
14
|
+
"seo-by-rank-math/rank-math.php": { "name": "Rank Math", "impact": 2, "category": "seo", "notes": "Similar to Yoast. Slightly leaner frontend output." },
|
|
15
|
+
"wp-seopress/seopress.php": { "name": "SEOPress", "impact": 1, "category": "seo", "notes": "Lightweight SEO plugin with minimal frontend impact." },
|
|
16
|
+
"all-in-one-seo-pack/all_in_one_seo_pack.php": { "name": "All in One SEO", "impact": 2, "category": "seo", "notes": "Moderate impact, similar to Yoast." },
|
|
17
|
+
"woocommerce/woocommerce.php": { "name": "WooCommerce", "impact": 4, "category": "ecommerce", "notes": "Large plugin with many DB queries. Cart fragments AJAX is a known bottleneck." },
|
|
18
|
+
"woocommerce-payments/woocommerce-payments.php": { "name": "WooCommerce Payments", "impact": 2, "category": "ecommerce", "notes": "External Stripe JS loaded on checkout." },
|
|
19
|
+
"contact-form-7/wp-contact-form-7.php": { "name": "Contact Form 7", "impact": 2, "category": "forms", "notes": "Loads CSS/JS on all pages by default. Use conditional loading." },
|
|
20
|
+
"wpforms-lite/wpforms.php": { "name": "WPForms Lite", "impact": 2, "category": "forms", "notes": "Loads assets only on pages with forms (better than CF7 defaults)." },
|
|
21
|
+
"gravityforms/gravityforms.php": { "name": "Gravity Forms", "impact": 2, "category": "forms", "notes": "Premium form plugin. Moderate impact, conditional loading available." },
|
|
22
|
+
"ninja-forms/ninja-forms.php": { "name": "Ninja Forms", "impact": 3, "category": "forms", "notes": "React-based frontend can be heavy. Multiple JS bundles." },
|
|
23
|
+
"wordfence/wordfence.php": { "name": "Wordfence", "impact": 3, "category": "security", "notes": "Filesystem scanning and firewall rules add server-side latency. Minimal frontend impact." },
|
|
24
|
+
"better-wp-security/better-wp-security.php": { "name": "iThemes Security", "impact": 2, "category": "security", "notes": "Moderate server-side overhead for brute force protection and scanning." },
|
|
25
|
+
"sucuri-scanner/sucuri.php": { "name": "Sucuri Security", "impact": 2, "category": "security", "notes": "File integrity monitoring. Low frontend impact when using CDN firewall." },
|
|
26
|
+
"all-in-one-wp-security-and-firewall/wp-security.php": { "name": "All In One WP Security", "impact": 2, "category": "security", "notes": "Feature-rich security plugin. Moderate DB overhead." },
|
|
27
|
+
"wp-rocket/wp-rocket.php": { "name": "WP Rocket", "impact": -3, "category": "performance", "notes": "Page caching, CSS/JS minification, lazy loading. Significant positive impact." },
|
|
28
|
+
"w3-total-cache/w3-total-cache.php": { "name": "W3 Total Cache", "impact": -2, "category": "performance", "notes": "Complex caching plugin. Positive impact when configured correctly." },
|
|
29
|
+
"litespeed-cache/litespeed-cache.php": { "name": "LiteSpeed Cache", "impact": -3, "category": "performance", "notes": "Server-level cache integration. Excellent performance on LiteSpeed servers." },
|
|
30
|
+
"wp-super-cache/wp-cache.php": { "name": "WP Super Cache", "impact": -2, "category": "performance", "notes": "Simple page caching. Good basic performance improvement." },
|
|
31
|
+
"autoptimize/autoptimize.php": { "name": "Autoptimize", "impact": -2, "category": "performance", "notes": "CSS/JS minification and concatenation. Good complement to caching plugins." },
|
|
32
|
+
"wp-fastest-cache/wpFastestCache.php": { "name": "WP Fastest Cache", "impact": -2, "category": "performance", "notes": "Simple caching with minification. Easy to configure." },
|
|
33
|
+
"jetpack/jetpack.php": { "name": "Jetpack", "impact": 4, "category": "multi-purpose", "notes": "Loads many modules by default. External API calls on every page load. Disable unused modules." },
|
|
34
|
+
"jetpack-boost/jetpack-boost.php": { "name": "Jetpack Boost", "impact": -2, "category": "performance", "notes": "Lightweight performance module. CSS optimization and lazy images." },
|
|
35
|
+
"akismet/akismet.php": { "name": "Akismet", "impact": 1, "category": "anti-spam", "notes": "External API call for comment processing. Minimal frontend impact." },
|
|
36
|
+
"classic-editor/classic-editor.php": { "name": "Classic Editor", "impact": 0, "category": "editor", "notes": "Disables Gutenberg. No frontend impact." },
|
|
37
|
+
"advanced-custom-fields/acf.php": { "name": "Advanced Custom Fields", "impact": 1, "category": "custom-fields", "notes": "Minimal frontend impact. Admin-side overhead for field rendering." },
|
|
38
|
+
"advanced-custom-fields-pro/acf.php": { "name": "ACF Pro", "impact": 1, "category": "custom-fields", "notes": "Same as ACF with additional field types. Low impact." },
|
|
39
|
+
"revslider/revslider.php": { "name": "Slider Revolution", "impact": 5, "category": "slider", "notes": "Very heavy. Large JS/CSS payload, render-blocking. Consider alternatives." },
|
|
40
|
+
"LayerSlider/layerslider.php": { "name": "LayerSlider", "impact": 4, "category": "slider", "notes": "Heavy animation library. Significant CLS and LCP impact." },
|
|
41
|
+
"smart-slider-3/smart-slider-3.php": { "name": "Smart Slider 3", "impact": 3, "category": "slider", "notes": "Moderate impact. Lazy loading available." },
|
|
42
|
+
"wp-smushit/wp-smush.php": { "name": "Smush", "impact": -1, "category": "image-optimization", "notes": "Image compression and lazy loading. Positive impact on image-heavy sites." },
|
|
43
|
+
"imagify/imagify.php": { "name": "Imagify", "impact": -1, "category": "image-optimization", "notes": "Image optimization with WebP conversion. Reduces page weight." },
|
|
44
|
+
"shortpixel-image-optimiser/wp-shortpixel.php": { "name": "ShortPixel", "impact": -1, "category": "image-optimization", "notes": "Image compression service. Low overhead, good optimization." },
|
|
45
|
+
"ewww-image-optimizer/ewww-image-optimizer.php": { "name": "EWWW Image Optimizer", "impact": -1, "category": "image-optimization", "notes": "Local and cloud image optimization. WebP support." },
|
|
46
|
+
"google-analytics-for-wordpress/googleanalytics.php": { "name": "MonsterInsights", "impact": 3, "category": "analytics", "notes": "Loads GA script plus its own tracking layer. Use native GA tag instead." },
|
|
47
|
+
"google-site-kit/google-site-kit.php": { "name": "Google Site Kit", "impact": 2, "category": "analytics", "notes": "Admin dashboard overhead. Minimal frontend impact if using native GA tag." },
|
|
48
|
+
"mailchimp-for-wp/mailchimp-for-wp.php": { "name": "MC4WP Mailchimp", "impact": 1, "category": "email-marketing", "notes": "Minimal impact. Loads form assets conditionally." },
|
|
49
|
+
"the-events-calendar/the-events-calendar.php": { "name": "The Events Calendar", "impact": 3, "category": "events", "notes": "Custom post type with complex queries. Moderate frontend overhead." },
|
|
50
|
+
"tablepress/tablepress.php": { "name": "TablePress", "impact": 2, "category": "content", "notes": "DataTables JS library loaded on table pages. Moderate impact." },
|
|
51
|
+
"updraftplus/updraftplus.php": { "name": "UpdraftPlus", "impact": 1, "category": "backup", "notes": "Runs on cron schedule. No frontend impact during normal browsing." },
|
|
52
|
+
"duplicator/duplicator.php": { "name": "Duplicator", "impact": 0, "category": "backup", "notes": "Only active during migrations. Zero runtime impact." },
|
|
53
|
+
"redirection/redirection.php": { "name": "Redirection", "impact": 1, "category": "seo", "notes": "DB query per request for redirect rules. Minimal for small rule sets." },
|
|
54
|
+
"broken-link-checker/broken-link-checker.php": { "name": "Broken Link Checker", "impact": 4, "category": "seo", "notes": "Background scanning causes high server load. Use sparingly or via CLI." },
|
|
55
|
+
"regenerate-thumbnails/regenerate-thumbnails.php": { "name": "Regenerate Thumbnails", "impact": 0, "category": "media", "notes": "Only active when running regeneration. Zero runtime impact." },
|
|
56
|
+
"wordpress-popular-posts/wordpress-popular-posts.php": { "name": "WordPress Popular Posts", "impact": 3, "category": "content", "notes": "DB writes on every page view for tracking. Use caching or external tracking." },
|
|
57
|
+
"amp/amp.php": { "name": "AMP", "impact": 2, "category": "performance", "notes": "Generates AMP versions. Can conflict with other plugins. Mixed impact." }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -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
|
+
}
|